Skip to main content

ferridriver_expect/
diff.rs

1//! Unified-diff rendering for assertion failures.
2//!
3//! Produces a plain-text diff (no ANSI) so it survives the
4//! Rust → QuickJS → JS error round trip. Printers add color at output
5//! time. The format matches the GNU `diff -u` shape — `-` for expected
6//! lines (what we asked for), `+` for received (what the test got),
7//! ` ` for context.
8
9use similar::{ChangeTag, TextDiff};
10
11use crate::asymmetric::Asymmetric;
12
13/// Render a `serde_json::Value` as multi-line pretty JSON with
14/// asymmetric matchers rendered as `<Description>` placeholders so the
15/// diff highlights the matcher rather than its tagged wire shape.
16pub fn pretty_json(v: &serde_json::Value) -> String {
17  let humanized = humanize_asymmetric(v);
18  serde_json::to_string_pretty(&humanized).unwrap_or_else(|_| humanized.to_string())
19}
20
21fn humanize_asymmetric(v: &serde_json::Value) -> serde_json::Value {
22  if let Some(asym) = Asymmetric::from_value(v) {
23    return serde_json::Value::String(asym.description());
24  }
25  match v {
26    serde_json::Value::Array(arr) => serde_json::Value::Array(arr.iter().map(humanize_asymmetric).collect()),
27    serde_json::Value::Object(map) => {
28      let mut out = serde_json::Map::with_capacity(map.len());
29      for (k, val) in map {
30        out.insert(k.clone(), humanize_asymmetric(val));
31      }
32      serde_json::Value::Object(out)
33    },
34    other => other.clone(),
35  }
36}
37
38/// Unified diff between two pretty-printed strings. Lines are prefixed
39/// with `-` / `+` / ` `; empty when the two inputs are byte-identical.
40pub fn unified_diff(expected: &str, received: &str) -> String {
41  let diff = TextDiff::from_lines(expected, received);
42  let mut out = String::new();
43  for change in diff.iter_all_changes() {
44    let sign = match change.tag() {
45      ChangeTag::Delete => '-',
46      ChangeTag::Insert => '+',
47      ChangeTag::Equal => ' ',
48    };
49    out.push(sign);
50    out.push_str(change.value().trim_end_matches('\n'));
51    out.push('\n');
52  }
53  out.pop(); // drop final '\n' so multi-line messages don't double-blank.
54  out
55}
56
57/// Render `expected` vs `received` as pretty JSON + a unified diff.
58/// Suitable for `toEqual` / `toMatchObject` / `toContainEqual` where
59/// the full structural shape matters.
60pub fn json_diff(expected: &serde_json::Value, received: &serde_json::Value) -> String {
61  let expected_str = pretty_json(expected);
62  let received_str = pretty_json(received);
63  unified_diff(&expected_str, &received_str)
64}
65
66#[cfg(test)]
67mod tests {
68  use super::*;
69  use pretty_assertions::assert_eq;
70  use serde_json::json;
71
72  #[test]
73  fn pretty_json_renders_multiline() {
74    let v = json!({"a": 1, "b": [2, 3]});
75    let s = pretty_json(&v);
76    assert!(s.contains('\n'), "expected multi-line: {s}");
77  }
78
79  #[test]
80  fn pretty_json_humanizes_asymmetric() {
81    let v = json!({ "id": { crate::ASYM_TAG_KEY: "any", "name": "Number" } });
82    let s = pretty_json(&v);
83    assert!(s.contains("Any<Number>"), "humanized asym missing: {s}");
84    assert!(!s.contains("@@asym"), "raw asym tag leaked: {s}");
85  }
86
87  #[test]
88  fn unified_diff_marks_changed_lines() {
89    let a = "line1\nline2\nline3";
90    let b = "line1\nLINE2\nline3";
91    let d = unified_diff(a, b);
92    assert!(d.contains("-line2"), "missing '-' marker: {d}");
93    assert!(d.contains("+LINE2"), "missing '+' marker: {d}");
94    assert!(d.contains(" line1"), "missing context: {d}");
95  }
96
97  #[test]
98  fn json_diff_for_object_mismatch() {
99    let expected = json!({"a": 1, "b": 2});
100    let actual = json!({"a": 1, "b": 3});
101    let d = json_diff(&expected, &actual);
102    assert!(d.contains('-'), "diff has no removals: {d}");
103    assert!(d.contains('+'), "diff has no additions: {d}");
104  }
105
106  #[test]
107  fn json_diff_empty_for_equal_values() {
108    // Equal values still produce a fully-` `-prefixed diff (no -/+
109    // lines) — useful so callers can assert "no real changes" by
110    // checking whether any line starts with -/+.
111    let v = json!({"a": 1});
112    let d = json_diff(&v, &v);
113    assert_eq!(
114      d.lines().filter(|l| l.starts_with('-') || l.starts_with('+')).count(),
115      0
116    );
117  }
118}