Skip to main content

kobold_csv/
controltotal.rs

1//! `KOBOLD.CSV.CONTROLTOTAL.1` -- the reconciliation killer feature.
2//!
3//! A batch CONTROL TOTAL is the figure banks reconcile a file against: the sum of an amount field across
4//! every record, plus the record count. If the control total of the source file matches the control total
5//! of the migrated/target file, the batch balanced; if not, money moved and someone must explain it.
6//!
7//! [`control_totals`] sums each named numeric leaf field across all records. Crucially it sums as EXACT
8//! integer-scaled values (the digits at the field's implied scale, as `i128`), NEVER as floating point --
9//! a `0.1 + 0.2 != 0.3` rounding drift in a financial control total is itself a defect. The sum is then
10//! rendered back at the field's scale for the report. A non-numeric byte in a field contributes a
11//! [`Finding`] and that record's value is treated as 0 for the sum (the finding makes the gap auditable).
12//!
13//! This module is independent of GnuCOBOL/libcob.
14
15use crate::export::decode_leaf;
16use crate::model::{Copybook, FieldKind, Finding};
17
18/// A control total for one numeric field across a record set.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ControlTotal {
21    /// The field name summed.
22    pub field: String,
23    /// The number of records included in the sum.
24    pub count: usize,
25    /// The exact sum, rendered at the field's implied scale (e.g. `12345.67`), sign included.
26    pub sum_rendered: String,
27    /// Findings raised while summing (e.g. a non-numeric value, or an unknown field name).
28    pub findings: Vec<Finding>,
29}
30
31/// `KOBOLD.CSV.CONTROLTOTAL.1` -- compute the control total of each field in `numeric_fields` across
32/// `records`, decoded per `copybook`. Unknown or non-numeric field names yield a `CONTROL_FIELD_UNKNOWN`
33/// finding with a zero sum (fail-closed: the reconciliation cannot silently skip a field it was told to sum).
34pub fn control_totals(
35    copybook: &Copybook,
36    records: &[&[u8]],
37    numeric_fields: &[&str],
38) -> Vec<ControlTotal> {
39    let leaves = copybook.leaf_fields();
40    let mut out = Vec::with_capacity(numeric_fields.len());
41
42    for &name in numeric_fields {
43        let decl = leaves.iter().find(|f| f.name == name);
44        let (scale, signed) = match decl.map(|d| &d.kind) {
45            Some(FieldKind::Numeric { scale, signed }) => (*scale, *signed),
46            Some(_) => {
47                out.push(ControlTotal {
48                    field: name.to_string(),
49                    count: records.len(),
50                    sum_rendered: "0".to_string(),
51                    findings: vec![Finding::new(
52                        "CONTROL_FIELD_NOT_NUMERIC",
53                        format!("field {} is not a numeric field; cannot sum", name),
54                    )],
55                });
56                continue;
57            }
58            None => {
59                out.push(ControlTotal {
60                    field: name.to_string(),
61                    count: records.len(),
62                    sum_rendered: "0".to_string(),
63                    findings: vec![Finding::new(
64                        "CONTROL_FIELD_UNKNOWN",
65                        format!("field {} is not a leaf field of copybook {}", name, copybook.record_name),
66                    )],
67                });
68                continue;
69            }
70        };
71        let decl = decl.unwrap();
72
73        let mut sum: i128 = 0;
74        let mut findings: Vec<Finding> = Vec::new();
75        for (ri, rec) in records.iter().enumerate() {
76            let dec = decode_leaf(decl, rec);
77            if !dec.findings.is_empty() {
78                for f in dec.findings {
79                    findings.push(Finding::new(
80                        f.code,
81                        format!("record {}: {} (treated as 0 in the sum)", ri, f.message),
82                    ));
83                }
84                continue;
85            }
86            match scaled_int(&dec.value, scale) {
87                Ok(v) => sum += v,
88                Err(msg) => findings.push(Finding::new(
89                    "CONTROL_VALUE_UNPARSED",
90                    format!("record {}: {} (treated as 0 in the sum)", ri, msg),
91                )),
92            }
93        }
94
95        out.push(ControlTotal {
96            field: name.to_string(),
97            count: records.len(),
98            sum_rendered: render_scaled(sum, scale, signed),
99            findings,
100        });
101    }
102
103    out
104}
105
106/// Parse a rendered numeric string (e.g. `-12.50`) into an exact integer scaled to `scale` decimal places.
107/// `-12.50` at scale 2 -> -1250. Fail-closed on a malformed value or too-many fraction digits.
108fn scaled_int(value: &str, scale: usize) -> Result<i128, String> {
109    let mut s = value.trim();
110    let mut negative = false;
111    if let Some(rest) = s.strip_prefix('-') {
112        negative = true;
113        s = rest;
114    } else if let Some(rest) = s.strip_prefix('+') {
115        s = rest;
116    }
117    let (int_str, frac_str) = match s.split_once('.') {
118        Some((i, f)) => (i, f),
119        None => (s, ""),
120    };
121    if int_str.is_empty() && frac_str.is_empty() {
122        return Err("empty numeric value".to_string());
123    }
124    for part in [int_str, frac_str] {
125        if !part.chars().all(|c| c.is_ascii_digit()) {
126            return Err(format!("non-numeric value {:?}", value));
127        }
128    }
129    if frac_str.len() > scale {
130        return Err(format!("value {:?} has more fraction digits than scale {}", value, scale));
131    }
132    let mut digits = String::new();
133    digits.push_str(int_str);
134    digits.push_str(frac_str);
135    for _ in 0..(scale - frac_str.len()) {
136        digits.push('0');
137    }
138    if digits.is_empty() {
139        digits.push('0');
140    }
141    let mag: i128 = digits.parse::<i128>().map_err(|_| format!("value {:?} overflows i128", value))?;
142    Ok(if negative { -mag } else { mag })
143}
144
145/// Render an exact integer-scaled `value` back to a decimal string at `scale`. `signed` is informational:
146/// an unsigned field never produces a negative sum here, but a negative `i128` is still rendered with `-`
147/// so a reconciliation never hides a sign.
148fn render_scaled(value: i128, scale: usize, _signed: bool) -> String {
149    let negative = value < 0;
150    let mag = value.unsigned_abs();
151    let mut digits = mag.to_string();
152    while digits.len() <= scale {
153        digits.insert(0, '0');
154    }
155    let int_len = digits.len() - scale;
156    let mut s = String::new();
157    if negative && mag != 0 {
158        s.push('-');
159    }
160    s.push_str(&digits[..int_len]);
161    if scale > 0 {
162        s.push('.');
163        s.push_str(&digits[int_len..]);
164    }
165    s
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::model::FieldDecl;
172
173    fn copybook() -> Copybook {
174        Copybook {
175            record_name: "TXN".into(),
176            encoding: "ascii".into(),
177            fields: vec![
178                FieldDecl::alnum("ACCT", "X(4)", 0, 4),
179                FieldDecl::numeric("AMT", "9(3)V99", 4, 5, 2, false),
180            ],
181        }
182    }
183
184    #[test]
185    fn control_total_sums_a_field() {
186        // KOBOLD.CSV.CONTROLTOTAL.1: 12.50 + 0.99 + 100.00 = 113.49
187        let cb = copybook();
188        let recs: Vec<&[u8]> = vec![b"A00101250", b"A00200099", b"A00310000"];
189        let ct = control_totals(&cb, &recs, &["AMT"]);
190        assert_eq!(ct.len(), 1);
191        assert_eq!(ct[0].field, "AMT");
192        assert_eq!(ct[0].count, 3);
193        assert_eq!(ct[0].sum_rendered, "113.49");
194        assert!(ct[0].findings.is_empty());
195    }
196
197    #[test]
198    fn scaled_int_exact() {
199        assert_eq!(scaled_int("12.50", 2).unwrap(), 1250);
200        assert_eq!(scaled_int("0.99", 2).unwrap(), 99);
201        assert_eq!(scaled_int("-1.00", 2).unwrap(), -100);
202        assert_eq!(scaled_int("100", 2).unwrap(), 10000);
203    }
204
205    #[test]
206    fn render_scaled_exact() {
207        assert_eq!(render_scaled(11349, 2, false), "113.49");
208        assert_eq!(render_scaled(-100, 2, true), "-1.00");
209        assert_eq!(render_scaled(0, 2, false), "0.00");
210        assert_eq!(render_scaled(42, 0, false), "42");
211    }
212
213    #[test]
214    fn unknown_field_is_a_finding() {
215        let cb = copybook();
216        let recs: Vec<&[u8]> = vec![b"A00101250"];
217        let ct = control_totals(&cb, &recs, &["NOPE"]);
218        assert_eq!(ct[0].findings[0].code, "CONTROL_FIELD_UNKNOWN");
219    }
220
221    #[test]
222    fn nonnumeric_field_name_is_a_finding() {
223        let cb = copybook();
224        let recs: Vec<&[u8]> = vec![b"A00101250"];
225        let ct = control_totals(&cb, &recs, &["ACCT"]);
226        assert_eq!(ct[0].findings[0].code, "CONTROL_FIELD_NOT_NUMERIC");
227    }
228}