Skip to main content

tickwise_cli/
diff.rs

1//! The diff command: field-level structural diff of two dump files.
2
3use std::fs::File;
4use std::io::BufReader;
5use std::path::Path;
6use tickwise::diff::{DiffClass, DiffError, DiffReport, FloatPolicy, structural_from};
7use tickwise::format::{FormatError, RecReader};
8
9/// Differences shown per tick before the output is truncated, unless
10/// `show_all` is set.
11const DEFAULT_LIMIT: usize = 100;
12
13/// Command line options for the diff command. The default is plain,
14/// uncolored output with the default float policy.
15#[derive(Debug, Clone, PartialEq, Default)]
16pub struct DiffOptions {
17    /// Float classification policy.
18    pub policy: FloatPolicy,
19    /// Emit ANSI colors.
20    pub color: bool,
21    /// Show every difference instead of truncating long lists.
22    pub show_all: bool,
23}
24
25/// Rendered diff output plus the verdict for the exit code.
26pub struct DiffOutput {
27    /// Human-readable report text.
28    pub text: String,
29    /// True when any common tick differs.
30    pub differs: bool,
31}
32
33/// Parses the arguments after `diff`: two paths plus optional flags.
34pub fn parse_args(args: &[String]) -> Result<(String, String, DiffOptions), String> {
35    let mut paths = Vec::new();
36    let mut options = DiffOptions {
37        color: true,
38        ..DiffOptions::default()
39    };
40    let mut iter = args.iter();
41    while let Some(arg) = iter.next() {
42        match arg.as_str() {
43            "--strict" => options.policy = FloatPolicy::strict(),
44            "--no-color" => options.color = false,
45            "--all" => options.show_all = true,
46            "--epsilon-f32" => {
47                let value = iter.next().ok_or("--epsilon-f32 needs a value")?;
48                options.policy.epsilon_f32 = value
49                    .parse()
50                    .map_err(|_| format!("--epsilon-f32: {value:?} is not a number"))?;
51            }
52            "--epsilon-f64" => {
53                let value = iter.next().ok_or("--epsilon-f64 needs a value")?;
54                options.policy.epsilon_f64 = value
55                    .parse()
56                    .map_err(|_| format!("--epsilon-f64: {value:?} is not a number"))?;
57            }
58            flag if flag.starts_with("--") => return Err(format!("unknown flag {flag}")),
59            path => paths.push(path.to_string()),
60        }
61    }
62    match paths.as_slice() {
63        [a, b] => Ok((a.clone(), b.clone(), options)),
64        _ => Err(
65            "usage: tickwise diff <a.dump> <b.dump> [--strict] [--epsilon-f32 X] \
66                  [--epsilon-f64 X] [--all] [--no-color]"
67                .to_string(),
68        ),
69    }
70}
71
72struct Palette {
73    enabled: bool,
74}
75
76impl Palette {
77    fn paint(&self, code: &str, text: &str) -> String {
78        if self.enabled {
79            format!("\x1b[{code}m{text}\x1b[0m")
80        } else {
81            text.to_string()
82        }
83    }
84    fn class(&self, class: DiffClass) -> String {
85        match class {
86            DiffClass::Structural => self.paint("1;31", "structural"),
87            DiffClass::Exact => self.paint("33", "exact     "),
88            DiffClass::SubEpsilonFloat => self.paint("36", "drift     "),
89        }
90    }
91    fn good(&self, text: &str) -> String {
92        self.paint("32", text)
93    }
94    fn dim(&self, text: &str) -> String {
95        self.paint("2", text)
96    }
97}
98
99fn tick_list(ticks: &[u64]) -> String {
100    let shown: Vec<String> = ticks.iter().take(8).map(u64::to_string).collect();
101    if ticks.len() > 8 {
102        format!("{} and {} more", shown.join(", "), ticks.len() - 8)
103    } else {
104        shown.join(", ")
105    }
106}
107
108fn plural(n: usize, one: &str, many: &str) -> String {
109    if n == 1 {
110        format!("{n} {one}")
111    } else {
112        format!("{n} {many}")
113    }
114}
115
116/// Diffs the dump files at the two paths.
117pub fn render<A: AsRef<Path>, B: AsRef<Path>>(
118    a: A,
119    b: B,
120    options: &DiffOptions,
121) -> Result<DiffOutput, DiffError> {
122    let path_a = a.as_ref();
123    let path_b = b.as_ref();
124    let mut reader_a = RecReader::open(BufReader::new(
125        File::open(path_a).map_err(FormatError::from)?,
126    ))?;
127    let mut reader_b = RecReader::open(BufReader::new(
128        File::open(path_b).map_err(FormatError::from)?,
129    ))?;
130    let meta_a = reader_a.header().meta.clone();
131    let meta_b = reader_b.header().meta.clone();
132
133    let report = structural_from(&mut reader_a, &mut reader_b, options.policy)?;
134    let palette = Palette {
135        enabled: options.color,
136    };
137
138    let mut s = String::new();
139    s.push_str(&format!(
140        "diffing {} and {}\n\n",
141        path_a.display(),
142        path_b.display()
143    ));
144    let common: Vec<u64> = report.ticks.iter().map(|t| t.tick).collect();
145    let describe = |meta: &tickwise::SessionMeta, extra: &[u64]| {
146        let mut ticks: Vec<u64> = common.iter().chain(extra.iter()).copied().collect();
147        ticks.sort_unstable();
148        format!(
149            "game {}, seed {:#x}, dumps at ticks {}",
150            if meta.game_id.is_empty() {
151                "unset"
152            } else {
153                &meta.game_id
154            },
155            meta.rng_seed,
156            tick_list(&ticks)
157        )
158    };
159    s.push_str(&format!(
160        "  first          {}\n",
161        describe(&meta_a, &report.only_in_a)
162    ));
163    s.push_str(&format!(
164        "  second         {}\n",
165        describe(&meta_b, &report.only_in_b)
166    ));
167    s.push_str(&format!(
168        "  float policy   f32 epsilon {:e}, f64 epsilon {:e}\n\n",
169        report.policy.epsilon_f32, report.policy.epsilon_f64
170    ));
171
172    let mut total = 0;
173    for tick in &report.ticks {
174        let n = tick.differences.len();
175        total += n;
176        if tick.is_identical() {
177            s.push_str(&format!(
178                "tick {:<10} {}\n\n",
179                tick.tick,
180                palette.good(&format!("identical over {} fields", tick.fields_compared))
181            ));
182            continue;
183        }
184        s.push_str(&format!(
185            "tick {:<10} {} over {} fields: {} structural, {} exact, {} sub-epsilon float drift\n",
186            tick.tick,
187            plural(n, "difference", "differences"),
188            tick.fields_compared,
189            tick.count(DiffClass::Structural),
190            tick.count(DiffClass::Exact),
191            tick.count(DiffClass::SubEpsilonFloat),
192        ));
193        let limit = if options.show_all { n } else { DEFAULT_LIMIT };
194        for difference in tick.differences.iter().take(limit) {
195            s.push_str(&format!(
196                "  {}     {}: {}\n",
197                palette.class(difference.class),
198                difference.path,
199                difference.detail
200            ));
201        }
202        if n > limit {
203            s.push_str(&palette.dim(&format!(
204                "  ... {} more, pass --all to see every difference\n",
205                n - limit
206            )));
207        }
208        s.push('\n');
209    }
210
211    if !report.only_in_a.is_empty() {
212        s.push_str(&format!(
213            "  only in first  dumps at ticks {}, no counterpart to diff\n",
214            tick_list(&report.only_in_a)
215        ));
216    }
217    if !report.only_in_b.is_empty() {
218        s.push_str(&format!(
219            "  only in second dumps at ticks {}, no counterpart to diff\n",
220            tick_list(&report.only_in_b)
221        ));
222    }
223
224    let differs = !report.is_identical();
225    s.push_str(&format!(
226        "  verdict        {}\n",
227        verdict_line(&report, total, &palette)
228    ));
229    s.push_str(&format!("  next           {}\n", next_hint(&report)));
230
231    Ok(DiffOutput { text: s, differs })
232}
233
234fn verdict_line(report: &DiffReport, total: usize, palette: &Palette) -> String {
235    if report.is_identical() {
236        palette.good(&format!(
237            "identical at {}",
238            plural(report.ticks.len(), "compared tick", "compared ticks")
239        ))
240    } else {
241        format!(
242            "{} across {}",
243            plural(total, "difference", "differences"),
244            plural(report.ticks.len(), "compared tick", "compared ticks")
245        )
246    }
247}
248
249fn next_hint(report: &DiffReport) -> String {
250    if report.is_identical() {
251        return "the dumps agree. If tickwise compare reported a divergence at this tick, \
252                the diverging state is not covered by state_dump, extend it"
253            .to_string();
254    }
255    let structural: usize = report
256        .ticks
257        .iter()
258        .map(|t| t.count(DiffClass::Structural))
259        .sum();
260    let exact: usize = report.ticks.iter().map(|t| t.count(DiffClass::Exact)).sum();
261    if structural > 0 {
262        "structural differences usually mean a collection in unspecified order or a field \
263         missed by a snapshot. Start with the first structural entry above"
264            .to_string()
265    } else if exact > 0 {
266        "an exact difference at the first divergent tick is your lead. Trace that field's \
267         last write backwards through the tick"
268            .to_string()
269    } else {
270        "only sub-epsilon float drift. If you target cross-platform determinism, rerun with \
271         --strict to see every bit, and consider fixed-point math for these fields"
272            .to_string()
273    }
274}