Skip to main content

copybook_codec/
determinism.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Determinism validation for COBOL copybook encoding and decoding operations.
3#![allow(clippy::missing_inline_in_public_items)]
4//!
5//! This module verifies that encode/decode operations produce identical outputs
6//! across repeated runs with the same schema, data, and options.
7
8use crate::lib_api::{decode_record, encode_record};
9use crate::options::{DecodeOptions, EncodeOptions};
10use copybook_core::{Error, ErrorCode, Result, Schema};
11use copybook_determinism::compare_outputs;
12use copybook_rdw::RdwHeader;
13
14pub use copybook_determinism::{ByteDiff, DeterminismMode, DeterminismResult};
15
16fn serialize_json(value: &serde_json::Value, context: &str) -> Result<Vec<u8>> {
17    serde_json::to_vec(value).map_err(|e| {
18        Error::new(
19            ErrorCode::CBKC201_JSON_WRITE_ERROR,
20            format!("Failed to serialize {context}: {e}"),
21        )
22    })
23}
24
25/// Check that decoding the same binary data twice produces identical JSON output.
26///
27/// # Errors
28///
29/// Returns an error if decoding or JSON serialization fails.
30#[inline]
31#[must_use = "Handle the Result or propagate the error"]
32pub fn check_decode_determinism(
33    schema: &Schema,
34    data: &[u8],
35    options: &DecodeOptions,
36) -> Result<DeterminismResult> {
37    let payload = payload_for_format(data, options.format)?;
38    let value1 = decode_record(schema, payload, options)?;
39    let value2 = decode_record(schema, payload, options)?;
40
41    let json1 = serialize_json(&value1, "first decode result")?;
42    let json2 = serialize_json(&value2, "second decode result")?;
43
44    Ok(compare_outputs(DeterminismMode::DecodeOnly, &json1, &json2))
45}
46
47/// Check that encoding the same JSON twice produces identical binary output.
48///
49/// # Errors
50///
51/// Returns an error if encoding fails.
52#[inline]
53#[must_use = "Handle the Result or propagate the error"]
54pub fn check_encode_determinism(
55    schema: &Schema,
56    json_data: &serde_json::Value,
57    options: &EncodeOptions,
58) -> Result<DeterminismResult> {
59    let binary1 = encode_record(schema, json_data, options)?;
60    let binary2 = encode_record(schema, json_data, options)?;
61
62    Ok(compare_outputs(
63        DeterminismMode::EncodeOnly,
64        &binary1,
65        &binary2,
66    ))
67}
68
69/// Check full round-trip determinism: decode->encode->decode.
70///
71/// # Errors
72///
73/// Returns an error if any decode/encode or JSON serialization step fails.
74#[inline]
75#[must_use = "Handle the Result or propagate the error"]
76pub fn check_round_trip_determinism(
77    schema: &Schema,
78    data: &[u8],
79    decode_opts: &DecodeOptions,
80    encode_opts: &EncodeOptions,
81) -> Result<DeterminismResult> {
82    let decoded_payload = payload_for_format(data, decode_opts.format)?;
83    let json1 = decode_record(schema, decoded_payload, decode_opts)?;
84    let binary = encode_record(schema, &json1, encode_opts)?;
85    let encoded_payload = payload_for_format(&binary, decode_opts.format)?;
86    let json2 = decode_record(schema, encoded_payload, decode_opts)?;
87
88    let serialized1 = serialize_json(&json1, "first round-trip decode result")?;
89    let serialized2 = serialize_json(&json2, "second round-trip decode result")?;
90
91    Ok(compare_outputs(
92        DeterminismMode::RoundTrip,
93        &serialized1,
94        &serialized2,
95    ))
96}
97
98#[inline]
99fn payload_for_format(data: &[u8], format: crate::options::RecordFormat) -> Result<&[u8]> {
100    if format != crate::options::RecordFormat::RDW {
101        return Ok(data);
102    }
103
104    if data.len() < copybook_rdw::RDW_HEADER_LEN {
105        return Err(Error::new(
106            ErrorCode::CBKF221_RDW_UNDERFLOW,
107            "RDW data is shorter than the 4-byte RDW header",
108        ));
109    }
110
111    let header_slice = data.get(..copybook_rdw::RDW_HEADER_LEN).ok_or_else(|| {
112        Error::new(
113            ErrorCode::CBKF221_RDW_UNDERFLOW,
114            "RDW data is shorter than the 4-byte RDW header",
115        )
116    })?;
117
118    let header_bytes: [u8; copybook_rdw::RDW_HEADER_LEN] =
119        header_slice.try_into().map_err(|_| {
120            Error::new(
121                ErrorCode::CBKF221_RDW_UNDERFLOW,
122                "RDW header must be exactly 4 bytes",
123            )
124        })?;
125
126    let header = RdwHeader::from_bytes(header_bytes);
127
128    let payload_len = usize::from(header.length());
129    let expected_len = copybook_rdw::RDW_HEADER_LEN.saturating_add(payload_len);
130    if data.len() != expected_len {
131        return Err(Error::new(
132            ErrorCode::CBKF221_RDW_UNDERFLOW,
133            format!(
134                "RDW payload mismatch: expected {expected_len} bytes, got {}",
135                data.len()
136            ),
137        ));
138    }
139
140    Ok(&data[copybook_rdw::RDW_HEADER_LEN..expected_len])
141}
142
143#[cfg(test)]
144#[allow(clippy::expect_used)]
145#[allow(clippy::unwrap_used)]
146mod tests {
147    use super::*;
148    use crate::options::{Codepage, RecordFormat};
149    use copybook_core::parse_copybook;
150
151    fn decode_opts() -> DecodeOptions {
152        DecodeOptions::new().with_codepage(Codepage::CP037)
153    }
154
155    fn encode_opts() -> EncodeOptions {
156        EncodeOptions::new()
157            .with_codepage(Codepage::CP037)
158            .with_format(RecordFormat::Fixed)
159    }
160
161    #[test]
162    fn decode_deterministic_for_display_schema() {
163        let copybook = r"
164            01 RECORD.
165               05 FIELD-A PIC X(10).
166        ";
167        let schema = parse_copybook(copybook).expect("parse copybook");
168
169        let data: Vec<u8> = vec![0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xD1];
170
171        let result =
172            check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
173
174        assert!(
175            result.is_deterministic,
176            "Expected deterministic decode for DISPLAY-only schema"
177        );
178        assert_eq!(result.mode, DeterminismMode::DecodeOnly);
179        assert!(result.byte_differences.is_none());
180        assert_eq!(result.diff_count(), 0);
181        assert!(result.passed());
182    }
183
184    #[test]
185    fn decode_deterministic_for_comp3_schema() {
186        let copybook = r"
187            01 RECORD.
188               05 AMOUNT PIC S9(7)V99 COMP-3.
189        ";
190        let schema = parse_copybook(copybook).expect("parse copybook");
191
192        let data = vec![0x12, 0x34, 0x56, 0x78, 0x9C];
193
194        let result =
195            check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
196
197        assert!(
198            result.is_deterministic,
199            "Expected deterministic decode for COMP-3 schema"
200        );
201        assert!(result.passed());
202    }
203
204    #[test]
205    fn encode_deterministic_for_display_schema() {
206        let copybook = r"
207            01 RECORD.
208               05 FIELD-A PIC X(5).
209        ";
210        let schema = parse_copybook(copybook).expect("parse copybook");
211        let json = serde_json::json!({"FIELD-A": "HELLO"});
212
213        let result =
214            check_encode_determinism(&schema, &json, &encode_opts()).expect("determinism check");
215
216        assert!(
217            result.is_deterministic,
218            "Expected deterministic encode for DISPLAY-only schema"
219        );
220        assert_eq!(result.mode, DeterminismMode::EncodeOnly);
221        assert!(result.byte_differences.is_none());
222    }
223
224    #[test]
225    fn round_trip_deterministic() {
226        let copybook = r"
227            01 RECORD.
228               05 NAME PIC X(10).
229               05 AGE  PIC 9(3).
230        ";
231        let schema = parse_copybook(copybook).expect("parse copybook");
232
233        let data: Vec<u8> = vec![
234            0xD1, 0xD6, 0xC8, 0xD5, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0xF1, 0xF2, 0xF3,
235        ];
236
237        let result = check_round_trip_determinism(&schema, &data, &decode_opts(), &encode_opts())
238            .expect("round-trip check");
239
240        assert!(result.is_deterministic, "Expected deterministic round-trip");
241        assert_eq!(result.mode, DeterminismMode::RoundTrip);
242    }
243
244    #[test]
245    fn detect_json_serialization_nondeterminism() {
246        let json1 = serde_json::json!({"FIELD": "VALUE1"});
247        let json2 = serde_json::json!({"FIELD": "VALUE2"});
248
249        let bytes1 = serde_json::to_vec(&json1).expect("serialize json1");
250        let bytes2 = serde_json::to_vec(&json2).expect("serialize json2");
251
252        let result = compare_outputs(DeterminismMode::DecodeOnly, &bytes1, &bytes2);
253        assert!(!result.is_deterministic);
254        assert!(result.diff_count() > 0);
255    }
256
257    #[test]
258    fn decode_error_propagates_correctly() {
259        let copybook = r"
260            01 RECORD.
261               05 AMOUNT PIC S9(7)V99 COMP-3.
262        ";
263        let schema = parse_copybook(copybook).expect("parse copybook");
264
265        let truncated_data = vec![0x12, 0x34];
266
267        let result = check_decode_determinism(&schema, &truncated_data, &decode_opts());
268
269        assert!(
270            result.is_err(),
271            "Should return error for truncated COMP-3 data"
272        );
273    }
274
275    #[test]
276    fn encode_error_propagates_correctly() {
277        let copybook = r"
278            01 RECORD.
279               05 FIELD PIC 9(5).
280        ";
281        let schema = parse_copybook(copybook).expect("parse copybook");
282
283        let invalid_json = serde_json::json!({"FIELD": "NOT_A_NUMBER"});
284
285        let result = check_encode_determinism(&schema, &invalid_json, &encode_opts());
286
287        assert!(
288            result.is_err(),
289            "Should return error for type mismatch in encoding"
290        );
291    }
292
293    #[test]
294    fn round_trip_error_propagates() {
295        let copybook = r"
296            01 RECORD.
297               05 AMOUNT PIC S9(7)V99 COMP-3.
298        ";
299        let schema = parse_copybook(copybook).expect("parse copybook");
300
301        let bad_data = vec![0x12, 0x34];
302
303        let result =
304            check_round_trip_determinism(&schema, &bad_data, &decode_opts(), &encode_opts());
305
306        assert!(
307            result.is_err(),
308            "Should return error for truncated data in round-trip"
309        );
310    }
311
312    #[test]
313    fn insufficient_data_handling_is_stable() {
314        let copybook = r"
315            01 RECORD.
316               05 FIELD PIC X(5).
317        ";
318        let schema = parse_copybook(copybook).expect("parse copybook");
319
320        let insufficient_data = vec![0x40, 0x40, 0x40];
321
322        let result = check_decode_determinism(&schema, &insufficient_data, &decode_opts());
323
324        if let Ok(det_result) = result {
325            assert!(
326                det_result.is_deterministic,
327                "If insufficient data is handled, it must be deterministic"
328            );
329        }
330    }
331}