gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

feat(charka): charka-bcd — aritmética decimal con semántica COBOL

Cimiento numérico del transpilador. Picture parsea la cláusula
PICTURE (9, V, S, 9(n)); Decimal es punto fijo exacto (mantissa i128
+ scale) con suma/resta/producto exactos, división con escala de
resultado fija, redondeo Truncate/HalfUp y coerce a un Picture con
detección de desbordamiento (ON SIZE ERROR).

22 tests. Determinista, sin deps de plataforma — base de Fase D.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

737ae5a696250c7e323aafdcdbc7f3adf985e0ff · por sergio · (hace 5 meses) · padre 9e7fa1741 · ver el árbol en este commit

Delta semántico · 56

crates/modules/charka/charka-bcd/src/decimal.rs

crates/modules/charka/charka-bcd/src/lib.rs

crates/modules/charka/charka-bcd/src/picture.rs

8 archivos · +653 −0

diff --git a/.vamos.txt.kate-swp b/.vamos.txt.kate-swp
index 917c06c..65b49be 100644
Binary files a/.vamos.txt.kate-swp and b/.vamos.txt.kate-swp differ
diff --git a/Cargo.lock b/Cargo.lock
index 6c975e3..b82ea19 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2205,6 +2205,14 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "charka-bcd"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "thiserror 2.0.18",
+]
+
 [[package]]
 name = "chasqui-card"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 70a0aba..c654f25 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -148,6 +148,11 @@ members = [
     # ============================================================
     "crates/modules/yachay/yachay-core",
 
+    # ============================================================
+    # modules/charka/ — Transpilador COBOL → Rust
+    # ============================================================
+    "crates/modules/charka/charka-bcd",
+
     # ============================================================
     # modules/nakui/ — ERP matemático (categórico)
     # ============================================================
diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md
new file mode 100644
index 0000000..4849f7d
--- /dev/null
+++ b/crates/modules/charka/SDD.md
@@ -0,0 +1,43 @@
+# modules/charka/ — Transpilador COBOL → Rust
+
+**Propósito.** Modernizar sistemas COBOL legados transpilándolos a Rust
+con un runtime determinista y un validador en sombra (shadow validator)
+que compara la salida del original y la del transpilado. Es el módulo
+más grande del ecosistema — el parser COBOL completo (CICS, SQL
+embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes.
+
+## Crates
+
+| crate        | tipo | rol                                                          |
+| ------------ | ---- | ------------------------------------------------------------ |
+| `charka-bcd` | lib  | Aritmética decimal de punto fijo con semántica COBOL: `Picture`, `Decimal`, redondeo, `ON SIZE ERROR` |
+
+## charka-bcd
+
+COBOL no calcula en binario flotante: opera sobre campos decimales de
+precisión fija (`PIC S9(5)V99`). Reproducir un programa COBOL exige
+reproducir esa aritmética dígito a dígito.
+
+- `Picture` — parsea la cláusula PICTURE numérica (`9`, `V`, `S`, `9(n)`).
+- `Decimal` — punto fijo exacto (`mantissa: i128` + `scale`); suma, resta
+  y producto exactos; división con escala de resultado fija; redondeo
+  `Truncate`/`HalfUp`; `coerce` a un `Picture` con detección de
+  desbordamiento.
+- Determinista, sin dependencias de plataforma — mismo programa, mismos
+  dígitos, en cualquier máquina.
+
+## Estado
+
+`charka-bcd` implementado y verde (22 tests). **Pendiente** — el grueso
+del transpilador (esfuerzo multi-mes, Fase D del plan macro):
+
+| crate pendiente   | rol                                                  |
+| ----------------- | ---------------------------------------------------- |
+| `charka-lexer`    | tokenizador COBOL (formato fijo de columnas)         |
+| `charka-parser`   | parser COBOL'85 → AST (luego CICS + SQL embebido)    |
+| `charka-ir`       | representación intermedia                            |
+| `charka-codegen`  | emisión de Rust                                      |
+| `charka-shadow`   | validador en sombra (original vs transpilado)        |
+| `charka-runtime`  | runtime determinista (sobre `charka-bcd`)            |
+
+Hito intermedio sugerido: subconjunto COBOL'85 puro antes de CICS/SQL.
diff --git a/crates/modules/charka/charka-bcd/Cargo.toml b/crates/modules/charka/charka-bcd/Cargo.toml
new file mode 100644
index 0000000..85b236c
--- /dev/null
+++ b/crates/modules/charka/charka-bcd/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "charka-bcd"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "charka — aritmética decimal de punto fijo con semántica COBOL: cláusula PICTURE, Decimal exacto, truncado/redondeo y detección de SIZE ERROR."
+
+[dependencies]
+serde = { workspace = true }
+thiserror = { workspace = true }
diff --git a/crates/modules/charka/charka-bcd/src/decimal.rs b/crates/modules/charka/charka-bcd/src/decimal.rs
new file mode 100644
index 0000000..433521f
--- /dev/null
+++ b/crates/modules/charka/charka-bcd/src/decimal.rs
@@ -0,0 +1,400 @@
+//! `Decimal` — un número decimal de punto fijo, exacto.
+//!
+//! COBOL no calcula en binario flotante: sus campos numéricos son
+//! decimales de precisión fija. Un `Decimal` se guarda como una mantisa
+//! entera (`i128`) y una escala (cuántos de sus dígitos son
+//! fraccionarios) — `valor = mantisa / 10^escala`. La aritmética es
+//! exacta; la pérdida de precisión sólo ocurre, explícita, al ajustar a
+//! la escala de un campo receptor.
+//!
+//! Dominio: hasta 38 dígitos significativos (el rango de `i128`).
+
+use std::cmp::Ordering;
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+
+use crate::picture::Picture;
+use crate::BcdError;
+
+/// Modo de redondeo al perder dígitos fraccionarios.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Rounding {
+    /// Descartar los dígitos sobrantes — el comportamiento por defecto
+    /// de COBOL.
+    Truncate,
+    /// Redondear al más cercano, la mitad alejándose de cero — la
+    /// opción `ROUNDED` de COBOL.
+    HalfUp,
+}
+
+/// `10^n` como `i128`. Pánico si `n` excede el rango (n > 38).
+fn pow10(n: u32) -> i128 {
+    10i128.pow(n)
+}
+
+/// Cuántos dígitos decimales tiene `n` (`0` tiene cero dígitos).
+fn digit_count(mut n: u128) -> u32 {
+    let mut d = 0;
+    while n > 0 {
+        n /= 10;
+        d += 1;
+    }
+    d
+}
+
+/// Un decimal de punto fijo exacto: `mantissa / 10^scale`.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct Decimal {
+    mantissa: i128,
+    scale: u8,
+}
+
+impl Decimal {
+    /// Construye desde mantisa y escala crudas.
+    pub fn new(mantissa: i128, scale: u8) -> Self {
+        Self { mantissa, scale }
+    }
+
+    /// El cero (escala 0).
+    pub fn zero() -> Self {
+        Self { mantissa: 0, scale: 0 }
+    }
+
+    /// Un entero como decimal de escala 0.
+    pub fn from_integer(value: i128) -> Self {
+        Self { mantissa: value, scale: 0 }
+    }
+
+    /// Mantisa cruda.
+    pub fn mantissa(&self) -> i128 {
+        self.mantissa
+    }
+
+    /// Escala — cantidad de dígitos fraccionarios.
+    pub fn scale(&self) -> u8 {
+        self.scale
+    }
+
+    pub fn is_zero(&self) -> bool {
+        self.mantissa == 0
+    }
+
+    pub fn is_negative(&self) -> bool {
+        self.mantissa < 0
+    }
+
+    /// Parsea un literal numérico — `"123.45"`, `"-7"`, `"+0.001"`, `".5"`.
+    pub fn parse(src: &str) -> Result<Decimal, BcdError> {
+        let t = src.trim();
+        let bad = || BcdError::BadNumber(src.to_string());
+        let (neg, rest) = match t.strip_prefix('-') {
+            Some(r) => (true, r),
+            None => (false, t.strip_prefix('+').unwrap_or(t)),
+        };
+        if !rest.bytes().any(|b| b.is_ascii_digit()) {
+            return Err(bad());
+        }
+        let (int_str, frac_str) = rest.split_once('.').unwrap_or((rest, ""));
+        let int_str = if int_str.is_empty() { "0" } else { int_str };
+        if !int_str.bytes().all(|b| b.is_ascii_digit())
+            || !frac_str.bytes().all(|b| b.is_ascii_digit())
+        {
+            return Err(bad());
+        }
+        let mut mantissa: i128 =
+            format!("{int_str}{frac_str}").parse().map_err(|_| bad())?;
+        if neg {
+            mantissa = -mantissa;
+        }
+        Ok(Decimal { mantissa, scale: frac_str.len() as u8 })
+    }
+
+    /// Devuelve el mismo valor expresado en `target_scale`. Subir de
+    /// escala es exacto; bajar pierde dígitos según `rounding`.
+    pub fn rescale(&self, target_scale: u8, rounding: Rounding) -> Decimal {
+        match target_scale.cmp(&self.scale) {
+            Ordering::Equal => *self,
+            Ordering::Greater => {
+                let factor = pow10((target_scale - self.scale) as u32);
+                Decimal { mantissa: self.mantissa * factor, scale: target_scale }
+            }
+            Ordering::Less => {
+                let divisor = pow10((self.scale - target_scale) as u32);
+                let q = self.mantissa / divisor;
+                let r = self.mantissa % divisor;
+                let m = match rounding {
+                    Rounding::Truncate => q,
+                    Rounding::HalfUp => {
+                        if r.unsigned_abs() * 2 >= divisor.unsigned_abs() {
+                            q + self.mantissa.signum()
+                        } else {
+                            q
+                        }
+                    }
+                };
+                Decimal { mantissa: m, scale: target_scale }
+            }
+        }
+    }
+
+    /// Lleva dos decimales a una escala común (la mayor, sin pérdida).
+    fn aligned(&self, other: &Decimal) -> (i128, i128, u8) {
+        let s = self.scale.max(other.scale);
+        (
+            self.rescale(s, Rounding::Truncate).mantissa,
+            other.rescale(s, Rounding::Truncate).mantissa,
+            s,
+        )
+    }
+
+    /// Suma exacta.
+    pub fn add(&self, other: &Decimal) -> Decimal {
+        let (a, b, s) = self.aligned(other);
+        Decimal { mantissa: a + b, scale: s }
+    }
+
+    /// Resta exacta.
+    pub fn sub(&self, other: &Decimal) -> Decimal {
+        let (a, b, s) = self.aligned(other);
+        Decimal { mantissa: a - b, scale: s }
+    }
+
+    /// Producto exacto — la escala del resultado es la suma de escalas.
+    pub fn mul(&self, other: &Decimal) -> Decimal {
+        Decimal {
+            mantissa: self.mantissa * other.mantissa,
+            scale: self.scale.saturating_add(other.scale),
+        }
+    }
+
+    /// División con la escala del resultado fijada de antemano (como en
+    /// COBOL, donde el campo receptor define la precisión). Error si el
+    /// divisor es cero o si un producto intermedio se sale de `i128`.
+    pub fn div(
+        &self,
+        other: &Decimal,
+        result_scale: u8,
+        rounding: Rounding,
+    ) -> Result<Decimal, BcdError> {
+        if other.mantissa == 0 {
+            return Err(BcdError::DivByZero);
+        }
+        let num_pow = other.scale as u32 + result_scale as u32;
+        let numerator = self
+            .mantissa
+            .checked_mul(10i128.checked_pow(num_pow).ok_or(BcdError::Overflow)?)
+            .ok_or(BcdError::Overflow)?;
+        let denominator = other
+            .mantissa
+            .checked_mul(10i128.checked_pow(self.scale as u32).ok_or(BcdError::Overflow)?)
+            .ok_or(BcdError::Overflow)?;
+        let q = numerator / denominator;
+        let r = numerator % denominator;
+        let m = match rounding {
+            Rounding::Truncate => q,
+            Rounding::HalfUp => {
+                if r.unsigned_abs() * 2 >= denominator.unsigned_abs() {
+                    q + numerator.signum() * denominator.signum()
+                } else {
+                    q
+                }
+            }
+        };
+        Ok(Decimal { mantissa: m, scale: result_scale })
+    }
+
+    /// `true` si el valor entra en el campo `pic` sin perder dígitos
+    /// enteros (la parte fraccionaria se ajusta, no desborda).
+    pub fn fits(&self, pic: &Picture) -> bool {
+        let r = self.rescale(pic.fraction_digits, Rounding::Truncate);
+        let int_part = r.mantissa.unsigned_abs() / 10u128.pow(pic.fraction_digits as u32);
+        digit_count(int_part) <= pic.integer_digits as u32
+    }
+
+    /// Almacena el valor en un campo `pic` — la operación `MOVE` de
+    /// COBOL. Ajusta la escala con `rounding`; un campo sin signo guarda
+    /// la magnitud; si la parte entera no cabe devuelve [`BcdError::Overflow`]
+    /// (el `ON SIZE ERROR` de COBOL).
+    pub fn coerce(&self, pic: &Picture, rounding: Rounding) -> Result<Decimal, BcdError> {
+        let mut r = self.rescale(pic.fraction_digits, rounding);
+        if !pic.signed && r.mantissa < 0 {
+            r.mantissa = -r.mantissa;
+        }
+        let int_part = r.mantissa.unsigned_abs() / 10u128.pow(pic.fraction_digits as u32);
+        if digit_count(int_part) > pic.integer_digits as u32 {
+            return Err(BcdError::Overflow);
+        }
+        Ok(r)
+    }
+}
+
+impl PartialEq for Decimal {
+    /// Dos decimales son iguales si representan el mismo valor — `1.0`
+    /// es igual a `1.00`.
+    fn eq(&self, other: &Self) -> bool {
+        self.cmp(other) == Ordering::Equal
+    }
+}
+
+impl Eq for Decimal {}
+
+impl PartialOrd for Decimal {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for Decimal {
+    fn cmp(&self, other: &Self) -> Ordering {
+        let (a, b, _) = self.aligned(other);
+        a.cmp(&b)
+    }
+}
+
+impl fmt::Display for Decimal {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if self.mantissa < 0 {
+            write!(f, "-")?;
+        }
+        let abs = self.mantissa.unsigned_abs();
+        if self.scale == 0 {
+            return write!(f, "{abs}");
+        }
+        let s = self.scale as usize;
+        let mut digits = abs.to_string();
+        if digits.len() <= s {
+            // Rellena para que haya al menos un dígito entero (`0.xx`).
+            digits = format!("{}{}", "0".repeat(s - digits.len() + 1), digits);
+        }
+        let (int_part, frac_part) = digits.split_at(digits.len() - s);
+        write!(f, "{int_part}.{frac_part}")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn d(s: &str) -> Decimal {
+        Decimal::parse(s).unwrap()
+    }
+
+    #[test]
+    fn parse_and_display_roundtrip() {
+        assert_eq!(d("123.45").to_string(), "123.45");
+        assert_eq!(d("-7").to_string(), "-7");
+        assert_eq!(d("0.001").to_string(), "0.001");
+        assert_eq!(d(".5").to_string(), "0.5");
+        assert_eq!(d("+42").to_string(), "42");
+    }
+
+    #[test]
+    fn parse_rejects_garbage() {
+        assert!(Decimal::parse("abc").is_err());
+        assert!(Decimal::parse("").is_err());
+        assert!(Decimal::parse("1.2.3").is_err());
+        assert!(Decimal::parse("-").is_err());
+    }
+
+    #[test]
+    fn addition_aligns_scales() {
+        // 1.5 + 2.25 = 3.75
+        assert_eq!(d("1.5").add(&d("2.25")), d("3.75"));
+        // 100 + 0.001 = 100.001
+        assert_eq!(d("100").add(&d("0.001")), d("100.001"));
+    }
+
+    #[test]
+    fn subtraction_is_exact() {
+        assert_eq!(d("10.00").sub(&d("3.33")), d("6.67"));
+    }
+
+    #[test]
+    fn multiplication_sums_scales() {
+        // 1.5 * 1.5 = 2.25
+        let p = d("1.5").mul(&d("1.5"));
+        assert_eq!(p, d("2.25"));
+        assert_eq!(p.scale(), 2);
+    }
+
+    #[test]
+    fn equality_ignores_trailing_zeros() {
+        assert_eq!(d("1.0"), d("1.00"));
+        assert_eq!(d("2"), d("2.000"));
+    }
+
+    #[test]
+    fn ordering_compares_values() {
+        assert!(d("1.9") < d("1.91"));
+        assert!(d("-5") < d("0.0"));
+        assert!(d("100.0") > d("99.99"));
+    }
+
+    #[test]
+    fn rescale_truncates_toward_zero() {
+        assert_eq!(d("1.999").rescale(2, Rounding::Truncate), d("1.99"));
+        assert_eq!(d("-1.999").rescale(2, Rounding::Truncate), d("-1.99"));
+    }
+
+    #[test]
+    fn rescale_half_up_rounds_away_from_zero() {
+        assert_eq!(d("1.995").rescale(2, Rounding::HalfUp), d("2.00"));
+        assert_eq!(d("-1.995").rescale(2, Rounding::HalfUp), d("-2.00"));
+        assert_eq!(d("1.994").rescale(2, Rounding::HalfUp), d("1.99"));
+    }
+
+    #[test]
+    fn division_respects_result_scale() {
+        // 10 / 3 a 4 decimales, truncado.
+        let q = d("10").div(&d("3"), 4, Rounding::Truncate).unwrap();
+        assert_eq!(q, d("3.3333"));
+        // Redondeado.
+        let r = d("10").div(&d("3"), 4, Rounding::HalfUp).unwrap();
+        assert_eq!(r, d("3.3333"));
+        // 7 / 8 = 0.875 exacto.
+        assert_eq!(d("7").div(&d("8"), 3, Rounding::Truncate).unwrap(), d("0.875"));
+    }
+
+    #[test]
+    fn division_by_zero_errors() {
+        assert_eq!(d("1").div(&d("0"), 2, Rounding::Truncate), Err(BcdError::DivByZero));
+    }
+
+    #[test]
+    fn coerce_into_picture_adjusts_scale() {
+        let pic = Picture::parse("9(5)V99").unwrap();
+        let stored = d("12.5").coerce(&pic, Rounding::Truncate).unwrap();
+        assert_eq!(stored.scale(), 2);
+        assert_eq!(stored, d("12.50"));
+    }
+
+    #[test]
+    fn coerce_overflow_is_a_size_error() {
+        let pic = Picture::parse("9(3)").unwrap(); // máx 999
+        assert_eq!(d("1000").coerce(&pic, Rounding::Truncate), Err(BcdError::Overflow));
+        assert!(d("999").coerce(&pic, Rounding::Truncate).is_ok());
+    }
+
+    #[test]
+    fn coerce_rounding_can_trigger_overflow() {
+        // 999.6 redondeado a entero → 1000, que ya no cabe en 9(3).
+        let pic = Picture::parse("9(3)").unwrap();
+        assert_eq!(d("999.6").coerce(&pic, Rounding::HalfUp), Err(BcdError::Overflow));
+    }
+
+    #[test]
+    fn unsigned_picture_stores_magnitude() {
+        let pic = Picture::parse("9(4)V99").unwrap(); // sin S
+        let stored = d("-12.34").coerce(&pic, Rounding::Truncate).unwrap();
+        assert!(!stored.is_negative());
+        assert_eq!(stored, d("12.34"));
+    }
+
+    #[test]
+    fn signed_picture_keeps_the_sign() {
+        let pic = Picture::parse("S9(4)V99").unwrap();
+        let stored = d("-12.34").coerce(&pic, Rounding::Truncate).unwrap();
+        assert!(stored.is_negative());
+    }
+}
diff --git a/crates/modules/charka/charka-bcd/src/lib.rs b/crates/modules/charka/charka-bcd/src/lib.rs
new file mode 100644
index 0000000..1d4de77
--- /dev/null
+++ b/crates/modules/charka/charka-bcd/src/lib.rs
@@ -0,0 +1,36 @@
+//! `charka-bcd` — aritmética decimal con semántica COBOL.
+//!
+//! El corazón numérico del transpilador charka. COBOL no calcula en
+//! binario flotante: opera sobre campos decimales de precisión fija
+//! declarados con una cláusula `PICTURE`. Reproducir un programa COBOL
+//! fielmente exige reproducir esa aritmética dígito a dígito — eso es lo
+//! que da este crate.
+//!
+//! - [`picture`] — la [`Picture`], forma declarada de un campo numérico.
+//! - [`decimal`] — el [`Decimal`] de punto fijo exacto + redondeo +
+//!   detección de desbordamiento (`ON SIZE ERROR`).
+//!
+//! Determinista y sin dependencias de plataforma: mismo programa, mismos
+//! dígitos, en cualquier máquina. El lexer, el parser, el IR y el codegen
+//! de charka se construyen sobre este cimiento.
+
+#![forbid(unsafe_code)]
+
+pub mod decimal;
+pub mod picture;
+
+pub use decimal::{Decimal, Rounding};
+pub use picture::Picture;
+
+/// Falla de una operación decimal o de una cláusula PICTURE.
+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
+pub enum BcdError {
+    #[error("cláusula PICTURE inválida: {0}")]
+    BadPicture(String),
+    #[error("literal numérico inválido: {0}")]
+    BadNumber(String),
+    #[error("división por cero")]
+    DivByZero,
+    #[error("desbordamiento de campo (ON SIZE ERROR)")]
+    Overflow,
+}
diff --git a/crates/modules/charka/charka-bcd/src/picture.rs b/crates/modules/charka/charka-bcd/src/picture.rs
new file mode 100644
index 0000000..03e3b7c
--- /dev/null
+++ b/crates/modules/charka/charka-bcd/src/picture.rs
@@ -0,0 +1,149 @@
+//! La cláusula `PICTURE` — la forma declarada de un campo numérico COBOL.
+//!
+//! Sólo el subconjunto numérico: `9` (dígito), `V` (punto decimal
+//! implícito), `S` (signo), y la repetición `9(n)`. Lo de edición
+//! (`Z`, `*`, `,`, `.`, `$`, `B`…) es presentación y se trata aparte.
+
+use serde::{Deserialize, Serialize};
+
+use crate::BcdError;
+
+/// La forma de un campo numérico: cuántos dígitos enteros, cuántos
+/// fraccionarios y si admite signo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Picture {
+    pub integer_digits: u8,
+    pub fraction_digits: u8,
+    pub signed: bool,
+}
+
+impl Picture {
+    /// Construye una `Picture` directa.
+    pub fn new(integer_digits: u8, fraction_digits: u8, signed: bool) -> Self {
+        Self { integer_digits, fraction_digits, signed }
+    }
+
+    /// Dígitos totales del campo (enteros + fraccionarios).
+    pub fn total_digits(&self) -> u8 {
+        self.integer_digits + self.fraction_digits
+    }
+
+    /// Parsea una cláusula PICTURE — `"9(5)V99"`, `"S9(3)"`, `"9999"`.
+    /// Acepta el prefijo `PIC ` / `PICTURE ` opcional.
+    pub fn parse(src: &str) -> Result<Picture, BcdError> {
+        let up = src.trim().to_ascii_uppercase();
+        let body = up
+            .strip_prefix("PICTURE ")
+            .or_else(|| up.strip_prefix("PIC "))
+            .unwrap_or(&up)
+            .trim();
+
+        let chars: Vec<char> = body.chars().collect();
+        let mut i = 0;
+        let mut signed = false;
+        let mut integer_digits: u32 = 0;
+        let mut fraction_digits: u32 = 0;
+        let mut seen_v = false;
+
+        // El signo, si lo hay, va primero.
+        if chars.first() == Some(&'S') {
+            signed = true;
+            i = 1;
+        }
+
+        let bad = || BcdError::BadPicture(src.to_string());
+
+        while i < chars.len() {
+            match chars[i] {
+                'V' => {
+                    if seen_v {
+                        return Err(bad()); // dos puntos decimales
+                    }
+                    seen_v = true;
+                    i += 1;
+                }
+                '9' => {
+                    // Cuenta este 9 y un posible '(n)' que lo siga.
+                    let mut count: u32 = 1;
+                    i += 1;
+                    if chars.get(i) == Some(&'(') {
+                        i += 1;
+                        let start = i;
+                        while i < chars.len() && chars[i].is_ascii_digit() {
+                            i += 1;
+                        }
+                        if start == i || chars.get(i) != Some(&')') {
+                            return Err(bad());
+                        }
+                        let n: u32 = chars[start..i]
+                            .iter()
+                            .collect::<String>()
+                            .parse()
+                            .map_err(|_| bad())?;
+                        count = n;
+                        i += 1; // consume ')'
+                    }
+                    if seen_v {
+                        fraction_digits += count;
+                    } else {
+                        integer_digits += count;
+                    }
+                }
+                _ => return Err(bad()),
+            }
+        }
+
+        let total = integer_digits + fraction_digits;
+        if total == 0 || total > 38 {
+            // i128 soporta 38 dígitos decimales.
+            return Err(bad());
+        }
+        Ok(Picture {
+            integer_digits: integer_digits as u8,
+            fraction_digits: fraction_digits as u8,
+            signed,
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_integer_and_fraction() {
+        let p = Picture::parse("9(5)V99").unwrap();
+        assert_eq!(p, Picture::new(5, 2, false));
+    }
+
+    #[test]
+    fn parses_signed() {
+        let p = Picture::parse("S9(3)").unwrap();
+        assert_eq!(p, Picture::new(3, 0, true));
+        assert!(p.signed);
+    }
+
+    #[test]
+    fn parses_repeated_nines() {
+        assert_eq!(Picture::parse("9999V9").unwrap(), Picture::new(4, 1, false));
+    }
+
+    #[test]
+    fn accepts_pic_prefix() {
+        assert_eq!(Picture::parse("PIC 9(2)").unwrap(), Picture::new(2, 0, false));
+        assert_eq!(Picture::parse("PICTURE S9V9").unwrap(), Picture::new(1, 1, true));
+    }
+
+    #[test]
+    fn rejects_garbage_and_double_v() {
+        assert!(Picture::parse("X(3)").is_err());
+        assert!(Picture::parse("9V9V9").is_err());
+        assert!(Picture::parse("").is_err());
+        assert!(Picture::parse("9(").is_err());
+    }
+
+    #[test]
+    fn total_digits_sums_both_parts() {
+        assert_eq!(Picture::parse("9(7)V999").unwrap().total_digits(), 10);
+    }
+}