use crate::json::JsonValue;
use crate::model::{Copybook, FieldDecl, FieldKind, Finding};
pub fn parse_into(copybook: &Copybook, packet: &JsonValue) -> Result<Vec<u8>, Vec<Finding>> {
let fields = match packet.get("fields") {
Some(f) => f,
None => {
return Err(vec![Finding::new(
"PACKET_NO_FIELDS",
"packet has no \"fields\" member".to_string(),
)]);
}
};
let total = copybook.record_length();
let mut out = vec![b' '; total];
let mut findings = Vec::new();
encode_fields(©book.fields, fields, &mut out, &mut findings);
if findings.is_empty() {
Ok(out)
} else {
Err(findings)
}
}
fn encode_fields(decls: &[FieldDecl], node: &JsonValue, out: &mut [u8], findings: &mut Vec<Finding>) {
for d in decls {
let member = match node.get(&d.name) {
Some(m) => m,
None => {
findings.push(Finding::new(
"FIELD_MISSING",
format!("field {} not present in packet", d.name),
));
continue;
}
};
encode_field(d, member, out, findings);
}
}
fn encode_field(d: &FieldDecl, member: &JsonValue, out: &mut [u8], findings: &mut Vec<Finding>) {
if let FieldKind::Group(children) = &d.kind {
let inner = member.get("fields").unwrap_or(member);
encode_fields(children, inner, out, findings);
return;
}
if let Some(raw_hex) = member.get("raw_hex").and_then(|v| v.as_str()) {
match decode_hex(raw_hex) {
Ok(bytes) => {
if bytes.len() != d.length {
findings.push(Finding::new(
"RAW_HEX_LENGTH",
format!(
"field {}: raw_hex decodes to {} bytes, declared length {}",
d.name,
bytes.len(),
d.length
),
));
return;
}
place(out, d.offset, &bytes, findings, &d.name);
return;
}
Err(msg) => {
findings.push(Finding::new("RAW_HEX_INVALID", format!("field {}: {}", d.name, msg)));
return;
}
}
}
let value_node = member.get("value").unwrap_or(member);
let value = match value_node {
JsonValue::String(s) => s.clone(),
JsonValue::Number(n) => n.clone(),
JsonValue::Null => String::new(),
_ => {
findings.push(Finding::new(
"VALUE_TYPE",
format!("field {}: value is not a string/number", d.name),
));
return;
}
};
match &d.kind {
FieldKind::Alphanumeric => encode_alnum(d, &value, out, findings),
FieldKind::Numeric { scale, signed } => encode_numeric(d, &value, *scale, *signed, out, findings),
FieldKind::Group(_) => unreachable!("group handled above"),
}
}
fn place(out: &mut [u8], offset: usize, bytes: &[u8], findings: &mut Vec<Finding>, name: &str) {
let end = offset + bytes.len();
if end > out.len() {
findings.push(Finding::new(
"FIELD_OUT_OF_RANGE",
format!("field {}: writing [{}..{}] exceeds record length {}", name, offset, end, out.len()),
));
return;
}
out[offset..end].copy_from_slice(bytes);
}
fn encode_alnum(d: &FieldDecl, value: &str, out: &mut [u8], findings: &mut Vec<Finding>) {
let mut bytes = Vec::with_capacity(value.len());
for ch in value.chars() {
let cp = ch as u32;
if cp > 0xff {
findings.push(Finding::new(
"ALNUM_NON_BYTE",
format!("field {}: char U+{:04X} is not representable in one byte", d.name, cp),
));
return;
}
bytes.push(cp as u8);
}
if bytes.len() > d.length {
findings.push(Finding::new(
"VALUE_OVERFLOW",
format!(
"field {}: value of {} bytes overflows field length {} (fail-closed, no truncation)",
d.name,
bytes.len(),
d.length
),
));
return;
}
let mut buf = vec![b' '; d.length];
buf[..bytes.len()].copy_from_slice(&bytes);
place(out, d.offset, &buf, findings, &d.name);
}
fn encode_numeric(
d: &FieldDecl,
value: &str,
scale: usize,
signed: bool,
out: &mut [u8],
findings: &mut Vec<Finding>,
) {
let mut s = value.trim();
let mut negative = false;
if let Some(rest) = s.strip_prefix('-') {
negative = true;
s = rest;
} else if let Some(rest) = s.strip_prefix('+') {
s = rest;
}
if negative && !signed {
findings.push(Finding::new(
"SIGN_ON_UNSIGNED",
format!("field {}: negative value into unsigned PIC {}", d.name, d.pic),
));
return;
}
let (int_str, frac_str) = match s.split_once('.') {
Some((i, f)) => (i, f),
None => (s, ""),
};
if int_str.is_empty() && frac_str.is_empty() {
findings.push(Finding::new("NUMERIC_EMPTY", format!("field {}: empty numeric value", d.name)));
return;
}
for (label, part) in [("integer", int_str), ("fraction", frac_str)] {
if !part.chars().all(|c| c.is_ascii_digit()) {
findings.push(Finding::new(
"NUMERIC_INVALID",
format!("field {}: non-numeric {} part {:?} (fail-closed)", d.name, label, part),
));
return;
}
}
if frac_str.len() > scale {
findings.push(Finding::new(
"FRACTION_OVERFLOW",
format!(
"field {}: {} fraction digits exceed scale {} (fail-closed, no rounding)",
d.name,
frac_str.len(),
scale
),
));
return;
}
let int_digits = d.length.saturating_sub(scale);
let int_trimmed = int_str.trim_start_matches('0');
if int_trimmed.len() > int_digits {
findings.push(Finding::new(
"VALUE_OVERFLOW",
format!(
"field {}: integer part {:?} needs {} digits, field has {} (fail-closed)",
d.name,
int_str,
int_trimmed.len(),
int_digits
),
));
return;
}
let mut digits = String::with_capacity(d.length);
for _ in 0..(int_digits - int_trimmed.len()) {
digits.push('0');
}
digits.push_str(int_trimmed);
digits.push_str(frac_str);
for _ in 0..(scale - frac_str.len()) {
digits.push('0');
}
let mut bytes: Vec<u8> = digits.into_bytes();
debug_assert_eq!(bytes.len(), d.length);
if bytes.len() != d.length {
findings.push(Finding::new(
"NUMERIC_LENGTH",
format!("field {}: built {} digits, declared length {}", d.name, bytes.len(), d.length),
));
return;
}
if signed {
if let Some(last) = bytes.last_mut() {
*last = overpunch_byte(*last, negative);
}
}
place(out, d.offset, &bytes, findings, &d.name);
}
fn overpunch_byte(digit: u8, negative: bool) -> u8 {
let n = digit.wrapping_sub(b'0');
if n > 9 {
return digit;
}
match (negative, n) {
(false, 0) => b'{',
(false, k) => b'A' + (k - 1),
(true, 0) => b'}',
(true, k) => b'J' + (k - 1),
}
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>, String> {
let b = s.as_bytes();
if b.len() % 2 != 0 {
return Err(format!("odd-length hex string ({} chars)", b.len()));
}
let mut out = Vec::with_capacity(b.len() / 2);
let mut i = 0;
while i < b.len() {
let hi = hex_val(b[i]).ok_or_else(|| format!("invalid hex char {:?}", b[i] as char))?;
let lo = hex_val(b[i + 1]).ok_or_else(|| format!("invalid hex char {:?}", b[i + 1] as char))?;
out.push((hi << 4) | lo);
i += 2;
}
Ok(out)
}
fn hex_val(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export::{export, Mode};
fn copybook() -> Copybook {
Copybook {
record_name: "CUST".into(),
encoding: "ascii".into(),
fields: vec![
FieldDecl::alnum("NAME", "X(4)", 0, 4),
FieldDecl::numeric("AMT", "S9(3)V99", 4, 5, 2, true),
],
}
}
#[test]
fn roundtrip_evidence_identical_bytes() {
let cb = copybook();
let rec = b"JOHN0125}";
let packet = export(&cb, rec, Mode::Evidence);
let back = parse_into(&cb, &packet).expect("roundtrip should succeed");
assert_eq!(&back, rec);
}
#[test]
fn roundtrip_audit_identical_bytes() {
let cb = copybook();
let rec = b"JANE0007A"; let packet = export(&cb, rec, Mode::Audit);
let back = parse_into(&cb, &packet).expect("roundtrip should succeed");
assert_eq!(&back, rec);
}
#[test]
fn compact_reencode_succeeds() {
let cb = Copybook {
record_name: "R".into(),
encoding: "ascii".into(),
fields: vec![
FieldDecl::alnum("NAME", "X(4)", 0, 4),
FieldDecl::numeric("AMT", "9(3)V99", 4, 5, 2, false),
],
};
let packet = export(&cb, b"AL 01250", Mode::Compact);
let back = parse_into(&cb, &packet).expect("compact re-encode");
assert_eq!(&back, b"AL 01250");
}
#[test]
fn fail_closed_overflow_alnum() {
let cb = Copybook {
record_name: "R".into(),
encoding: "ascii".into(),
fields: vec![FieldDecl::alnum("NAME", "X(4)", 0, 4)],
};
let packet = JsonValue::Object(vec![
("record".into(), JsonValue::str("R")),
(
"fields".into(),
JsonValue::Object(vec![("NAME".into(), JsonValue::str("TOOLONG"))]),
),
]);
let res = parse_into(&cb, &packet);
let findings = res.expect_err("must fail closed on overflow");
assert_eq!(findings[0].code, "VALUE_OVERFLOW");
}
#[test]
fn fail_closed_nonnumeric() {
let cb = Copybook {
record_name: "R".into(),
encoding: "ascii".into(),
fields: vec![FieldDecl::numeric("AMT", "9(3)", 0, 3, 0, false)],
};
let packet = JsonValue::Object(vec![
("record".into(), JsonValue::str("R")),
(
"fields".into(),
JsonValue::Object(vec![("AMT".into(), JsonValue::str("12X"))]),
),
]);
let findings = parse_into(&cb, &packet).expect_err("must fail closed on non-numeric");
assert_eq!(findings[0].code, "NUMERIC_INVALID");
}
#[test]
fn decode_hex_fail_closed() {
assert!(decode_hex("abc").is_err()); assert!(decode_hex("zz").is_err()); assert_eq!(decode_hex("4a4f").unwrap(), b"JO");
}
}