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;
12
13pub use copybook_determinism::{ByteDiff, DeterminismMode, DeterminismResult};
14
15fn serialize_json(value: &serde_json::Value, context: &str) -> Result<Vec<u8>> {
16    serde_json::to_vec(value).map_err(|e| {
17        Error::new(
18            ErrorCode::CBKC201_JSON_WRITE_ERROR,
19            format!("Failed to serialize {context}: {e}"),
20        )
21    })
22}
23
24/// Check that decoding the same binary data twice produces identical JSON output.
25///
26/// # Errors
27///
28/// Returns an error if decoding or JSON serialization fails.
29pub fn check_decode_determinism(
30    schema: &Schema,
31    data: &[u8],
32    options: &DecodeOptions,
33) -> Result<DeterminismResult> {
34    let value1 = decode_record(schema, data, options)?;
35    let value2 = decode_record(schema, data, options)?;
36
37    let json1 = serialize_json(&value1, "first decode result")?;
38    let json2 = serialize_json(&value2, "second decode result")?;
39
40    Ok(compare_outputs(DeterminismMode::DecodeOnly, &json1, &json2))
41}
42
43/// Check that encoding the same JSON twice produces identical binary output.
44///
45/// # Errors
46///
47/// Returns an error if encoding fails.
48pub fn check_encode_determinism(
49    schema: &Schema,
50    json_data: &serde_json::Value,
51    options: &EncodeOptions,
52) -> Result<DeterminismResult> {
53    let binary1 = encode_record(schema, json_data, options)?;
54    let binary2 = encode_record(schema, json_data, options)?;
55
56    Ok(compare_outputs(
57        DeterminismMode::EncodeOnly,
58        &binary1,
59        &binary2,
60    ))
61}
62
63/// Check full round-trip determinism: decode->encode->decode.
64///
65/// # Errors
66///
67/// Returns an error if any decode/encode or JSON serialization step fails.
68pub fn check_round_trip_determinism(
69    schema: &Schema,
70    data: &[u8],
71    decode_opts: &DecodeOptions,
72    encode_opts: &EncodeOptions,
73) -> Result<DeterminismResult> {
74    let json1 = decode_record(schema, data, decode_opts)?;
75    let binary = encode_record(schema, &json1, encode_opts)?;
76    let json2 = decode_record(schema, &binary, decode_opts)?;
77
78    let serialized1 = serialize_json(&json1, "first round-trip decode result")?;
79    let serialized2 = serialize_json(&json2, "second round-trip decode result")?;
80
81    Ok(compare_outputs(
82        DeterminismMode::RoundTrip,
83        &serialized1,
84        &serialized2,
85    ))
86}
87
88#[cfg(test)]
89#[allow(clippy::expect_used)]
90#[allow(clippy::unwrap_used)]
91mod tests {
92    use super::*;
93    use crate::options::{Codepage, RecordFormat};
94    use copybook_core::parse_copybook;
95
96    fn decode_opts() -> DecodeOptions {
97        DecodeOptions::new().with_codepage(Codepage::CP037)
98    }
99
100    fn encode_opts() -> EncodeOptions {
101        EncodeOptions::new()
102            .with_codepage(Codepage::CP037)
103            .with_format(RecordFormat::Fixed)
104    }
105
106    #[test]
107    fn decode_deterministic_for_display_schema() {
108        let copybook = r"
109            01 RECORD.
110               05 FIELD-A PIC X(10).
111        ";
112        let schema = parse_copybook(copybook).expect("parse copybook");
113
114        let data: Vec<u8> = vec![0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xD1];
115
116        let result =
117            check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
118
119        assert!(
120            result.is_deterministic,
121            "Expected deterministic decode for DISPLAY-only schema"
122        );
123        assert_eq!(result.mode, DeterminismMode::DecodeOnly);
124        assert!(result.byte_differences.is_none());
125        assert_eq!(result.diff_count(), 0);
126        assert!(result.passed());
127    }
128
129    #[test]
130    fn decode_deterministic_for_comp3_schema() {
131        let copybook = r"
132            01 RECORD.
133               05 AMOUNT PIC S9(7)V99 COMP-3.
134        ";
135        let schema = parse_copybook(copybook).expect("parse copybook");
136
137        let data = vec![0x12, 0x34, 0x56, 0x78, 0x9C];
138
139        let result =
140            check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
141
142        assert!(
143            result.is_deterministic,
144            "Expected deterministic decode for COMP-3 schema"
145        );
146        assert!(result.passed());
147    }
148
149    #[test]
150    fn encode_deterministic_for_display_schema() {
151        let copybook = r"
152            01 RECORD.
153               05 FIELD-A PIC X(5).
154        ";
155        let schema = parse_copybook(copybook).expect("parse copybook");
156        let json = serde_json::json!({"FIELD-A": "HELLO"});
157
158        let result =
159            check_encode_determinism(&schema, &json, &encode_opts()).expect("determinism check");
160
161        assert!(
162            result.is_deterministic,
163            "Expected deterministic encode for DISPLAY-only schema"
164        );
165        assert_eq!(result.mode, DeterminismMode::EncodeOnly);
166        assert!(result.byte_differences.is_none());
167    }
168
169    #[test]
170    fn round_trip_deterministic() {
171        let copybook = r"
172            01 RECORD.
173               05 NAME PIC X(10).
174               05 AGE  PIC 9(3).
175        ";
176        let schema = parse_copybook(copybook).expect("parse copybook");
177
178        let data: Vec<u8> = vec![
179            0xD1, 0xD6, 0xC8, 0xD5, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0xF1, 0xF2, 0xF3,
180        ];
181
182        let result = check_round_trip_determinism(&schema, &data, &decode_opts(), &encode_opts())
183            .expect("round-trip check");
184
185        assert!(result.is_deterministic, "Expected deterministic round-trip");
186        assert_eq!(result.mode, DeterminismMode::RoundTrip);
187    }
188
189    #[test]
190    fn detect_json_serialization_nondeterminism() {
191        let json1 = serde_json::json!({"FIELD": "VALUE1"});
192        let json2 = serde_json::json!({"FIELD": "VALUE2"});
193
194        let bytes1 = serde_json::to_vec(&json1).expect("serialize json1");
195        let bytes2 = serde_json::to_vec(&json2).expect("serialize json2");
196
197        let result = compare_outputs(DeterminismMode::DecodeOnly, &bytes1, &bytes2);
198        assert!(!result.is_deterministic);
199        assert!(result.diff_count() > 0);
200    }
201
202    #[test]
203    fn decode_error_propagates_correctly() {
204        let copybook = r"
205            01 RECORD.
206               05 AMOUNT PIC S9(7)V99 COMP-3.
207        ";
208        let schema = parse_copybook(copybook).expect("parse copybook");
209
210        let truncated_data = vec![0x12, 0x34];
211
212        let result = check_decode_determinism(&schema, &truncated_data, &decode_opts());
213
214        assert!(
215            result.is_err(),
216            "Should return error for truncated COMP-3 data"
217        );
218    }
219
220    #[test]
221    fn encode_error_propagates_correctly() {
222        let copybook = r"
223            01 RECORD.
224               05 FIELD PIC 9(5).
225        ";
226        let schema = parse_copybook(copybook).expect("parse copybook");
227
228        let invalid_json = serde_json::json!({"FIELD": "NOT_A_NUMBER"});
229
230        let result = check_encode_determinism(&schema, &invalid_json, &encode_opts());
231
232        assert!(
233            result.is_err(),
234            "Should return error for type mismatch in encoding"
235        );
236    }
237
238    #[test]
239    fn round_trip_error_propagates() {
240        let copybook = r"
241            01 RECORD.
242               05 AMOUNT PIC S9(7)V99 COMP-3.
243        ";
244        let schema = parse_copybook(copybook).expect("parse copybook");
245
246        let bad_data = vec![0x12, 0x34];
247
248        let result =
249            check_round_trip_determinism(&schema, &bad_data, &decode_opts(), &encode_opts());
250
251        assert!(
252            result.is_err(),
253            "Should return error for truncated data in round-trip"
254        );
255    }
256
257    #[test]
258    fn insufficient_data_handling_is_stable() {
259        let copybook = r"
260            01 RECORD.
261               05 FIELD PIC X(5).
262        ";
263        let schema = parse_copybook(copybook).expect("parse copybook");
264
265        let insufficient_data = vec![0x40, 0x40, 0x40];
266
267        let result = check_decode_determinism(&schema, &insufficient_data, &decode_opts());
268
269        if let Ok(det_result) = result {
270            assert!(
271                det_result.is_deterministic,
272                "If insufficient data is handled, it must be deterministic"
273            );
274        }
275    }
276}