1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2use anyhow::Context;
10use clap::{Args, Subcommand, ValueEnum};
11use copybook_codec::{
12 Codepage, DecodeOptions, EncodeOptions, JsonNumberMode, RecordFormat,
13 determinism::{
14 DeterminismResult, check_decode_determinism, check_encode_determinism,
15 check_round_trip_determinism,
16 },
17};
18use copybook_core::{Schema, parse_copybook};
19use std::fmt::Write as _;
20use std::fs;
21use std::io::{self, Read};
22use std::path::{Path, PathBuf};
23
24pub use copybook_codec::determinism::DeterminismMode as DeterminismCheckMode;
25pub use copybook_determinism::{BLAKE3_HEX_LEN, DEFAULT_MAX_DIFFS};
26
27#[derive(Args, Debug, Clone)]
29pub struct DeterminismCommand {
30 #[command(subcommand)]
32 pub mode: DeterminismMode,
33}
34
35#[derive(Subcommand, Debug, Clone)]
37pub enum DeterminismMode {
38 Decode(DecodeDeterminismArgs),
40
41 Encode(EncodeDeterminismArgs),
43
44 RoundTrip(RoundTripDeterminismArgs),
46}
47
48pub type DeterminismModeCommand = DeterminismMode;
50
51#[derive(Args, Debug, Clone)]
53pub struct CommonDeterminismArgs {
54 #[arg(value_name = "COPYBOOK")]
56 pub copybook: PathBuf,
57
58 #[arg(long, default_value = "fixed")]
60 pub format: RecordFormat,
61
62 #[arg(long, default_value = "cp037")]
64 pub codepage: Codepage,
65
66 #[arg(long, value_name = "MODE", default_value = "lossless")]
68 pub json_number: JsonNumberMode,
69
70 #[arg(long)]
72 pub emit_meta: bool,
73
74 #[arg(long, value_name = "FORMAT", default_value = "human")]
76 pub output: OutputFormat,
77
78 #[arg(long, value_name = "N", default_value_t = DEFAULT_MAX_DIFFS)]
80 pub max_diffs: usize,
81}
82
83#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
95pub enum OutputFormat {
96 Human,
98 Json,
100}
101
102#[derive(Args, Debug, Clone)]
104pub struct DecodeDeterminismArgs {
105 #[command(flatten)]
107 pub common: CommonDeterminismArgs,
108
109 #[arg(value_name = "DATA")]
111 pub data: PathBuf,
112}
113
114#[derive(Args, Debug, Clone)]
116pub struct EncodeDeterminismArgs {
117 #[command(flatten)]
119 pub common: CommonDeterminismArgs,
120
121 #[arg(value_name = "JSON")]
123 pub json: PathBuf,
124}
125
126#[derive(Args, Debug, Clone)]
128pub struct RoundTripDeterminismArgs {
129 #[command(flatten)]
131 pub common: CommonDeterminismArgs,
132
133 #[arg(value_name = "DATA")]
135 pub data: PathBuf,
136}
137
138#[derive(Debug, Clone)]
140pub struct DeterminismRun {
141 pub verdict: DeterminismVerdict,
143 pub output: String,
145}
146
147impl DeterminismRun {
148 #[inline]
150 #[must_use]
151 pub const fn exit_code(&self) -> i32 {
152 self.verdict.exit_code()
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum DeterminismVerdict {
171 Deterministic,
173 NonDeterministic,
175}
176
177impl DeterminismVerdict {
178 const fn from_result(result: &DeterminismResult) -> Self {
179 if result.is_deterministic {
180 Self::Deterministic
181 } else {
182 Self::NonDeterministic
183 }
184 }
185
186 #[inline]
188 #[must_use]
189 pub const fn exit_code(&self) -> i32 {
190 match self {
191 Self::Deterministic => 0,
192 Self::NonDeterministic => 2,
193 }
194 }
195}
196
197#[inline]
203pub fn run(cmd: &DeterminismCommand) -> anyhow::Result<DeterminismRun> {
204 let result = match &cmd.mode {
205 DeterminismModeCommand::Decode(args) => run_decode(args),
206 DeterminismModeCommand::Encode(args) => run_encode(args),
207 DeterminismModeCommand::RoundTrip(args) => run_round_trip(args),
208 }?;
209
210 Ok(result)
211}
212
213fn run_decode(args: &DecodeDeterminismArgs) -> anyhow::Result<DeterminismRun> {
215 let schema = load_schema(&args.common.copybook)?;
216 let decode_opts = build_decode_options(&args.common);
217 let data = read_bytes_or_stdin(&args.data).with_context(|| {
218 format!(
219 "Failed to read data file for determinism check: {}",
220 args.data.display()
221 )
222 })?;
223
224 let result = check_decode_determinism(&schema, &data, &decode_opts)
225 .context("Decode determinism check failed")?;
226
227 render_result(&result, &args.common)
228}
229
230fn run_encode(args: &EncodeDeterminismArgs) -> anyhow::Result<DeterminismRun> {
232 let schema = load_schema(&args.common.copybook)?;
233 let encode_opts = build_encode_options(&args.common);
234 let json_text = read_text_or_stdin(&args.json).with_context(|| {
235 format!(
236 "Failed to read JSON input for determinism check: {}",
237 args.json.display()
238 )
239 })?;
240
241 let first_line = json_text
242 .lines()
243 .next()
244 .ok_or_else(|| anyhow::anyhow!("JSON input file is empty"))?;
245 let value: serde_json::Value =
246 serde_json::from_str(first_line).context("Failed to parse JSON input")?;
247
248 let result = check_encode_determinism(&schema, &value, &encode_opts)
249 .context("Encode determinism check failed")?;
250
251 render_result(&result, &args.common)
252}
253
254fn run_round_trip(args: &RoundTripDeterminismArgs) -> anyhow::Result<DeterminismRun> {
256 let schema = load_schema(&args.common.copybook)?;
257 let decode_opts = build_decode_options(&args.common);
258 let encode_opts = build_encode_options(&args.common);
259 let data = read_bytes_or_stdin(&args.data).with_context(|| {
260 format!(
261 "Failed to read data file for round-trip determinism check: {}",
262 args.data.display()
263 )
264 })?;
265
266 let result = check_round_trip_determinism(&schema, &data, &decode_opts, &encode_opts)
267 .context("Round-trip determinism check failed")?;
268
269 render_result(&result, &args.common)
270}
271
272fn render_result(
274 result: &DeterminismResult,
275 common: &CommonDeterminismArgs,
276) -> anyhow::Result<DeterminismRun> {
277 let output = match common.output {
278 OutputFormat::Json => render_json_result(result),
279 OutputFormat::Human => Ok(render_human_result(result, common.max_diffs)),
280 }?;
281
282 Ok(DeterminismRun {
283 verdict: DeterminismVerdict::from_result(result),
284 output,
285 })
286}
287
288#[inline]
294pub fn render_json_result(result: &DeterminismResult) -> anyhow::Result<String> {
295 serde_json::to_string_pretty(result).context("Failed to serialize determinism result to JSON")
296}
297
298#[inline]
300#[must_use]
301pub fn render_human_result(result: &DeterminismResult, max_diffs: usize) -> String {
302 let mut output = String::new();
303
304 let _ = writeln!(&mut output, "Determinism mode: {:?}", result.mode);
305 let _ = writeln!(
306 &mut output,
307 "Round 1 hash: {}",
308 truncate_hash(&result.round1_hash)
309 );
310 let _ = writeln!(
311 &mut output,
312 "Round 2 hash: {}",
313 truncate_hash(&result.round2_hash)
314 );
315
316 if result.is_deterministic {
317 let _ = writeln!(&mut output, "\n✅ DETERMINISTIC");
318 } else {
319 let _ = writeln!(&mut output, "\n❌ NON-DETERMINISTIC");
320 }
321
322 if let Some(diffs) = &result.byte_differences {
323 let count = diffs.len();
324 let shown = diffs.iter().take(max_diffs);
325
326 let _ = writeln!(&mut output, "\nByte differences: {count} total");
327 if count > 0 {
328 output.push_str("\n Offset Round1 Round2\n");
329 output.push_str(" ------ ------ ------\n");
330 for diff in shown {
331 let _ = writeln!(
332 &mut output,
333 " 0x{:04X} 0x{:02X} 0x{:02X}",
334 diff.offset, diff.round1_byte, diff.round2_byte
335 );
336 }
337
338 if count > max_diffs {
339 let _ = writeln!(
340 &mut output,
341 "\n ... {} more differences not shown",
342 count - max_diffs
343 );
344 }
345 }
346 } else {
347 output.push_str("\nByte differences: none");
348 }
349
350 output
351}
352
353#[inline]
355#[must_use]
356pub fn build_decode_options(common: &CommonDeterminismArgs) -> DecodeOptions {
357 DecodeOptions::new()
358 .with_codepage(common.codepage)
359 .with_format(common.format)
360 .with_json_number_mode(common.json_number)
361 .with_emit_meta(common.emit_meta)
362}
363
364#[inline]
366#[must_use]
367pub fn build_encode_options(common: &CommonDeterminismArgs) -> EncodeOptions {
368 EncodeOptions::new()
369 .with_codepage(common.codepage)
370 .with_format(common.format)
371 .with_json_number_mode(common.json_number)
372}
373
374#[inline]
380pub fn load_schema(path: &Path) -> anyhow::Result<Schema> {
381 let text = read_text_or_stdin(path)?;
382 let schema = parse_copybook(&text)
383 .with_context(|| format!("Failed to parse copybook: {}", path.display()))?;
384 Ok(schema)
385}
386
387#[inline]
389#[must_use]
390pub fn truncate_hash(hash: &str) -> String {
391 if hash.len() > 16 {
392 format!("{}...", &hash[..16])
393 } else {
394 hash.to_string()
395 }
396}
397
398fn read_text_or_stdin(path: &Path) -> anyhow::Result<String> {
399 if path.as_os_str() == "-" {
400 let mut text = String::new();
401 io::stdin().read_to_string(&mut text)?;
402 return Ok(text);
403 }
404
405 let text = fs::read_to_string(path)?;
406 Ok(text)
407}
408
409fn read_bytes_or_stdin(path: &Path) -> anyhow::Result<Vec<u8>> {
410 if path.as_os_str() == "-" {
411 let mut data = Vec::new();
412 io::stdin().read_to_end(&mut data)?;
413 return Ok(data);
414 }
415
416 Ok(fs::read(path)?)
417}
418
419#[cfg(test)]
420#[allow(clippy::expect_used)]
421#[allow(clippy::unwrap_used)]
422mod tests {
423 use super::*;
424 use copybook_determinism::ByteDiff;
425 use copybook_determinism::DeterminismMode as CodecDeterminismMode;
426 use proptest::prelude::*;
427
428 #[test]
429 fn hash_truncation_works() {
430 let long_hash = "7a3f9e2b1c4d5e6f7a3f9e2b1c4d5e6f";
431 assert_eq!(truncate_hash(long_hash), "7a3f9e2b1c4d5e6f...");
432
433 let short_hash = "7a3f9e2b";
434 assert_eq!(truncate_hash(short_hash), "7a3f9e2b");
435 }
436
437 #[test]
438 fn human_result_includes_diff_metadata() {
439 let result = DeterminismResult {
440 mode: CodecDeterminismMode::DecodeOnly,
441 round1_hash: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
442 .to_string(),
443 round2_hash: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeff1"
444 .to_string(),
445 is_deterministic: false,
446 byte_differences: Some(vec![
447 ByteDiff {
448 offset: 16,
449 round1_byte: 0xAA,
450 round2_byte: 0x55,
451 },
452 ByteDiff {
453 offset: 17,
454 round1_byte: 0xBB,
455 round2_byte: 0x44,
456 },
457 ]),
458 };
459 let output = render_human_result(&result, 1);
460 assert!(output.contains("Determinism mode:"));
461 assert!(output.contains("Byte differences: 2 total"));
462 assert!(output.contains("0x0010"));
463 assert!(output.contains("... 1 more differences not shown"));
464 }
465
466 #[test]
467 fn human_result_deterministic_shows_checkmark() {
468 let result = DeterminismResult {
469 mode: CodecDeterminismMode::DecodeOnly,
470 round1_hash: "a".repeat(64),
471 round2_hash: "a".repeat(64),
472 is_deterministic: true,
473 byte_differences: None,
474 };
475 let output = render_human_result(&result, 100);
476 assert!(output.contains("✅ DETERMINISTIC"));
477 assert!(!output.contains("NON-DETERMINISTIC"));
478 assert!(output.contains("Byte differences: none"));
479 }
480
481 #[test]
482 fn human_result_non_deterministic_shows_cross() {
483 let result = DeterminismResult {
484 mode: CodecDeterminismMode::EncodeOnly,
485 round1_hash: "a".repeat(64),
486 round2_hash: "b".repeat(64),
487 is_deterministic: false,
488 byte_differences: Some(vec![ByteDiff {
489 offset: 0,
490 round1_byte: 0x41,
491 round2_byte: 0x42,
492 }]),
493 };
494 let output = render_human_result(&result, 100);
495 assert!(output.contains("❌ NON-DETERMINISTIC"));
496 assert!(output.contains("Byte differences: 1 total"));
497 assert!(output.contains("0x0000"));
498 assert!(output.contains("0x41"));
499 assert!(output.contains("0x42"));
500 }
501
502 #[test]
503 fn render_json_deterministic_round_trips_correctly() {
504 let result = DeterminismResult {
505 mode: CodecDeterminismMode::RoundTrip,
506 round1_hash: "c".repeat(64),
507 round2_hash: "c".repeat(64),
508 is_deterministic: true,
509 byte_differences: None,
510 };
511 let json = render_json_result(&result).unwrap();
512 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
513 assert_eq!(parsed["is_deterministic"], true);
514 assert_eq!(parsed["mode"], "round_trip");
515 assert!(parsed.get("byte_differences").is_none());
516 }
517
518 #[test]
519 fn render_json_non_deterministic_includes_diffs() {
520 let result = DeterminismResult {
521 mode: CodecDeterminismMode::DecodeOnly,
522 round1_hash: "d".repeat(64),
523 round2_hash: "e".repeat(64),
524 is_deterministic: false,
525 byte_differences: Some(vec![ByteDiff {
526 offset: 5,
527 round1_byte: 0x10,
528 round2_byte: 0x20,
529 }]),
530 };
531 let json = render_json_result(&result).unwrap();
532 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
533 assert_eq!(parsed["is_deterministic"], false);
534 let diffs = parsed["byte_differences"].as_array().unwrap();
535 assert_eq!(diffs.len(), 1);
536 assert_eq!(diffs[0]["offset"], 5);
537 }
538
539 #[test]
540 fn verdict_exit_codes_are_stable() {
541 assert_eq!(DeterminismVerdict::Deterministic.exit_code(), 0);
542 assert_eq!(DeterminismVerdict::NonDeterministic.exit_code(), 2);
543 }
544
545 #[test]
546 fn determinism_run_exit_code_delegates_to_verdict() {
547 let run_pass = DeterminismRun {
548 verdict: DeterminismVerdict::Deterministic,
549 output: String::new(),
550 };
551 assert_eq!(run_pass.exit_code(), 0);
552
553 let run_fail = DeterminismRun {
554 verdict: DeterminismVerdict::NonDeterministic,
555 output: String::new(),
556 };
557 assert_eq!(run_fail.exit_code(), 2);
558 }
559
560 #[test]
561 fn truncate_hash_exactly_16_chars() {
562 let hash = "0123456789abcdef";
563 assert_eq!(truncate_hash(hash), "0123456789abcdef");
564 }
565
566 #[test]
567 fn truncate_hash_empty_string() {
568 assert_eq!(truncate_hash(""), "");
569 }
570
571 #[test]
572 fn human_result_shows_mode_name() {
573 for mode in [
574 CodecDeterminismMode::DecodeOnly,
575 CodecDeterminismMode::EncodeOnly,
576 CodecDeterminismMode::RoundTrip,
577 ] {
578 let result = DeterminismResult {
579 mode,
580 round1_hash: "f".repeat(64),
581 round2_hash: "f".repeat(64),
582 is_deterministic: true,
583 byte_differences: None,
584 };
585 let output = render_human_result(&result, 100);
586 assert!(output.contains("Determinism mode:"));
587 assert!(output.contains("Round 1 hash:"));
588 assert!(output.contains("Round 2 hash:"));
589 }
590 }
591
592 proptest! {
593 #[test]
594 fn prop_hash_truncation_is_prefix_plus_ellipsis(bytes in prop::collection::vec(any::<u8>(), 0..128)) {
595 let mut raw = String::with_capacity(bytes.len());
596 for byte in bytes {
597 let digit = byte % 16;
598 let ch = if digit < 10 {
599 (b'0' + digit) as char
600 } else {
601 (b'a' + (digit - 10)) as char
602 };
603 raw.push(ch);
604 }
605
606 let rendered = truncate_hash(&raw);
607 if raw.len() <= 16 {
608 prop_assert_eq!(rendered, raw);
609 } else {
610 prop_assert_eq!(&rendered[16..], "...");
611 prop_assert_eq!(rendered.len(), 19);
612 prop_assert_eq!(&rendered[..16], &raw[..16]);
613 }
614 }
615
616 #[test]
617 fn prop_json_output_is_parseable(
618 hash_a in prop::collection::vec(any::<u8>(), 0..64),
619 hash_b in prop::collection::vec(any::<u8>(), 0..64),
620 deterministic in any::<bool>(),
621 ) {
622 let make_hash = |bytes: &[u8]| {
623 let mut out = String::with_capacity(bytes.len() * 2);
624 for byte in bytes {
625 out.push_str(&format!("{byte:02x}"));
626 }
627 out
628 };
629
630 let result = DeterminismResult {
631 mode: CodecDeterminismMode::RoundTrip,
632 round1_hash: make_hash(&hash_a),
633 round2_hash: if deterministic {
634 make_hash(&hash_a)
635 } else {
636 make_hash(&hash_b)
637 },
638 is_deterministic: deterministic,
639 byte_differences: if deterministic {
640 None
641 } else {
642 Some(vec![ByteDiff {
643 offset: 1,
644 round1_byte: 1,
645 round2_byte: 2,
646 }])
647 },
648 };
649 let json = render_json_result(&result).expect("json output");
650 let de = serde_json::from_str::<DeterminismResult>(&json).expect("round-trip decode");
651 assert_eq!(de.mode, result.mode);
652 assert_eq!(de.is_deterministic, result.is_deterministic);
653 }
654 }
655}