1#![allow(clippy::missing_inline_in_public_items)]
4use crate::lib_api::{decode_record, encode_record};
9use crate::options::{DecodeOptions, EncodeOptions};
10use copybook_core::{Error, ErrorCode, Result, Schema};
11use copybook_rdw::RdwHeader;
12
13pub const DEFAULT_MAX_DIFFS: usize = 100;
15
16pub const BLAKE3_HEX_LEN: usize = 64;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum DeterminismMode {
23 DecodeOnly,
25 EncodeOnly,
27 RoundTrip,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct ByteDiff {
34 pub offset: usize,
36 pub round1_byte: u8,
38 pub round2_byte: u8,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct DeterminismResult {
45 pub mode: DeterminismMode,
47 pub round1_hash: String,
49 pub round2_hash: String,
51 pub is_deterministic: bool,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub byte_differences: Option<Vec<ByteDiff>>,
56}
57
58impl DeterminismResult {
59 #[must_use]
61 #[inline]
62 pub fn passed(&self) -> bool {
63 self.is_deterministic
64 }
65
66 #[must_use]
68 #[inline]
69 pub fn diff_count(&self) -> usize {
70 self.byte_differences.as_ref().map_or(0, Vec::len)
71 }
72}
73
74#[must_use]
76#[inline]
77pub fn blake3_hex(data: &[u8]) -> String {
78 blake3::hash(data).to_hex().to_string()
79}
80
81#[must_use]
83#[inline]
84pub fn compare_outputs(mode: DeterminismMode, round1: &[u8], round2: &[u8]) -> DeterminismResult {
85 compare_outputs_with_limit(mode, round1, round2, DEFAULT_MAX_DIFFS)
86}
87
88#[must_use]
90pub fn compare_outputs_with_limit(
91 mode: DeterminismMode,
92 round1: &[u8],
93 round2: &[u8],
94 max_diffs: usize,
95) -> DeterminismResult {
96 let hash1 = blake3::hash(round1);
97 let hash2 = blake3::hash(round2);
98 let is_deterministic = hash1 == hash2;
99
100 DeterminismResult {
101 mode,
102 round1_hash: hash1.to_hex().to_string(),
103 round2_hash: hash2.to_hex().to_string(),
104 is_deterministic,
105 byte_differences: if is_deterministic {
106 None
107 } else {
108 Some(find_byte_differences_with_limit(round1, round2, max_diffs))
109 },
110 }
111}
112
113#[must_use]
115#[inline]
116pub fn find_byte_differences(round1: &[u8], round2: &[u8]) -> Vec<ByteDiff> {
117 find_byte_differences_with_limit(round1, round2, DEFAULT_MAX_DIFFS)
118}
119
120#[must_use]
126pub fn find_byte_differences_with_limit(
127 round1: &[u8],
128 round2: &[u8],
129 max_diffs: usize,
130) -> Vec<ByteDiff> {
131 if max_diffs == 0 {
132 return Vec::new();
133 }
134
135 let min_len = round1.len().min(round2.len());
136 let max_len = round1.len().max(round2.len());
137 let mut diffs = Vec::with_capacity(max_diffs.min(max_len));
138
139 for (offset, (&byte_a, &byte_b)) in round1.iter().zip(round2.iter()).enumerate() {
140 if byte_a != byte_b {
141 diffs.push(ByteDiff {
142 offset,
143 round1_byte: byte_a,
144 round2_byte: byte_b,
145 });
146 if diffs.len() >= max_diffs {
147 return diffs;
148 }
149 }
150 }
151
152 if round1.len() != round2.len() {
153 for offset in min_len..max_len {
154 let byte_a = round1.get(offset).copied().unwrap_or(0);
155 let byte_b = round2.get(offset).copied().unwrap_or(0);
156 diffs.push(ByteDiff {
157 offset,
158 round1_byte: byte_a,
159 round2_byte: byte_b,
160 });
161 if diffs.len() >= max_diffs {
162 return diffs;
163 }
164 }
165 }
166
167 diffs
168}
169
170fn serialize_json(value: &serde_json::Value, context: &str) -> Result<Vec<u8>> {
171 serde_json::to_vec(value).map_err(|e| {
172 Error::new(
173 ErrorCode::CBKC201_JSON_WRITE_ERROR,
174 format!("Failed to serialize {context}: {e}"),
175 )
176 })
177}
178
179#[inline]
185#[must_use = "Handle the Result or propagate the error"]
186pub fn check_decode_determinism(
187 schema: &Schema,
188 data: &[u8],
189 options: &DecodeOptions,
190) -> Result<DeterminismResult> {
191 let payload = payload_for_format(data, options.format)?;
192 let value1 = decode_record(schema, payload, options)?;
193 let value2 = decode_record(schema, payload, options)?;
194
195 let json1 = serialize_json(&value1, "first decode result")?;
196 let json2 = serialize_json(&value2, "second decode result")?;
197
198 Ok(compare_outputs(DeterminismMode::DecodeOnly, &json1, &json2))
199}
200
201#[inline]
207#[must_use = "Handle the Result or propagate the error"]
208pub fn check_encode_determinism(
209 schema: &Schema,
210 json_data: &serde_json::Value,
211 options: &EncodeOptions,
212) -> Result<DeterminismResult> {
213 let binary1 = encode_record(schema, json_data, options)?;
214 let binary2 = encode_record(schema, json_data, options)?;
215
216 Ok(compare_outputs(
217 DeterminismMode::EncodeOnly,
218 &binary1,
219 &binary2,
220 ))
221}
222
223#[inline]
229#[must_use = "Handle the Result or propagate the error"]
230pub fn check_round_trip_determinism(
231 schema: &Schema,
232 data: &[u8],
233 decode_opts: &DecodeOptions,
234 encode_opts: &EncodeOptions,
235) -> Result<DeterminismResult> {
236 let decoded_payload = payload_for_format(data, decode_opts.format)?;
237 let json1 = decode_record(schema, decoded_payload, decode_opts)?;
238 let binary = encode_record(schema, &json1, encode_opts)?;
239 let encoded_payload = payload_for_format(&binary, decode_opts.format)?;
240 let json2 = decode_record(schema, encoded_payload, decode_opts)?;
241
242 let serialized1 = serialize_json(&json1, "first round-trip decode result")?;
243 let serialized2 = serialize_json(&json2, "second round-trip decode result")?;
244
245 Ok(compare_outputs(
246 DeterminismMode::RoundTrip,
247 &serialized1,
248 &serialized2,
249 ))
250}
251
252#[inline]
253fn payload_for_format(data: &[u8], format: crate::options::RecordFormat) -> Result<&[u8]> {
254 if format != crate::options::RecordFormat::RDW {
255 return Ok(data);
256 }
257
258 if data.len() < copybook_rdw::RDW_HEADER_LEN {
259 return Err(Error::new(
260 ErrorCode::CBKF221_RDW_UNDERFLOW,
261 "RDW data is shorter than the 4-byte RDW header",
262 ));
263 }
264
265 let header_slice = data.get(..copybook_rdw::RDW_HEADER_LEN).ok_or_else(|| {
266 Error::new(
267 ErrorCode::CBKF221_RDW_UNDERFLOW,
268 "RDW data is shorter than the 4-byte RDW header",
269 )
270 })?;
271
272 let header_bytes: [u8; copybook_rdw::RDW_HEADER_LEN] =
273 header_slice.try_into().map_err(|_| {
274 Error::new(
275 ErrorCode::CBKF221_RDW_UNDERFLOW,
276 "RDW header must be exactly 4 bytes",
277 )
278 })?;
279
280 let header = RdwHeader::from_bytes(header_bytes);
281
282 let payload_len = usize::from(header.length());
283 let expected_len = copybook_rdw::RDW_HEADER_LEN.saturating_add(payload_len);
284 if data.len() != expected_len {
285 return Err(Error::new(
286 ErrorCode::CBKF221_RDW_UNDERFLOW,
287 format!(
288 "RDW payload mismatch: expected {expected_len} bytes, got {}",
289 data.len()
290 ),
291 ));
292 }
293
294 Ok(&data[copybook_rdw::RDW_HEADER_LEN..expected_len])
295}
296
297#[cfg(test)]
298#[allow(clippy::expect_used)]
299#[allow(clippy::unwrap_used)]
300mod tests {
301 use super::*;
302 use crate::options::{Codepage, RecordFormat};
303 use copybook_core::parse_copybook;
304
305 fn decode_opts() -> DecodeOptions {
306 DecodeOptions::new().with_codepage(Codepage::CP037)
307 }
308
309 fn encode_opts() -> EncodeOptions {
310 EncodeOptions::new()
311 .with_codepage(Codepage::CP037)
312 .with_format(RecordFormat::Fixed)
313 }
314
315 #[test]
316 fn decode_deterministic_for_display_schema() {
317 let copybook = r"
318 01 RECORD.
319 05 FIELD-A PIC X(10).
320 ";
321 let schema = parse_copybook(copybook).expect("parse copybook");
322
323 let data: Vec<u8> = vec![0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xD1];
324
325 let result =
326 check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
327
328 assert!(
329 result.is_deterministic,
330 "Expected deterministic decode for DISPLAY-only schema"
331 );
332 assert_eq!(result.mode, DeterminismMode::DecodeOnly);
333 assert!(result.byte_differences.is_none());
334 assert_eq!(result.diff_count(), 0);
335 assert!(result.passed());
336 }
337
338 #[test]
339 fn decode_deterministic_for_comp3_schema() {
340 let copybook = r"
341 01 RECORD.
342 05 AMOUNT PIC S9(7)V99 COMP-3.
343 ";
344 let schema = parse_copybook(copybook).expect("parse copybook");
345
346 let data = vec![0x12, 0x34, 0x56, 0x78, 0x9C];
347
348 let result =
349 check_decode_determinism(&schema, &data, &decode_opts()).expect("determinism check");
350
351 assert!(
352 result.is_deterministic,
353 "Expected deterministic decode for COMP-3 schema"
354 );
355 assert!(result.passed());
356 }
357
358 #[test]
359 fn encode_deterministic_for_display_schema() {
360 let copybook = r"
361 01 RECORD.
362 05 FIELD-A PIC X(5).
363 ";
364 let schema = parse_copybook(copybook).expect("parse copybook");
365 let json = serde_json::json!({"FIELD-A": "HELLO"});
366
367 let result =
368 check_encode_determinism(&schema, &json, &encode_opts()).expect("determinism check");
369
370 assert!(
371 result.is_deterministic,
372 "Expected deterministic encode for DISPLAY-only schema"
373 );
374 assert_eq!(result.mode, DeterminismMode::EncodeOnly);
375 assert!(result.byte_differences.is_none());
376 }
377
378 #[test]
379 fn round_trip_deterministic() {
380 let copybook = r"
381 01 RECORD.
382 05 NAME PIC X(10).
383 05 AGE PIC 9(3).
384 ";
385 let schema = parse_copybook(copybook).expect("parse copybook");
386
387 let data: Vec<u8> = vec![
388 0xD1, 0xD6, 0xC8, 0xD5, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0xF1, 0xF2, 0xF3,
389 ];
390
391 let result = check_round_trip_determinism(&schema, &data, &decode_opts(), &encode_opts())
392 .expect("round-trip check");
393
394 assert!(result.is_deterministic, "Expected deterministic round-trip");
395 assert_eq!(result.mode, DeterminismMode::RoundTrip);
396 }
397
398 #[test]
399 fn detect_json_serialization_nondeterminism() {
400 let json1 = serde_json::json!({"FIELD": "VALUE1"});
401 let json2 = serde_json::json!({"FIELD": "VALUE2"});
402
403 let bytes1 = serde_json::to_vec(&json1).expect("serialize json1");
404 let bytes2 = serde_json::to_vec(&json2).expect("serialize json2");
405
406 let result = compare_outputs(DeterminismMode::DecodeOnly, &bytes1, &bytes2);
407 assert!(!result.is_deterministic);
408 assert!(result.diff_count() > 0);
409 }
410
411 #[test]
412 fn primitive_comparison_reports_bounded_differences() {
413 let result =
414 compare_outputs_with_limit(DeterminismMode::EncodeOnly, b"ABCDEF", b"ABxDEy", 1);
415
416 assert!(!result.passed());
417 assert_eq!(result.diff_count(), 1);
418 assert_eq!(
419 result.byte_differences.as_ref().expect("diffs")[0].offset,
420 2
421 );
422 }
423
424 #[test]
425 fn primitive_hash_and_result_serde_are_stable() {
426 let hash = blake3_hex(b"copybook");
427 let result = compare_outputs(DeterminismMode::RoundTrip, b"copybook", b"copybook");
428
429 assert_eq!(hash.len(), BLAKE3_HEX_LEN);
430 assert_eq!(result.round1_hash, hash);
431 assert!(result.byte_differences.is_none());
432
433 let json = serde_json::to_string(&result).expect("serialize determinism result");
434 let decoded: DeterminismResult =
435 serde_json::from_str(&json).expect("deserialize determinism result");
436 assert_eq!(decoded, result);
437 }
438
439 #[test]
440 fn primitive_length_difference_documents_zero_padding() {
441 let diffs = find_byte_differences(b"ABC", b"ABC\0");
442
443 assert_eq!(diffs.len(), 1);
444 assert_eq!(diffs[0].offset, 3);
445 assert_eq!(diffs[0].round1_byte, 0);
446 assert_eq!(diffs[0].round2_byte, 0);
447 }
448
449 #[test]
450 fn decode_error_propagates_correctly() {
451 let copybook = r"
452 01 RECORD.
453 05 AMOUNT PIC S9(7)V99 COMP-3.
454 ";
455 let schema = parse_copybook(copybook).expect("parse copybook");
456
457 let truncated_data = vec![0x12, 0x34];
458
459 let result = check_decode_determinism(&schema, &truncated_data, &decode_opts());
460
461 assert!(
462 result.is_err(),
463 "Should return error for truncated COMP-3 data"
464 );
465 }
466
467 #[test]
468 fn encode_error_propagates_correctly() {
469 let copybook = r"
470 01 RECORD.
471 05 FIELD PIC 9(5).
472 ";
473 let schema = parse_copybook(copybook).expect("parse copybook");
474
475 let invalid_json = serde_json::json!({"FIELD": "NOT_A_NUMBER"});
476
477 let result = check_encode_determinism(&schema, &invalid_json, &encode_opts());
478
479 assert!(
480 result.is_err(),
481 "Should return error for type mismatch in encoding"
482 );
483 }
484
485 #[test]
486 fn round_trip_error_propagates() {
487 let copybook = r"
488 01 RECORD.
489 05 AMOUNT PIC S9(7)V99 COMP-3.
490 ";
491 let schema = parse_copybook(copybook).expect("parse copybook");
492
493 let bad_data = vec![0x12, 0x34];
494
495 let result =
496 check_round_trip_determinism(&schema, &bad_data, &decode_opts(), &encode_opts());
497
498 assert!(
499 result.is_err(),
500 "Should return error for truncated data in round-trip"
501 );
502 }
503
504 #[test]
505 fn insufficient_data_handling_is_stable() {
506 let copybook = r"
507 01 RECORD.
508 05 FIELD PIC X(5).
509 ";
510 let schema = parse_copybook(copybook).expect("parse copybook");
511
512 let insufficient_data = vec![0x40, 0x40, 0x40];
513
514 let result = check_decode_determinism(&schema, &insufficient_data, &decode_opts());
515
516 if let Ok(det_result) = result {
517 assert!(
518 det_result.is_deterministic,
519 "If insufficient data is handled, it must be deterministic"
520 );
521 }
522 }
523}