Skip to main content

harn_vm/
text_diff.rs

1//! The workspace's single line-diff owner.
2//!
3//! Every unified-diff renderer in the codebase — orchestration run-record
4//! diffs, the `ast.dry_run` preview, and the `harn package publish` index
5//! preview — routes through [`render_line_diff`] so the algorithm choice and
6//! context radius live in exactly one place. Callers supply their own file
7//! header (`--- a/… / +++ b/…`, `/dev/null`, etc.); this module owns the
8//! `@@` hunk body and the `+`/`-` line counts.
9
10use similar::{Algorithm, ChangeTag, TextDiff};
11
12/// Line-diff algorithm shared by every renderer.
13///
14/// Histogram is anchor-based: it produces more stable, human-readable hunks
15/// on real source than plain Myers, at equal correctness and generally lower
16/// cost. Every consumer here renders diffs for people to read (run
17/// comparisons, edit previews, index-change previews), so hunk quality is
18/// worth more than matching Myers' particular edit script.
19const ALGORITHM: Algorithm = Algorithm::Histogram;
20
21/// Unchanged lines kept on each side of a hunk. Matches `git diff`'s default.
22const CONTEXT: usize = 3;
23
24/// A rendered line diff: the unified-diff hunk body plus raw `+`/`-` counts.
25///
26/// `body` carries `@@` hunk headers, up to [`CONTEXT`] context lines per side,
27/// and `\ No newline at end of file` markers, but no file header — the caller
28/// prepends that. It is empty exactly when the inputs are identical. A line
29/// modified in place counts toward both `lines_added` and `lines_removed`.
30pub struct LineDiff {
31    pub body: String,
32    pub lines_added: usize,
33    pub lines_removed: usize,
34}
35
36/// Diff `before` against `after` line by line.
37///
38/// `from`/`to` are compared with trailing terminators intact, so a file that
39/// only gains or loses its final newline still diffs as a change (and earns
40/// the `\ No newline at end of file` marker) rather than collapsing to a no-op.
41pub fn render_line_diff(before: &str, after: &str) -> LineDiff {
42    let diff = TextDiff::configure()
43        .algorithm(ALGORITHM)
44        .diff_lines(before, after);
45    let body = diff.unified_diff().context_radius(CONTEXT).to_string();
46
47    let mut lines_added = 0;
48    let mut lines_removed = 0;
49    for change in diff.iter_all_changes() {
50        match change.tag() {
51            ChangeTag::Insert => lines_added += 1,
52            ChangeTag::Delete => lines_removed += 1,
53            ChangeTag::Equal => {}
54        }
55    }
56
57    LineDiff {
58        body,
59        lines_added,
60        lines_removed,
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn identical_inputs_yield_empty_body() {
70        let diff = render_line_diff("a\nb\nc\n", "a\nb\nc\n");
71        assert_eq!(diff.body, "");
72        assert_eq!(diff.lines_added, 0);
73        assert_eq!(diff.lines_removed, 0);
74    }
75
76    #[test]
77    fn single_change_emits_bounded_hunk() {
78        let diff = render_line_diff("a\nb\nc\n", "a\nB\nc\n");
79        assert!(diff.body.starts_with("@@ -"));
80        assert!(diff.body.contains("-b\n"));
81        assert!(diff.body.contains("+B\n"));
82        assert_eq!(diff.lines_added, 1);
83        assert_eq!(diff.lines_removed, 1);
84    }
85
86    #[test]
87    fn context_stays_bounded_on_large_inputs() {
88        let before: String = (0..1000).map(|i| format!("line {i}\n")).collect();
89        let mut after_lines: Vec<String> = (0..1000).map(|i| format!("line {i}")).collect();
90        after_lines[500] = "CHANGED".to_string();
91        let after = after_lines
92            .iter()
93            .map(|l| format!("{l}\n"))
94            .collect::<String>();
95        let diff = render_line_diff(&before, &after);
96        // One hunk with 3 lines of context each side — not all 1000 lines.
97        assert_eq!(diff.body.matches("@@ -").count(), 1);
98        assert!(diff.body.lines().count() < 12);
99        assert!(!diff.body.contains("line 100\n"));
100    }
101
102    #[test]
103    fn trailing_newline_change_is_not_collapsed() {
104        let diff = render_line_diff("a\nb", "a\nb\n");
105        assert!(diff.body.contains("\\ No newline at end of file"));
106    }
107}