Skip to main content

kobold_json/
model.rs

1//! The clean-room COBOL-record data model for kobold-json.
2//!
3//! This is a plain Rust description of a record layout (a tree of named fields with offsets, lengths and
4//! PIC kinds) plus the decoded result of applying it to raw bytes. **It is independent of GnuCOBOL/libcob**
5//! -- it links no COBOL runtime and reproduces no runtime's exact behavior. The caller describes their
6//! copybook by whatever means they like and feeds raw record bytes to the `KOBOLD.JSON.EXPORT.1` court.
7
8/// The storage kind of a field, as declared by the copybook.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum FieldKind {
11    /// `PIC X`/`A` -- alphanumeric display bytes.
12    Alphanumeric,
13    /// `PIC 9` -- zoned-decimal display digits. `scale` = implied decimal places; `signed` if `PIC S9`.
14    Numeric { scale: usize, signed: bool },
15    /// A group (`01`/`05` with subordinate items): the child fields, in order. A group's `length` is the
16    /// sum of its children's lengths.
17    Group(Vec<FieldDecl>),
18}
19
20/// A single field declaration from a copybook: a name, its PIC string, its byte `offset` within the record,
21/// its byte `length`, and its [`FieldKind`].
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FieldDecl {
24    /// The COBOL data name (e.g. `CUST-NAME`).
25    pub name: String,
26    /// The PIC clause text, kept verbatim for the audit packet (e.g. `X(20)`, `S9(5)V99`).
27    pub pic: String,
28    /// Byte offset of the field within the record.
29    pub offset: usize,
30    /// Byte length of the field.
31    pub length: usize,
32    /// The storage kind.
33    pub kind: FieldKind,
34}
35
36impl FieldDecl {
37    /// An alphanumeric field.
38    pub fn alnum(name: impl Into<String>, pic: impl Into<String>, offset: usize, length: usize) -> Self {
39        FieldDecl { name: name.into(), pic: pic.into(), offset, length, kind: FieldKind::Alphanumeric }
40    }
41
42    /// A numeric (zoned display) field.
43    pub fn numeric(
44        name: impl Into<String>,
45        pic: impl Into<String>,
46        offset: usize,
47        length: usize,
48        scale: usize,
49        signed: bool,
50    ) -> Self {
51        FieldDecl {
52            name: name.into(),
53            pic: pic.into(),
54            offset,
55            length,
56            kind: FieldKind::Numeric { scale, signed },
57        }
58    }
59
60    /// A group field with the given children. `length` is the sum of child lengths.
61    pub fn group(name: impl Into<String>, offset: usize, children: Vec<FieldDecl>) -> Self {
62        let length = children.iter().map(|c| c.length).sum();
63        FieldDecl { name: name.into(), pic: String::new(), offset, length, kind: FieldKind::Group(children) }
64    }
65}
66
67/// A copybook: the record name, the declared character encoding (informational, carried into the packet),
68/// and the top-level fields in order.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Copybook {
71    /// The `01`-level record name.
72    pub record_name: String,
73    /// The declared character encoding, e.g. `ascii` or `ebcdic-cp-us`. Carried into the packet as
74    /// `encoding`; kobold-json decodes display bytes as-is and does not transcode.
75    pub encoding: String,
76    /// The top-level fields, in record order.
77    pub fields: Vec<FieldDecl>,
78}
79
80impl Copybook {
81    /// The total declared record length = sum of top-level field lengths.
82    pub fn record_length(&self) -> usize {
83        self.fields.iter().map(|f| f.length).sum()
84    }
85
86    /// A stable, canonical byte serialization of the copybook layout, used as the input to
87    /// `copybook_hash`. Deterministic: a pure function of the structure (names/pics/offsets/lengths/kinds),
88    /// independent of insertion-time state.
89    pub fn canonical_bytes(&self) -> Vec<u8> {
90        let mut out = Vec::new();
91        out.extend_from_slice(b"COPYBOOK\x1f");
92        out.extend_from_slice(self.record_name.as_bytes());
93        out.push(0x1f);
94        out.extend_from_slice(self.encoding.as_bytes());
95        out.push(0x1e);
96        for f in &self.fields {
97            canon_field(f, &mut out);
98        }
99        out
100    }
101}
102
103fn canon_field(f: &FieldDecl, out: &mut Vec<u8>) {
104    out.extend_from_slice(f.name.as_bytes());
105    out.push(0x1f);
106    out.extend_from_slice(f.pic.as_bytes());
107    out.push(0x1f);
108    out.extend_from_slice(f.offset.to_string().as_bytes());
109    out.push(0x1f);
110    out.extend_from_slice(f.length.to_string().as_bytes());
111    out.push(0x1f);
112    match &f.kind {
113        FieldKind::Alphanumeric => out.extend_from_slice(b"A"),
114        FieldKind::Numeric { scale, signed } => {
115            out.extend_from_slice(b"N");
116            out.extend_from_slice(scale.to_string().as_bytes());
117            out.push(if *signed { b'S' } else { b'U' });
118        }
119        FieldKind::Group(children) => {
120            out.extend_from_slice(b"G{");
121            for c in children {
122                canon_field(c, out);
123            }
124            out.push(b'}');
125        }
126    }
127    out.push(0x1e);
128}
129
130/// A finding emitted by a court instead of a silent coercion: a stable `code` and a human `message`.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Finding {
133    /// A stable machine code, e.g. `NUMERIC_NONDIGIT`, `VALUE_OVERFLOW`, `FIELD_OUT_OF_RANGE`.
134    pub code: String,
135    /// A human-readable message.
136    pub message: String,
137}
138
139impl Finding {
140    /// Construct a finding.
141    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
142        Finding { code: code.into(), message: message.into() }
143    }
144}
145
146/// A decoded leaf field: its name, the rendered semantic `value`, the raw bytes, the declaration it came
147/// from, and any findings raised while decoding it.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct DecodedField {
150    /// The field name.
151    pub name: String,
152    /// The rendered value (see [`crate::export`] for the rendering policy).
153    pub value: String,
154    /// The exact raw bytes of the field.
155    pub raw: Vec<u8>,
156    /// The originating declaration.
157    pub decl: FieldDecl,
158    /// Findings raised while decoding (empty = clean).
159    pub findings: Vec<Finding>,
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn record_length_sums_children() {
168        let cb = Copybook {
169            record_name: "REC".into(),
170            encoding: "ascii".into(),
171            fields: vec![
172                FieldDecl::alnum("NAME", "X(4)", 0, 4),
173                FieldDecl::numeric("AMT", "9(3)V99", 4, 5, 2, false),
174            ],
175        };
176        assert_eq!(cb.record_length(), 9);
177    }
178
179    #[test]
180    fn group_length_is_sum() {
181        let g = FieldDecl::group(
182            "G",
183            0,
184            vec![FieldDecl::alnum("A", "X(2)", 0, 2), FieldDecl::alnum("B", "X(3)", 2, 3)],
185        );
186        assert_eq!(g.length, 5);
187    }
188
189    #[test]
190    fn canonical_bytes_deterministic() {
191        let cb = Copybook {
192            record_name: "REC".into(),
193            encoding: "ascii".into(),
194            fields: vec![FieldDecl::alnum("NAME", "X(4)", 0, 4)],
195        };
196        assert_eq!(cb.canonical_bytes(), cb.canonical_bytes());
197        assert!(!cb.canonical_bytes().is_empty());
198    }
199}