1use crate::export::decode_leaf;
16use crate::model::{Copybook, FieldKind, Finding};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ControlTotal {
21 pub field: String,
23 pub count: usize,
25 pub sum_rendered: String,
27 pub findings: Vec<Finding>,
29}
30
31pub 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
106fn 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
145fn 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 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}