Skip to main content

dejavu/reduce/
compare.rs

1//! Line-level diff statistics and compact unified diffs (spec §12.4).
2
3use similar::{ChangeTag, TextDiff};
4
5pub struct DiffStats {
6    pub changed_lines: usize,
7    pub ratio: f64,
8}
9
10/// `changed_lines = inserts + deletes`; `ratio = changed / max(prev, curr)`
11/// (decision #6). Both-empty → ratio 0.0.
12pub fn diff_stats(prev: &str, curr: &str) -> DiffStats {
13    let diff = TextDiff::from_lines(prev, curr);
14    let mut changed = 0usize;
15    for change in diff.iter_all_changes() {
16        match change.tag() {
17            ChangeTag::Insert | ChangeTag::Delete => changed += 1,
18            ChangeTag::Equal => {}
19        }
20    }
21    let prev_lines = prev.lines().count();
22    let curr_lines = curr.lines().count();
23    let denom = prev_lines.max(curr_lines).max(1);
24    DiffStats {
25        changed_lines: changed,
26        ratio: changed as f64 / denom as f64,
27    }
28}
29
30/// A compact unified diff, capped to `max_lines` output lines.
31pub fn unified_diff(prev: &str, curr: &str, context: usize, max_lines: usize) -> String {
32    let diff = TextDiff::from_lines(prev, curr);
33    let mut out: Vec<String> = Vec::new();
34    'outer: for group in diff.grouped_ops(context) {
35        for op in group {
36            for change in diff.iter_changes(&op) {
37                let sign = match change.tag() {
38                    ChangeTag::Delete => '-',
39                    ChangeTag::Insert => '+',
40                    ChangeTag::Equal => ' ',
41                };
42                let value = change.value();
43                let value = value.strip_suffix('\n').unwrap_or(value);
44                out.push(format!("{sign}{value}"));
45                if out.len() >= max_lines {
46                    out.push("... (diff truncated)".to_string());
47                    break 'outer;
48                }
49            }
50        }
51    }
52    out.join("\n")
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn identical_has_no_changes() {
61        let stats = diff_stats("a\nb\nc\n", "a\nb\nc\n");
62        assert_eq!(stats.changed_lines, 0);
63        assert_eq!(stats.ratio, 0.0);
64    }
65
66    #[test]
67    fn one_line_change_is_small() {
68        let stats = diff_stats("a\nb\nc\n", "a\nB\nc\n");
69        // one delete + one insert
70        assert_eq!(stats.changed_lines, 2);
71        assert!(stats.ratio < 0.8);
72    }
73
74    #[test]
75    fn unified_diff_shows_the_change() {
76        let d = unified_diff("a\nb\nc\n", "a\nB\nc\n", 1, 100);
77        assert!(d.contains("-b"));
78        assert!(d.contains("+B"));
79    }
80
81    #[test]
82    fn unified_diff_caps_lines() {
83        let prev: String = (0..100).map(|i| format!("line {i}\n")).collect();
84        let curr: String = (0..100).map(|i| format!("changed {i}\n")).collect();
85        let d = unified_diff(&prev, &curr, 3, 10);
86        assert!(d.lines().count() <= 11);
87        assert!(d.contains("truncated"));
88    }
89}