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}