use crate::json::JsonValue;
use crate::model::{Copybook, FieldDecl, FieldKind, Finding};
use crate::sha256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Compact,
Audit,
Evidence,
}
fn render_alnum(data: &[u8]) -> String {
let mut end = data.len();
while end > 0 && (data[end - 1] == b' ' || data[end - 1] == 0) {
end -= 1;
}
data[..end].iter().map(|&b| b as char).collect()
}
fn render_numeric(data: &[u8], scale: usize, signed: bool) -> (String, Vec<Finding>) {
let mut findings = Vec::new();
let mut digits: Vec<u8> = Vec::with_capacity(data.len());
let mut negative = false;
for (idx, &b) in data.iter().enumerate() {
let is_last = idx + 1 == data.len();
if b.is_ascii_digit() {
digits.push(b);
continue;
}
if signed && is_last {
if let Some((d, neg)) = overpunch(b) {
digits.push(d);
negative = neg;
continue;
}
}
findings.push(Finding::new(
"NUMERIC_NONDIGIT",
format!("non-digit byte 0x{:02x} at position {} in numeric field", b, idx),
));
}
if digits.is_empty() {
digits.push(b'0');
}
let s = format_digits(&digits, scale, negative);
(s, findings)
}
fn overpunch(b: u8) -> Option<(u8, bool)> {
match b {
b'{' => Some((b'0', false)),
b'A'..=b'I' => Some((b'0' + (b - b'A' + 1), false)),
b'}' => Some((b'0', true)),
b'J'..=b'R' => Some((b'0' + (b - b'J' + 1), true)),
_ => None,
}
}
fn format_digits(digits: &[u8], scale: usize, negative: bool) -> String {
let d: Vec<u8> = digits.iter().copied().filter(|b| b.is_ascii_digit()).collect();
let d = if d.is_empty() { vec![b'0'] } else { d };
let mut padded = d;
while padded.len() <= scale {
padded.insert(0, b'0');
}
let int_len = padded.len() - scale;
let int_part = &padded[..int_len];
let mut start = 0;
while start + 1 < int_part.len() && int_part[start] == b'0' {
start += 1;
}
let mut s = String::new();
let int_str: String = int_part[start..].iter().map(|&b| b as char).collect();
let all_zero = padded.iter().all(|&b| b == b'0');
if negative && !all_zero {
s.push('-');
}
s.push_str(&int_str);
if scale > 0 {
s.push('.');
let frac: String = padded[int_len..].iter().map(|&b| b as char).collect();
s.push_str(&frac);
}
s
}
fn hex_lower(data: &[u8]) -> String {
let mut s = String::with_capacity(data.len() * 2);
for &b in data {
sha256::push_hex_byte(b, &mut s);
}
s
}
struct Decoded {
value: JsonValue,
raw: Vec<u8>,
findings: Vec<Finding>,
group: Option<Vec<(String, Decoded)>>,
}
fn decode_field(decl: &FieldDecl, record: &[u8]) -> Decoded {
let start = decl.offset;
let end = decl.offset.saturating_add(decl.length);
if end > record.len() {
let raw = if start < record.len() { record[start..].to_vec() } else { Vec::new() };
return Decoded {
value: JsonValue::Null,
raw,
findings: vec![Finding::new(
"FIELD_OUT_OF_RANGE",
format!(
"field {} [{}..{}] exceeds record length {}",
decl.name, start, end, record.len()
),
)],
group: None,
};
}
let raw = record[start..end].to_vec();
match &decl.kind {
FieldKind::Alphanumeric => Decoded {
value: JsonValue::String(render_alnum(&raw)),
raw,
findings: Vec::new(),
group: None,
},
FieldKind::Numeric { scale, signed } => {
let (s, findings) = render_numeric(&raw, *scale, *signed);
Decoded { value: JsonValue::String(s), raw, findings, group: None }
}
FieldKind::Group(children) => {
let mut members = Vec::new();
for c in children {
members.push((c.name.clone(), decode_field(c, record)));
}
Decoded { value: JsonValue::Null, raw, findings: Vec::new(), group: Some(members) }
}
}
}
fn compact_fields(decls: &[FieldDecl], record: &[u8]) -> JsonValue {
let mut members = Vec::new();
for d in decls {
let dec = decode_field(d, record);
let val = match dec.group {
Some(_) => match &d.kind {
FieldKind::Group(children) => compact_fields(children, record),
_ => JsonValue::Null,
},
None => dec.value,
};
members.push((d.name.clone(), val));
}
JsonValue::Object(members)
}
fn audit_fields(decls: &[FieldDecl], record: &[u8]) -> JsonValue {
let mut members = Vec::new();
for d in decls {
let dec = decode_field(d, record);
if let FieldKind::Group(children) = &d.kind {
let mut obj = vec![
("offset".to_string(), JsonValue::uint(d.offset as u64)),
("length".to_string(), JsonValue::uint(d.length as u64)),
("raw_hex".to_string(), JsonValue::str(hex_lower(&dec.raw))),
("fields".to_string(), audit_fields(children, record)),
];
obj.insert(0, ("group".to_string(), JsonValue::Bool(true)));
members.push((d.name.clone(), JsonValue::Object(obj)));
continue;
}
let findings_json = findings_to_json(&dec.findings);
let obj = vec![
("value".to_string(), dec.value),
("pic".to_string(), JsonValue::str(d.pic.clone())),
("offset".to_string(), JsonValue::uint(d.offset as u64)),
("length".to_string(), JsonValue::uint(d.length as u64)),
("raw_hex".to_string(), JsonValue::str(hex_lower(&dec.raw))),
("findings".to_string(), findings_json),
];
members.push((d.name.clone(), JsonValue::Object(obj)));
}
JsonValue::Object(members)
}
pub fn findings_to_json(findings: &[Finding]) -> JsonValue {
JsonValue::Array(
findings
.iter()
.map(|f| {
JsonValue::Object(vec![
("code".to_string(), JsonValue::str(f.code.clone())),
("message".to_string(), JsonValue::str(f.message.clone())),
])
})
.collect(),
)
}
pub fn export(copybook: &Copybook, record: &[u8], mode: Mode) -> JsonValue {
let mut top: Vec<(String, JsonValue)> = Vec::new();
top.push(("record".to_string(), JsonValue::str(copybook.record_name.clone())));
match mode {
Mode::Compact => {
top.push(("fields".to_string(), compact_fields(©book.fields, record)));
}
Mode::Audit => {
top.push(("encoding".to_string(), JsonValue::str(copybook.encoding.clone())));
top.push(("fields".to_string(), audit_fields(©book.fields, record)));
}
Mode::Evidence => {
top.push(("encoding".to_string(), JsonValue::str(copybook.encoding.clone())));
top.push((
"copybook_hash".to_string(),
JsonValue::str(format!("sha256:{}", sha256::hex_digest(©book.canonical_bytes()))),
));
top.push((
"record_hash".to_string(),
JsonValue::str(format!("sha256:{}", sha256::hex_digest(record))),
));
top.push(("fields".to_string(), audit_fields(©book.fields, record)));
top.push((
"roundtrip".to_string(),
JsonValue::Object(vec![
("byte_reconstructable".to_string(), JsonValue::Bool(true)),
("requires_raw_hex".to_string(), JsonValue::Bool(true)),
]),
));
}
}
JsonValue::Object(top)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json::to_string;
fn copybook() -> Copybook {
Copybook {
record_name: "CUST".into(),
encoding: "ascii".into(),
fields: vec![
FieldDecl::alnum("NAME", "X(4)", 0, 4),
FieldDecl::numeric("AMT", "9(3)V99", 4, 5, 2, false),
],
}
}
#[test]
fn compact_values_only() {
let cb = copybook();
let rec = b"JOHN01250";
let p = export(&cb, rec, Mode::Compact);
assert_eq!(
to_string(&p),
"{\"record\":\"CUST\",\"fields\":{\"NAME\":\"JOHN\",\"AMT\":\"12.50\"}}"
);
}
#[test]
fn audit_has_storage_truth() {
let cb = copybook();
let rec = b"JOHN01250";
let p = export(&cb, rec, Mode::Audit);
let fields = p.get("fields").unwrap();
let amt = fields.get("AMT").unwrap();
assert_eq!(amt.get("pic").unwrap().as_str(), Some("9(3)V99"));
assert_eq!(amt.get("offset").unwrap(), &JsonValue::uint(4));
assert_eq!(amt.get("raw_hex").unwrap().as_str(), Some("3031323530")); }
#[test]
fn evidence_has_hashes_and_roundtrip() {
let cb = copybook();
let rec = b"JOHN01250";
let p = export(&cb, rec, Mode::Evidence);
let ch = p.get("copybook_hash").unwrap().as_str().unwrap();
assert!(ch.starts_with("sha256:"));
let rt = p.get("roundtrip").unwrap();
assert_eq!(rt.get("byte_reconstructable").unwrap(), &JsonValue::Bool(true));
}
#[test]
fn numeric_nondigit_is_a_finding_not_coercion() {
let cb = copybook();
let rec = b"JOHN0AB50"; let p = export(&cb, rec, Mode::Audit);
let amt = p.get("fields").unwrap().get("AMT").unwrap();
let findings = amt.get("findings").unwrap();
if let JsonValue::Array(items) = findings {
assert!(!items.is_empty(), "expected NUMERIC_NONDIGIT finding");
assert_eq!(items[0].get("code").unwrap().as_str(), Some("NUMERIC_NONDIGIT"));
} else {
panic!("findings not an array");
}
assert_eq!(amt.get("raw_hex").unwrap().as_str(), Some("3041423530"));
}
#[test]
fn numeric_formatting() {
assert_eq!(format_digits(b"042", 0, false), "42");
assert_eq!(format_digits(b"01250", 2, false), "12.50");
assert_eq!(format_digits(b"0000", 0, false), "0");
assert_eq!(format_digits(b"042", 0, true), "-42");
assert_eq!(format_digits(b"00", 2, true), "0.00"); assert_eq!(format_digits(b"5", 2, false), "0.05"); }
}