kaptein_viewmodel/
diff.rs1#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct DiffLine {
11 pub tag: char,
13 pub text: String,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct UnifiedDiff {
19 pub hunks: Vec<Vec<DiffLine>>,
21 pub added: usize,
23 pub removed: usize,
25}
26
27pub fn unified_diff(old: &str, new: &str, context: usize) -> UnifiedDiff {
31 let old_lines: Vec<&str> = old.lines().collect();
32 let new_lines: Vec<&str> = new.lines().collect();
33
34 let (n, m) = (old_lines.len(), new_lines.len());
36 let mut dp = vec![vec![0usize; m + 1]; n + 1];
37 for i in (0..n).rev() {
38 for j in (0..m).rev() {
39 dp[i][j] = if old_lines[i] == new_lines[j] {
40 dp[i + 1][j + 1] + 1
41 } else {
42 dp[i + 1][j].max(dp[i][j + 1])
43 };
44 }
45 }
46
47 #[derive(PartialEq)]
49 enum Op {
50 Keep,
51 Remove,
52 Add,
53 }
54 let mut ops: Vec<Op> = Vec::new();
55 let (mut i, mut j) = (0, 0);
56 while i < n && j < m {
57 if old_lines[i] == new_lines[j] {
58 ops.push(Op::Keep);
59 i += 1;
60 j += 1;
61 } else if dp[i + 1][j] >= dp[i][j + 1] {
62 ops.push(Op::Remove);
63 i += 1;
64 } else {
65 ops.push(Op::Add);
66 j += 1;
67 }
68 }
69 while i < n {
70 ops.push(Op::Remove);
71 i += 1;
72 }
73 while j < m {
74 ops.push(Op::Add);
75 j += 1;
76 }
77
78 let mut tagged: Vec<DiffLine> = Vec::new();
80 let (mut oi, mut ni) = (0usize, 0usize);
81 let (mut added, mut removed) = (0usize, 0usize);
82 for op in &ops {
83 match op {
84 Op::Keep => {
85 tagged.push(DiffLine {
86 tag: ' ',
87 text: old_lines[oi].to_string(),
88 });
89 oi += 1;
90 ni += 1;
91 }
92 Op::Remove => {
93 tagged.push(DiffLine {
94 tag: '-',
95 text: old_lines[oi].to_string(),
96 });
97 removed += 1;
98 oi += 1;
99 }
100 Op::Add => {
101 tagged.push(DiffLine {
102 tag: '+',
103 text: new_lines[ni].to_string(),
104 });
105 added += 1;
106 ni += 1;
107 }
108 }
109 }
110
111 let hunks = group_hunks(&tagged, context);
113
114 UnifiedDiff {
115 hunks,
116 added,
117 removed,
118 }
119}
120
121pub fn render_unified(diff: &UnifiedDiff, old_label: &str, new_label: &str) -> String {
123 let mut out = String::new();
124 out.push_str(&format!("--- {old_label}\n+++ {new_label}\n"));
125 for (i, hunk) in diff.hunks.iter().enumerate() {
126 out.push_str(&format!("@@ hunk {} @@\n", i + 1));
127 for line in hunk {
128 out.push(line.tag);
129 out.push(' ');
130 out.push_str(&line.text);
131 out.push('\n');
132 }
133 }
134 out
135}
136
137fn group_hunks(tagged: &[DiffLine], context: usize) -> Vec<Vec<DiffLine>> {
138 let is_changed = |l: &DiffLine| l.tag == '+' || l.tag == '-';
140
141 let mut hunks: Vec<Vec<DiffLine>> = Vec::new();
142 let mut i = 0usize;
143 let n = tagged.len();
144
145 while i < n {
146 if !is_changed(&tagged[i]) {
147 i += 1;
148 continue;
149 }
150 let start = i.saturating_sub(context);
152 let mut end = i;
154 while end < n && is_changed(&tagged[end]) {
155 end += 1;
156 }
157 let mut trailing = end;
159 let mut count = 0;
160 while trailing < n && count < context {
161 if is_changed(&tagged[trailing]) {
162 break;
163 }
164 trailing += 1;
165 count += 1;
166 }
167 let hunk: Vec<DiffLine> = tagged[start..trailing].to_vec();
168 if !hunk.is_empty() {
169 hunks.push(hunk);
170 }
171 i = trailing.max(end).max(i + 1);
172 }
173
174 hunks
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn identical_strings_produce_no_changes() {
183 let d = unified_diff("a\nb\nc", "a\nb\nc", 0);
184 assert_eq!(d.added, 0);
185 assert_eq!(d.removed, 0);
186 assert!(d.hunks.is_empty());
187 }
188
189 #[test]
190 fn single_line_change_is_detected() {
191 let d = unified_diff("a\nb\nc", "a\nx\nc", 0);
192 assert_eq!(d.added, 1);
193 assert_eq!(d.removed, 1);
194 let hunk = &d.hunks[0];
196 assert!(hunk.iter().any(|l| l.tag == '-' && l.text == "b"));
197 assert!(hunk.iter().any(|l| l.tag == '+' && l.text == "x"));
198 }
199
200 #[test]
201 fn context_lines_are_included() {
202 let d = unified_diff("a\nb\nc\nd\ne", "a\nb\nX\nd\ne", 1);
203 let hunk = &d.hunks[0];
205 assert!(hunk.iter().any(|l| l.tag == ' ' && l.text == "b"));
206 assert!(hunk.iter().any(|l| l.tag == ' ' && l.text == "d"));
207 assert!(hunk.iter().any(|l| l.tag == '+' && l.text == "X"));
208 }
209
210 #[test]
211 fn render_unified_has_header_and_hunks() {
212 let d = unified_diff("a\nb", "a\nc", 0);
213 let s = render_unified(&d, "old.yaml", "new.yaml");
214 assert!(s.contains("--- old.yaml"));
215 assert!(s.contains("+++ new.yaml"));
216 assert!(s.contains("- b"));
217 assert!(s.contains("+ c"));
218 }
219}