Skip to main content

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}