Skip to main content

kitchen_ui/
diff.rs

1//! Line diffs of two texts, git style, for [`crate::widgets::DiffView`]: added and removed lines
2//! with their old and new line numbers, grouped into hunks with a few unchanged lines of context
3//! around each change; the unchanged runs between hunks fold away.
4//!
5//! [`Diff::json`] compares two JSON documents by key instead of by text: both are printed with
6//! sorted keys first, so reordered keys are not noise, and a long array (cells, tiles, labels)
7//! prints as one line, so a changed array reads as one summary line (`cells: 2,539 → 2,712`)
8//! instead of thousands of numbers.
9//!
10//! Plain data: no UI here, so an app (or its tests) can compute a diff without a window.
11
12use std::collections::hash_map::DefaultHasher;
13use std::hash::{Hash as _, Hasher as _};
14use std::ops::Range;
15
16use serde_json::Value;
17use similar::{Algorithm, DiffOp, capture_diff_slices};
18
19/// Unchanged lines shown around each change by default.
20pub const CONTEXT: usize = 3;
21/// Arrays longer than this print as one summary line in [`Diff::json`] by default.
22pub const ARRAY_SUMMARY: usize = 16;
23
24/// What a line of a diff is.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum LineKind {
27    /// Unchanged.
28    Context,
29    /// Only in the new text.
30    Added,
31    /// Only in the old text.
32    Removed,
33    /// A long JSON array that changed, as one line (`cells: 2,539 → 2,712`).
34    Summary,
35}
36
37/// One line of a diff.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct DiffLine {
40    /// What the line is.
41    pub kind: LineKind,
42    /// Its line number in the old text (1-based), if it is there.
43    pub old: Option<usize>,
44    /// Its line number in the new text (1-based), if it is there.
45    pub new: Option<usize>,
46    /// The line, without its newline.
47    pub text: String,
48}
49
50/// A run of changes with its context: [`Diff::lines`]`[lines]`.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct Hunk {
53    /// Its lines in [`Diff::lines`].
54    pub lines: Range<usize>,
55    /// First old line it covers (1-based; the line before it when it covers none).
56    pub old_start: usize,
57    /// Old lines it covers.
58    pub old_len: usize,
59    /// First new line it covers (1-based; the line before it when it covers none).
60    pub new_start: usize,
61    /// New lines it covers.
62    pub new_len: usize,
63}
64
65impl Hunk {
66    /// The unified-diff header: `@@ -12,7 +12,9 @@`.
67    pub fn header(&self) -> String {
68        format!("@@ -{},{} +{},{} @@", self.old_start, self.old_len, self.new_start, self.new_len)
69    }
70}
71
72/// The diff of two texts: every line, and the hunks over them.
73#[derive(Clone, Debug, Default, PartialEq, Eq)]
74pub struct Diff {
75    /// Every line of both texts in order: unchanged, removed and added (and, for JSON, summary)
76    /// lines.
77    pub lines: Vec<DiffLine>,
78    /// The changes with their context, in order. Lines outside every hunk are unchanged
79    /// ([`Diff::gaps`]).
80    pub hunks: Vec<Hunk>,
81    /// Compared as JSON ([`Diff::json`]).
82    pub json: bool,
83    /// Why a JSON comparison fell back to text (a side did not parse).
84    pub note: Option<String>,
85}
86
87impl Diff {
88    /// The line diff of `old` and `new`, with `context` unchanged lines around each change.
89    pub fn text(old: &str, new: &str, context: usize) -> Diff {
90        let (a, b): (Vec<&str>, Vec<&str>) = (old.lines().collect(), new.lines().collect());
91        let lines = walk(&a, &b, |i| a[i].to_string(), |i| b[i].to_string());
92        let hunks = hunks(&lines, context);
93        Diff { lines, hunks, json: false, note: None }
94    }
95
96    /// The diff of two JSON documents compared by key ([`Diff::json_values`]); when a side does
97    /// not parse, the text diff with a [`Diff::note`] saying why.
98    pub fn json(old: &str, new: &str, context: usize, array_summary: usize) -> Diff {
99        let parse = |s: &str| if s.trim().is_empty() { Ok(Value::Null) } else { serde_json::from_str::<Value>(s) };
100        match (parse(old), parse(new)) {
101            (Ok(a), Ok(b)) => Diff::json_values(&a, &b, context, array_summary),
102            (a, b) => {
103                let which = if a.is_err() { "old" } else { "new" };
104                let e = a.err().or(b.err()).map(|e| e.to_string()).unwrap_or_default();
105                Diff { note: Some(format!("The {which} side is not JSON ({e}); compared as text.")), ..Diff::text(old, new, context) }
106            }
107        }
108    }
109
110    /// The diff of two JSON values: both printed with sorted keys (two-space indent), arrays
111    /// longer than `array_summary` as one line each, then compared line by line. A changed long
112    /// array becomes one [`LineKind::Summary`] line. Trailing commas are not compared, so adding
113    /// a key at the end does not mark the line before it as changed.
114    pub fn json_values(old: &Value, new: &Value, context: usize, array_summary: usize) -> Diff {
115        let (a, b) = (print_json(old, array_summary), print_json(new, array_summary));
116        let (ka, kb): (Vec<&str>, Vec<&str>) = (a.iter().map(|l| l.key.as_str()).collect(), b.iter().map(|l| l.key.as_str()).collect());
117        let mut lines = walk(&ka, &kb, |i| a[i].display.clone(), |i| b[i].display.clone());
118        merge_summaries(&mut lines, &a, &b);
119        let hunks = hunks(&lines, context);
120        Diff { lines, hunks, json: true, note: None }
121    }
122
123    /// Whether the texts are the same.
124    pub fn is_empty(&self) -> bool {
125        self.hunks.is_empty()
126    }
127
128    /// Lines added (summary lines count as one added and one removed).
129    pub fn added(&self) -> usize {
130        self.lines.iter().filter(|l| matches!(l.kind, LineKind::Added | LineKind::Summary)).count()
131    }
132
133    /// Lines removed (summary lines count as one added and one removed).
134    pub fn removed(&self) -> usize {
135        self.lines.iter().filter(|l| matches!(l.kind, LineKind::Removed | LineKind::Summary)).count()
136    }
137
138    /// The unchanged runs outside the hunks, as ranges of [`Diff::lines`]: before the first hunk,
139    /// between hunks and after the last (empty runs left out). `gaps()[i]` comes before
140    /// `hunks[i]` when it ends where that hunk starts.
141    pub fn gaps(&self) -> Vec<Range<usize>> {
142        let mut out = Vec::new();
143        let mut at = 0;
144        for h in &self.hunks {
145            if h.lines.start > at {
146                out.push(at..h.lines.start);
147            }
148            at = h.lines.end;
149        }
150        if at < self.lines.len() {
151            out.push(at..self.lines.len());
152        }
153        out
154    }
155
156    /// The diff as unified-diff text (hunk headers, then ` `, `-`, `+` lines; a summary line as
157    /// `~`).
158    pub fn unified(&self) -> String {
159        let mut out = String::new();
160        for h in &self.hunks {
161            out.push_str(&h.header());
162            out.push('\n');
163            for l in &self.lines[h.lines.clone()] {
164                out.push(match l.kind {
165                    LineKind::Context => ' ',
166                    LineKind::Added => '+',
167                    LineKind::Removed => '-',
168                    LineKind::Summary => '~',
169                });
170                out.push_str(&l.text);
171                out.push('\n');
172            }
173        }
174        out
175    }
176
177    /// The old text with the accepted hunks applied (`accepted[i]` for `hunks[i]`; missing
178    /// entries are rejected): an agent's proposed change, reviewed. `None` for a JSON diff (its
179    /// lines are a printout, not the document).
180    pub fn apply(&self, accepted: &[bool]) -> Option<String> {
181        if self.json {
182            return None;
183        }
184        let hunk_of = |i: usize| self.hunks.iter().position(|h| h.lines.contains(&i));
185        let mut out: Vec<&str> = Vec::new();
186        for (i, l) in self.lines.iter().enumerate() {
187            let take = match l.kind {
188                LineKind::Context | LineKind::Summary => true,
189                LineKind::Added => hunk_of(i).is_some_and(|h| accepted.get(h).copied().unwrap_or(false)),
190                LineKind::Removed => !hunk_of(i).is_some_and(|h| accepted.get(h).copied().unwrap_or(false)),
191            };
192            if take {
193                out.push(&l.text);
194            }
195        }
196        let mut s = out.join("\n");
197        if !s.is_empty() {
198            s.push('\n');
199        }
200        Some(s)
201    }
202}
203
204/// Every line of both sides in order, from the line ops of `a` and `b`.
205fn walk<K: Eq + std::hash::Hash>(a: &[K], b: &[K], old_text: impl Fn(usize) -> String, new_text: impl Fn(usize) -> String) -> Vec<DiffLine> {
206    let mut lines = Vec::with_capacity(a.len().max(b.len()));
207    let removed = |lines: &mut Vec<DiffLine>, from: usize, len: usize| {
208        lines.extend((from..from + len).map(|i| DiffLine { kind: LineKind::Removed, old: Some(i + 1), new: None, text: old_text(i) }));
209    };
210    let added = |lines: &mut Vec<DiffLine>, from: usize, len: usize| {
211        lines.extend((from..from + len).map(|i| DiffLine { kind: LineKind::Added, old: None, new: Some(i + 1), text: new_text(i) }));
212    };
213    for op in capture_diff_slices(Algorithm::Myers, a, b) {
214        match op {
215            DiffOp::Equal { old_index, new_index, len } => {
216                // The new side's text: a trailing comma that came or went shows as it is now
217                lines.extend((0..len).map(|k| DiffLine { kind: LineKind::Context, old: Some(old_index + k + 1), new: Some(new_index + k + 1), text: new_text(new_index + k) }));
218            }
219            DiffOp::Delete { old_index, old_len, .. } => removed(&mut lines, old_index, old_len),
220            DiffOp::Insert { new_index, new_len, .. } => added(&mut lines, new_index, new_len),
221            DiffOp::Replace { old_index, old_len, new_index, new_len } => {
222                removed(&mut lines, old_index, old_len);
223                added(&mut lines, new_index, new_len);
224            }
225        }
226    }
227    lines
228}
229
230/// The hunks over `lines`: each change with up to `context` unchanged lines on either side;
231/// changes whose context touches or overlaps join into one hunk.
232fn hunks(lines: &[DiffLine], context: usize) -> Vec<Hunk> {
233    let mut ranges: Vec<Range<usize>> = Vec::new();
234    for (i, l) in lines.iter().enumerate() {
235        if l.kind == LineKind::Context {
236            continue;
237        }
238        let r = i.saturating_sub(context)..(i + 1 + context).min(lines.len());
239        match ranges.last_mut() {
240            Some(last) if r.start <= last.end => last.end = last.end.max(r.end),
241            _ => ranges.push(r),
242        }
243    }
244    ranges
245        .into_iter()
246        .map(|r| {
247            let before = &lines[..r.start];
248            let old_before = before.iter().filter(|l| l.old.is_some()).count();
249            let new_before = before.iter().filter(|l| l.new.is_some()).count();
250            let inside = &lines[r.clone()];
251            let old_len = inside.iter().filter(|l| l.old.is_some()).count();
252            let new_len = inside.iter().filter(|l| l.new.is_some()).count();
253            Hunk {
254                lines: r,
255                old_start: if old_len > 0 { old_before + 1 } else { old_before },
256                old_len,
257                new_start: if new_len > 0 { new_before + 1 } else { new_before },
258                new_len,
259            }
260        })
261        .collect()
262}
263
264/// A printed JSON line: what is compared, what is shown, and the long array it stands for.
265struct JsonLine {
266    key: String,
267    display: String,
268    array: Option<(String, usize)>,
269}
270
271/// `v` printed with sorted keys, two-space indent, long arrays as one line.
272fn print_json(v: &Value, array_summary: usize) -> Vec<JsonLine> {
273    let mut out = Vec::new();
274    print_value(v, 0, "", None, false, array_summary, &mut out);
275    out
276}
277
278fn print_value(v: &Value, depth: usize, prefix: &str, label: Option<&str>, comma: bool, max: usize, out: &mut Vec<JsonLine>) {
279    let pad = "  ".repeat(depth);
280    let c = if comma { "," } else { "" };
281    let mut line = |body: String, array: Option<(String, usize)>, hash: Option<u64>| {
282        let key = match hash {
283            Some(h) => format!("{pad}{prefix}{body}#{h:016x}"),
284            None => format!("{pad}{prefix}{body}"),
285        };
286        out.push(JsonLine { display: format!("{pad}{prefix}{body}{c}"), key, array });
287    };
288    match v {
289        Value::Object(map) if !map.is_empty() => {
290            line("{".into(), None, None);
291            let mut keys: Vec<&String> = map.keys().collect();
292            keys.sort();
293            let n = keys.len();
294            for (i, k) in keys.into_iter().enumerate() {
295                let p = format!("{}: ", Value::String(k.clone()));
296                print_value(&map[k], depth + 1, &p, Some(k), i + 1 < n, max, out);
297            }
298            out.push(JsonLine { key: format!("{pad}}}"), display: format!("{pad}}}{c}"), array: None });
299        }
300        Value::Array(items) if items.len() > max => {
301            let mut h = DefaultHasher::new();
302            serde_json::to_string(v).unwrap_or_default().hash(&mut h);
303            let name = label.unwrap_or("items").to_string();
304            line(format!("[{} items]", group_digits(items.len())), Some((name, items.len())), Some(h.finish()));
305        }
306        Value::Array(items) if !items.is_empty() => {
307            line("[".into(), None, None);
308            let n = items.len();
309            for (i, item) in items.iter().enumerate() {
310                print_value(item, depth + 1, "", label, i + 1 < n, max, out);
311            }
312            out.push(JsonLine { key: format!("{pad}]"), display: format!("{pad}]{c}"), array: None });
313        }
314        _ => line(serde_json::to_string(v).unwrap_or_default(), None, None),
315    }
316}
317
318/// In each run of changes, a removed long array and an added one of the same name become one
319/// summary line (`cells: 2,539 → 2,712`).
320fn merge_summaries(lines: &mut Vec<DiffLine>, a: &[JsonLine], b: &[JsonLine]) {
321    let array_of = |l: &DiffLine| match l.kind {
322        LineKind::Removed => l.old.and_then(|n| a[n - 1].array.clone()),
323        LineKind::Added => l.new.and_then(|n| b[n - 1].array.clone()),
324        _ => None,
325    };
326    let mut i = 0;
327    while i < lines.len() {
328        if lines[i].kind == LineKind::Context {
329            i += 1;
330            continue;
331        }
332        let end = (i..lines.len()).find(|&j| lines[j].kind == LineKind::Context).unwrap_or(lines.len());
333        let mut drop = Vec::new();
334        for r in i..end {
335            if lines[r].kind != LineKind::Removed {
336                continue;
337            }
338            let Some((name, old_len)) = array_of(&lines[r]) else { continue };
339            let found = (i..end).find(|&j| !drop.contains(&j) && lines[j].kind == LineKind::Added && array_of(&lines[j]).is_some_and(|(n, _)| n == name));
340            if let Some(j) = found {
341                let new_len = array_of(&lines[j]).map(|(_, n)| n).unwrap_or(0);
342                let pad: String = lines[r].text.chars().take_while(|c| *c == ' ').collect();
343                let text = if old_len == new_len {
344                    format!("{pad}{name}: {} items, changed", group_digits(new_len))
345                } else {
346                    format!("{pad}{name}: {} → {}", group_digits(old_len), group_digits(new_len))
347                };
348                lines[r] = DiffLine { kind: LineKind::Summary, old: lines[r].old, new: lines[j].new, text };
349                drop.push(j);
350            }
351        }
352        drop.sort_unstable();
353        for j in drop.iter().rev() {
354            lines.remove(*j);
355        }
356        i = end - drop.len();
357    }
358}
359
360/// `2539` → `"2,539"`.
361pub fn group_digits(n: usize) -> String {
362    let s = n.to_string();
363    let mut out = String::with_capacity(s.len() + s.len() / 3);
364    for (i, ch) in s.chars().enumerate() {
365        if i > 0 && (s.len() - i) % 3 == 0 {
366            out.push(',');
367        }
368        out.push(ch);
369    }
370    out
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use serde_json::json;
377
378    fn kinds(d: &Diff) -> String {
379        d.lines
380            .iter()
381            .map(|l| match l.kind {
382                LineKind::Context => ' ',
383                LineKind::Added => '+',
384                LineKind::Removed => '-',
385                LineKind::Summary => '~',
386            })
387            .collect()
388    }
389
390    #[test]
391    fn text_diff_numbers_lines_and_folds_context() {
392        let old: String = (1..=20).map(|i| format!("line {i}\n")).collect();
393        let new = old.replace("line 5\n", "line five\n").replace("line 18\n", "line 18\nline 18b\n");
394        let d = Diff::text(&old, &new, 2);
395        assert_eq!(d.added(), 2);
396        assert_eq!(d.removed(), 1);
397        assert_eq!(d.hunks.len(), 2, "two changes far apart: two hunks");
398        let h = &d.hunks[0];
399        assert_eq!(h.header(), "@@ -3,5 +3,5 @@");
400        let first: Vec<&DiffLine> = d.lines[h.lines.clone()].iter().collect();
401        assert_eq!(first[2].kind, LineKind::Removed);
402        assert_eq!((first[2].old, first[2].new), (Some(5), None));
403        assert_eq!(first[3].kind, LineKind::Added);
404        assert_eq!((first[3].old, first[3].new), (None, Some(5)));
405        assert_eq!(d.hunks[1].header(), "@@ -17,4 +17,5 @@");
406        // Unchanged runs: before the first hunk, between, none after (the change is near the end)
407        let gaps = d.gaps();
408        assert_eq!(gaps.len(), 2);
409        assert_eq!(gaps[0], 0..2);
410        assert_eq!(gaps[1].len(), 9);
411        assert!(d.gaps().iter().flat_map(|g| d.lines[g.clone()].iter()).all(|l| l.kind == LineKind::Context));
412    }
413
414    #[test]
415    fn near_changes_share_a_hunk() {
416        let d = Diff::text("a\nb\nc\nd\ne\n", "a\nB\nc\nD\ne\n", 3);
417        assert_eq!(d.hunks.len(), 1);
418        assert_eq!(kinds(&d), " -+ -+ ");
419        assert!(Diff::text("same\n", "same\n", 3).is_empty());
420    }
421
422    #[test]
423    fn apply_takes_only_accepted_hunks() {
424        let old: String = (1..=20).map(|i| format!("l{i}\n")).collect();
425        let new = old.replace("l2\n", "two\n").replace("l19\n", "nineteen\n");
426        let d = Diff::text(&old, &new, 1);
427        assert_eq!(d.hunks.len(), 2);
428        assert_eq!(d.apply(&[true, true]).unwrap(), new);
429        assert_eq!(d.apply(&[false, false]).unwrap(), old);
430        assert_eq!(d.apply(&[false, true]).unwrap(), old.replace("l19\n", "nineteen\n"));
431        assert!(d.unified().starts_with("@@ -1,3 +1,3 @@\n l1\n-l2\n+two\n"), "{}", d.unified());
432    }
433
434    #[test]
435    fn json_ignores_key_order_and_trailing_commas() {
436        let old = r#"{"b": 1, "a": {"y": 2, "x": 1}}"#;
437        let new = r#"{"a": {"x": 1, "y": 2}, "b": 1}"#;
438        assert!(Diff::json(old, new, CONTEXT, ARRAY_SUMMARY).is_empty());
439        // A key added at the end changes one line only
440        let d = Diff::json(r#"{"a": 1}"#, r#"{"a": 1, "b": 2}"#, CONTEXT, ARRAY_SUMMARY);
441        assert_eq!(kinds(&d), "  + ");
442        assert_eq!(d.lines[1].text, r#"  "a": 1,"#);
443        assert_eq!(d.lines[2].text, r#"  "b": 2"#);
444    }
445
446    #[test]
447    fn json_long_arrays_are_one_summary_line() {
448        let old = json!({"cells": (0..2539).collect::<Vec<_>>(), "image": "slide-01", "tiles": (0..40).collect::<Vec<_>>()});
449        let new = json!({"tiles": (0..40).collect::<Vec<_>>(), "image": "slide-01", "cells": (0..2712).collect::<Vec<_>>()});
450        let d = Diff::json_values(&old, &new, CONTEXT, ARRAY_SUMMARY);
451        assert_eq!(d.lines.len(), 5, "{:#?}", d.lines);
452        assert_eq!(kinds(&d), " ~   ");
453        assert_eq!(d.lines[1].text, "  cells: 2,539 → 2,712");
454        assert_eq!(d.lines[3].text, r#"  "tiles": [40 items]"#);
455        // Same length, other values: still a change
456        let d = Diff::json_values(&json!({"cells": (0..100).collect::<Vec<_>>()}), &json!({"cells": (1..101).collect::<Vec<_>>()}), CONTEXT, ARRAY_SUMMARY);
457        assert_eq!(d.lines[1].text, "  cells: 100 items, changed");
458        // Short arrays print element by element
459        let d = Diff::json_values(&json!({"steps": [1, 2]}), &json!({"steps": [1, 3]}), CONTEXT, ARRAY_SUMMARY);
460        assert_eq!(kinds(&d), "   -+  ");
461    }
462
463    #[test]
464    fn json_that_does_not_parse_falls_back_to_text() {
465        let d = Diff::json("{\"a\": 1}", "{oops", CONTEXT, ARRAY_SUMMARY);
466        assert!(!d.json);
467        assert!(d.note.as_deref().is_some_and(|n| n.starts_with("The new side is not JSON")));
468        assert_eq!(d.added(), 1);
469    }
470
471    #[test]
472    fn digits_are_grouped() {
473        assert_eq!(group_digits(0), "0");
474        assert_eq!(group_digits(999), "999");
475        assert_eq!(group_digits(2539), "2,539");
476        assert_eq!(group_digits(1234567), "1,234,567");
477    }
478}