Skip to main content

harn_vm/value/
diff.rs

1//! Structural, path-addressed comparison of two values.
2//!
3//! This is the engine behind `assert_eq` / `assert_ne` / `value_diff`. Where
4//! `values_equal` answers "are these the same?", this answers "*where* are they
5//! different, and how?" — which is the question an author actually has when an
6//! assertion fails at 2am.
7//!
8//! Two decisions shape everything here:
9//!
10//! * **Address, don't dump.** A mismatch three levels inside a dict is reported
11//!   as `.user.roles[1]`, not by printing both whole values and leaving the
12//!   reader to compare them by eye. Only the leaves that actually differ are
13//!   rendered.
14//! * **Use the types.** Values carry their type at runtime, so a diff can say
15//!   `1 (int)` vs `"1" (string)` instead of showing two identical-looking
16//!   glyphs. Text-based differs cannot do this; we can, so we do.
17//!
18//! Rendering is deterministic: dict keys iterate in sorted order and sets are
19//! sorted by their rendered form, so failure output is stable across runs and
20//! safe to assert on byte-for-byte.
21
22use std::fmt::Write;
23
24use super::core::{struct_fields_to_map, VmValue};
25use super::recursion::guard_recursion;
26use super::structural::values_equal;
27
28/// The most differing leaves reported before the diff summarizes the rest.
29/// Past this, a wall of text stops being a diff and starts being a haystack.
30const MAX_DIFFERENCES: usize = 10;
31
32/// The longest a single rendered value may be before it is abbreviated. Chosen
33/// so a value still fits comfortably on one terminal line alongside its label.
34const MAX_LEAF_CHARS: usize = 120;
35
36/// How the two sides disagree at one path.
37#[derive(Debug, Clone)]
38pub enum DifferenceKind {
39    /// Both sides have a value here, and the values are not equal.
40    Unequal { actual: VmValue, expected: VmValue },
41    /// Only the actual value has anything here: an extra dict key, list item,
42    /// or set member.
43    Unexpected { actual: VmValue },
44    /// Only the expected value has anything here: a dict key, list item, or set
45    /// member the actual value is missing.
46    Missing { expected: VmValue },
47}
48
49/// One place where two values disagree.
50#[derive(Debug, Clone)]
51pub struct ValueDifference {
52    /// Path from the root of the compared values, in Harn access syntax:
53    /// `.user.name`, `.items[2]`, `.headers["content-type"]`. Empty at the root.
54    pub path: String,
55    pub kind: DifferenceKind,
56}
57
58/// Every place `actual` and `expected` disagree, deepest-addressable first.
59///
60/// Returns an empty vec exactly when `values_equal(actual, expected)` is true —
61/// the two are kept in lockstep by [`tests::diff_is_empty_iff_values_equal`].
62pub fn diff_values(actual: &VmValue, expected: &VmValue) -> Vec<ValueDifference> {
63    let mut out = Vec::new();
64    walk(String::new(), actual, expected, &mut out);
65    out
66}
67
68fn walk(path: String, actual: &VmValue, expected: &VmValue, out: &mut Vec<ValueDifference>) {
69    if values_equal(actual, expected) {
70        return;
71    }
72    match (actual, expected) {
73        (VmValue::Dict(a), VmValue::Dict(e)) => {
74            guard_recursion(|| {
75                // Both maps iterate sorted, so merging them by key is both
76                // linear and deterministic.
77                let mut keys: Vec<&str> = a.keys().map(|k| k.as_str()).collect();
78                keys.extend(e.keys().map(|k| k.as_str()));
79                keys.sort_unstable();
80                keys.dedup();
81                for key in keys {
82                    let child = format!("{path}{}", render_key_step(key));
83                    match (a.get(key), e.get(key)) {
84                        (Some(av), Some(ev)) => walk(child, av, ev, out),
85                        (Some(av), None) => out.push(ValueDifference {
86                            path: child,
87                            kind: DifferenceKind::Unexpected { actual: av.clone() },
88                        }),
89                        (None, Some(ev)) => out.push(ValueDifference {
90                            path: child,
91                            kind: DifferenceKind::Missing {
92                                expected: ev.clone(),
93                            },
94                        }),
95                        (None, None) => {}
96                    }
97                }
98            });
99        }
100        (VmValue::List(a), VmValue::List(e)) => {
101            guard_recursion(|| walk_sequence(&path, a, e, out));
102        }
103        (VmValue::StructInstance(a), VmValue::StructInstance(e))
104            if a.layout.struct_name() == e.layout.struct_name() =>
105        {
106            guard_recursion(|| {
107                let a_fields = struct_fields_to_map(&a.layout, &a.fields);
108                let e_fields = struct_fields_to_map(&e.layout, &e.fields);
109                let mut keys: Vec<&str> = a_fields.keys().map(|k| k.as_str()).collect();
110                keys.extend(e_fields.keys().map(|k| k.as_str()));
111                keys.sort_unstable();
112                keys.dedup();
113                for key in keys {
114                    let child = format!("{path}{}", render_key_step(key));
115                    match (a_fields.get(key), e_fields.get(key)) {
116                        (Some(av), Some(ev)) => walk(child, av, ev, out),
117                        (Some(av), None) => out.push(ValueDifference {
118                            path: child,
119                            kind: DifferenceKind::Unexpected { actual: av.clone() },
120                        }),
121                        (None, Some(ev)) => out.push(ValueDifference {
122                            path: child,
123                            kind: DifferenceKind::Missing {
124                                expected: ev.clone(),
125                            },
126                        }),
127                        (None, None) => {}
128                    }
129                }
130            });
131        }
132        (VmValue::EnumVariant(a), VmValue::EnumVariant(e))
133            if a.enum_name == e.enum_name
134                && a.variant == e.variant
135                && a.fields.len() == e.fields.len() =>
136        {
137            // Same enum and variant, differing payload. The payload of a
138            // variant is reached as `value.fields[i]` in Harn, so that is what
139            // the path says — a bare `[i]` here would read as a list index into
140            // something that is not a list.
141            guard_recursion(|| {
142                walk_sequence(&format!("{path}.fields"), &a.fields, &e.fields, out);
143            });
144        }
145        (VmValue::Set(a), VmValue::Set(e)) => {
146            // Sets are unordered, so an index-wise walk would report noise.
147            // Report membership only, sorted by rendering for determinism.
148            let mut extra: Vec<&VmValue> = a.iter().filter(|v| !e.contains(v)).collect();
149            let mut absent: Vec<&VmValue> = e.iter().filter(|v| !a.contains(v)).collect();
150            extra.sort_by_cached_key(|v| repr(v));
151            absent.sort_by_cached_key(|v| repr(v));
152            for value in extra {
153                out.push(ValueDifference {
154                    path: format!("{path}{{{}}}", repr(value)),
155                    kind: DifferenceKind::Unexpected {
156                        actual: value.clone(),
157                    },
158                });
159            }
160            for value in absent {
161                out.push(ValueDifference {
162                    path: format!("{path}{{{}}}", repr(value)),
163                    kind: DifferenceKind::Missing {
164                        expected: value.clone(),
165                    },
166                });
167            }
168        }
169        // Two values of unrelated shape (or scalars): the disagreement is here,
170        // whole. Recursing into a dict against a list would invent a
171        // correspondence that does not exist.
172        _ => out.push(ValueDifference {
173            path,
174            kind: DifferenceKind::Unequal {
175                actual: actual.clone(),
176                expected: expected.clone(),
177            },
178        }),
179    }
180}
181
182fn walk_sequence(path: &str, a: &[VmValue], e: &[VmValue], out: &mut Vec<ValueDifference>) {
183    for index in 0..a.len().max(e.len()) {
184        let child = format!("{path}[{index}]");
185        match (a.get(index), e.get(index)) {
186            (Some(av), Some(ev)) => walk(child, av, ev, out),
187            (Some(av), None) => out.push(ValueDifference {
188                path: child,
189                kind: DifferenceKind::Unexpected { actual: av.clone() },
190            }),
191            (None, Some(ev)) => out.push(ValueDifference {
192                path: child,
193                kind: DifferenceKind::Missing {
194                    expected: ev.clone(),
195                },
196            }),
197            (None, None) => {}
198        }
199    }
200}
201
202/// A dict/struct key as a path step: `.name` when it is a plain identifier,
203/// `["odd key"]` otherwise — so the path is always something the reader can
204/// paste back into their program.
205fn render_key_step(key: &str) -> String {
206    let plain = !key.is_empty()
207        && !key.starts_with(|c: char| c.is_ascii_digit())
208        && key
209            .chars()
210            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$');
211    if plain {
212        format!(".{key}")
213    } else {
214        format!("[{}]", quote_string(key))
215    }
216}
217
218// -------------------------------------------------------------------------------------------------
219// Rendering
220// -------------------------------------------------------------------------------------------------
221
222/// An unambiguous rendering of `value`, in Harn literal syntax where one
223/// exists.
224///
225/// This is deliberately not `display()`: `display()` prints a string without
226/// quotes, which makes `1` and `"1"` render identically — the single worst
227/// property a value can have in assertion output.
228pub fn repr(value: &VmValue) -> String {
229    let mut out = String::new();
230    write_repr(value, &mut out);
231    out
232}
233
234fn write_repr(value: &VmValue, out: &mut String) {
235    match value {
236        VmValue::String(s) => out.push_str(&quote_string(s)),
237        VmValue::List(items) => {
238            out.push('[');
239            guard_recursion(|| {
240                for (i, item) in items.iter().enumerate() {
241                    if i > 0 {
242                        out.push_str(", ");
243                    }
244                    write_repr(item, out);
245                }
246            });
247            out.push(']');
248        }
249        VmValue::Dict(map) => {
250            out.push('{');
251            guard_recursion(|| {
252                for (i, (k, v)) in map.iter().enumerate() {
253                    if i > 0 {
254                        out.push_str(", ");
255                    }
256                    out.push_str(&quote_string(k));
257                    out.push_str(": ");
258                    write_repr(v, out);
259                }
260            });
261            out.push('}');
262        }
263        VmValue::Set(members) => {
264            let mut rendered: Vec<String> = members.iter().map(repr).collect();
265            rendered.sort();
266            let _ = write!(out, "set([{}])", rendered.join(", "));
267        }
268        VmValue::StructInstance(data) => {
269            let _ = write!(out, "{} {{", data.layout.struct_name());
270            guard_recursion(|| {
271                for (i, (k, v)) in struct_fields_to_map(&data.layout, &data.fields)
272                    .iter()
273                    .enumerate()
274                {
275                    if i > 0 {
276                        out.push_str(", ");
277                    }
278                    let _ = write!(out, "{k}: ");
279                    write_repr(v, out);
280                }
281            });
282            out.push('}');
283        }
284        VmValue::EnumVariant(variant) => {
285            let _ = write!(out, "{}::{}", variant.enum_name, variant.variant);
286            if !variant.fields.is_empty() {
287                out.push('(');
288                guard_recursion(|| {
289                    for (i, field) in variant.fields.iter().enumerate() {
290                        if i > 0 {
291                            out.push_str(", ");
292                        }
293                        write_repr(field, out);
294                    }
295                });
296                out.push(')');
297            }
298        }
299        // Everything else already renders unambiguously.
300        other => other.write_display(out),
301    }
302}
303
304fn quote_string(s: &str) -> String {
305    let mut out = String::with_capacity(s.len() + 2);
306    out.push('"');
307    for c in s.chars() {
308        match c {
309            '"' => out.push_str("\\\""),
310            '\\' => out.push_str("\\\\"),
311            '\n' => out.push_str("\\n"),
312            '\r' => out.push_str("\\r"),
313            '\t' => out.push_str("\\t"),
314            _ => out.push(c),
315        }
316    }
317    out.push('"');
318    out
319}
320
321/// `repr`, abbreviated in the middle if it is too long to read. Keeping both
322/// ends matters: a long string usually differs at one end, and a trailing
323/// ellipsis alone would hide it.
324fn repr_abbreviated(value: &VmValue) -> String {
325    let full = repr(value);
326    let chars: Vec<char> = full.chars().collect();
327    if chars.len() <= MAX_LEAF_CHARS {
328        return full;
329    }
330    let keep = MAX_LEAF_CHARS / 2 - 6;
331    let head: String = chars[..keep].iter().collect();
332    let tail: String = chars[chars.len() - keep..].iter().collect();
333    format!("{head} … {tail}   ({} characters in all)", chars.len())
334}
335
336/// Render one side of a difference, tagging its type when the two sides'
337/// types disagree — that is precisely when the values may look identical but
338/// are not (`1` vs `"1"`, `1` vs `1.0`).
339fn render_side(value: &VmValue, counterpart: Option<&VmValue>) -> String {
340    let rendered = repr_abbreviated(value);
341    match counterpart {
342        Some(other) if other.type_name() != value.type_name() => {
343            format!("{rendered} ({})", value.type_name())
344        }
345        _ => rendered,
346    }
347}
348
349/// A one-line nudge for the mistakes a bare value comparison cannot explain by
350/// itself. Absent when there is nothing useful to add — an unconditional hint
351/// is noise, and noise is what makes people stop reading failure output.
352fn hint_for(actual: &VmValue, expected: &VmValue) -> Option<String> {
353    match (actual, expected) {
354        (VmValue::Float(a), VmValue::Float(e)) => {
355            let gap = (a - e).abs();
356            if gap == 0.0 || !gap.is_finite() {
357                return None;
358            }
359            Some(format!(
360                "These differ by {gap:e}. Floating-point arithmetic is inexact, so exact \
361                 equality on computed floats is usually a bug in the test, not the code — \
362                 compare with a tolerance using assert_approx."
363            ))
364        }
365        (VmValue::String(a), VmValue::String(e)) => {
366            let index = a
367                .chars()
368                .zip(e.chars())
369                .position(|(x, y)| x != y)
370                .unwrap_or_else(|| a.chars().count().min(e.chars().count()));
371            if a.chars().count() != e.chars().count()
372                && index == a.chars().count().min(e.chars().count())
373            {
374                Some(format!(
375                    "The first {index} characters match; the strings differ in length \
376                     ({} vs {}).",
377                    a.chars().count(),
378                    e.chars().count()
379                ))
380            } else {
381                Some(format!("The strings first differ at character {index}."))
382            }
383        }
384        (VmValue::Int(_), VmValue::String(_)) | (VmValue::String(_), VmValue::Int(_)) => Some(
385            "One side is a number and the other is text. If this came from parsed input, \
386             the conversion may be missing."
387                .to_string(),
388        ),
389        _ => None,
390    }
391}
392
393/// The addressed, per-leaf diff naming what differs between the two values and
394/// where. `None` when they are equal.
395///
396/// `headline` is the caller's framing (e.g. `Some("assert_eq failed")`), or
397/// `None` to render the diff on its own for a caller that supplies its own
398/// context.
399pub fn render_diff(headline: Option<&str>, actual: &VmValue, expected: &VmValue) -> Option<String> {
400    let differences = diff_values(actual, expected);
401    if differences.is_empty() {
402        return None;
403    }
404    let mut out = String::new();
405
406    // The common case — one whole-value mismatch — deserves the plainest
407    // possible rendering. Path headers and difference counts would be
408    // ceremony around two lines of substance.
409    let root_only = differences.len() == 1 && differences[0].path.is_empty();
410    match (headline, root_only) {
411        (Some(headline), true) => {
412            let _ = writeln!(out, "{headline}.");
413        }
414        (Some(headline), false) => {
415            let _ = writeln!(
416                out,
417                "{headline}: the two values differ in {}.\n",
418                plural(differences.len(), "place", "places")
419            );
420        }
421        (None, true) => {}
422        (None, false) => {
423            let _ = writeln!(
424                out,
425                "The two values differ in {}.\n",
426                plural(differences.len(), "place", "places")
427            );
428        }
429    }
430
431    for difference in differences.iter().take(MAX_DIFFERENCES) {
432        if !difference.path.is_empty() {
433            let _ = writeln!(out, "  at {}", difference.path);
434        }
435        match &difference.kind {
436            DifferenceKind::Unequal { actual, expected } => {
437                let _ = writeln!(out, "    expected  {}", render_side(expected, Some(actual)));
438                let _ = writeln!(out, "    actual    {}", render_side(actual, Some(expected)));
439                if let Some(hint) = hint_for(actual, expected) {
440                    let _ = writeln!(out, "    {hint}");
441                }
442            }
443            DifferenceKind::Unexpected { actual } => {
444                let _ = writeln!(out, "    expected  nothing here");
445                let _ = writeln!(out, "    actual    {}", repr_abbreviated(actual));
446            }
447            DifferenceKind::Missing { expected } => {
448                let _ = writeln!(out, "    expected  {}", repr_abbreviated(expected));
449                let _ = writeln!(out, "    actual    nothing here");
450            }
451        }
452        if !root_only {
453            out.push('\n');
454        }
455    }
456
457    if differences.len() > MAX_DIFFERENCES {
458        let suppressed = differences.len() - MAX_DIFFERENCES;
459        let _ = writeln!(
460            out,
461            "  … and {suppressed} more {}.",
462            if suppressed == 1 {
463                "difference"
464            } else {
465                "differences"
466            }
467        );
468    }
469
470    Some(out.trim_end().to_string())
471}
472
473fn plural(count: usize, one: &str, many: &str) -> String {
474    if count == 1 {
475        format!("{count} {one}")
476    } else {
477        format!("{count} {many}")
478    }
479}
480
481#[cfg(test)]
482mod tests;