lenso_test/receipt_diagnostics.rs
1//! Locate the first difference between two observed scenario receipts.
2//!
3//! This compares recorded evidence; it does not replay a scenario, inject a
4//! fault, advance time, or infer which execution produced the correct result.
5
6use std::fmt;
7
8use crate::ScenarioReceiptEvent;
9
10/// The first unequal event, or the first event missing from one receipt.
11///
12/// The events are borrowed in full, including identifiers beyond the diagnostic
13/// display limit. `Display` and `Debug` show only this position, in expected /
14/// actual order, with virtual time in nanoseconds and the finite event enums.
15/// Each identifier is escaped and limited to 128 escaped ASCII bytes, followed
16/// by `...` if truncated. Both formats produce fewer than 1024 bytes regardless
17/// of receipt length or identifier length, including directly constructed events
18/// that bypass the recorder's identifier validation. This is output bounding,
19/// not secret redaction; use only test-owned, non-sensitive identifiers.
20#[derive(Clone, Copy, Eq, PartialEq)]
21pub struct ReceiptDifference<'a> {
22 /// Zero-based index of the first difference.
23 pub index: usize,
24 /// Expected event at this position, or `None` if the expected receipt ended.
25 pub expected: Option<&'a ScenarioReceiptEvent>,
26 /// Actual event at this position, or `None` if the actual receipt ended.
27 pub actual: Option<&'a ScenarioReceiptEvent>,
28}
29
30/// Returns the first event difference, or `None` when both receipts are equal.
31///
32/// Compares every field using the existing event equality semantics. A shared
33/// prefix followed by an extra event differs at the prefix's length. Comparison
34/// allocates no copy of either receipt and never truncates identifiers; only
35/// the returned diagnostic's formatting has an output limit.
36///
37/// ```
38/// use std::time::Duration;
39/// use lenso_test::{TestSimulator, ScenarioTransition, first_receipt_difference};
40///
41/// let simulator = TestSimulator::new();
42/// let expected = simulator.receipt();
43/// expected.transition("generation-1", "job-1", ScenarioTransition::Started)?;
44/// simulator.advance(Duration::from_millis(1));
45/// let actual = simulator.receipt();
46/// actual.transition("generation-1", "job-1", ScenarioTransition::Started)?;
47/// let expected_events = expected.events();
48/// let actual_events = actual.events();
49/// let difference = first_receipt_difference(&expected_events, &actual_events).unwrap();
50/// assert_eq!(difference.index, 0);
51/// assert_eq!(difference.actual.unwrap().virtual_time, Duration::from_millis(1));
52/// assert!(difference.to_string().contains("time_ns=1000000"));
53/// # Ok::<(), lenso_test::FaultPointError>(())
54/// ```
55pub fn first_receipt_difference<'a>(
56 expected: &'a [ScenarioReceiptEvent],
57 actual: &'a [ScenarioReceiptEvent],
58) -> Option<ReceiptDifference<'a>> {
59 let index = expected
60 .iter()
61 .zip(actual)
62 .position(|(expected, actual)| expected != actual)
63 .or_else(|| (expected.len() != actual.len()).then_some(expected.len().min(actual.len())))?;
64 Some(ReceiptDifference {
65 index,
66 expected: expected.get(index),
67 actual: actual.get(index),
68 })
69}
70
71impl fmt::Display for ReceiptDifference<'_> {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 write!(
74 formatter,
75 "receipt differs at event {}\nexpected: ",
76 self.index
77 )?;
78 write_event(formatter, self.expected)?;
79 formatter.write_str("\nactual: ")?;
80 write_event(formatter, self.actual)
81 }
82}
83
84// Assertion failures use Debug. Keep it bounded just like the explicit display.
85impl fmt::Debug for ReceiptDifference<'_> {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 fmt::Display::fmt(self, formatter)
88 }
89}
90
91fn write_event(
92 formatter: &mut fmt::Formatter<'_>,
93 event: Option<&ScenarioReceiptEvent>,
94) -> fmt::Result {
95 let Some(event) = event else {
96 return formatter.write_str("<end of receipt>");
97 };
98 write!(
99 formatter,
100 "time_ns={} generation=",
101 event.virtual_time.as_nanos()
102 )?;
103 write_identifier(formatter, &event.generation_id)?;
104 formatter.write_str(" operation=")?;
105 write_identifier(formatter, &event.operation_id)?;
106 write!(
107 formatter,
108 " transition={:?} fault={:?} terminal={:?}",
109 event.transition, event.fault, event.terminal,
110 )
111}
112
113fn write_identifier(formatter: &mut fmt::Formatter<'_>, identifier: &str) -> fmt::Result {
114 let mut remaining = 128;
115 formatter.write_str("\"")?;
116 for character in identifier.chars() {
117 let escaped_size = character.escape_default().count();
118 if escaped_size > remaining {
119 formatter.write_str("...")?;
120 break;
121 }
122 write!(formatter, "{}", character.escape_default())?;
123 remaining -= escaped_size;
124 }
125 formatter.write_str("\"")
126}