1use std::path::PathBuf;
2
3pub struct UnifiedDiff {
4 pub old_path: PathBuf,
5 pub new_path: PathBuf,
6 pub hunks: Vec<Hunk>,
7}
8
9pub struct Hunk {
10 pub old_start: u32,
11 pub old_count: u32,
12 pub new_start: u32,
13 pub new_count: u32,
14 pub lines: Vec<DiffLine>,
15}
16
17#[derive(Debug, PartialEq, Eq, Clone)]
18pub enum DiffLine {
19 Context(String),
20 Add(String),
21 Remove(String),
22}
23
24#[derive(Debug, PartialEq, Eq)]
25pub struct DiffStats {
26 pub additions: usize,
27 pub removals: usize,
28 pub files_changed: usize,
29}
30
31impl UnifiedDiff {
32 pub fn generate(old: &str, new: &str, old_path: &str, new_path: &str) -> Self {
33 let changes = similar::TextDiff::from_lines(old, new);
34 let mut hunks = Vec::new();
35
36 for hunk in changes.unified_diff().iter_hunks() {
37 let mut lines = Vec::new();
38 let mut old_start = None;
39 let mut old_count = 0u32;
40 let mut new_start = None;
41 let mut new_count = 0u32;
42
43 for change in hunk.iter_changes() {
44 if old_start.is_none() && change.old_index().is_some() {
45 old_start = Some(change.old_index().unwrap() as u32);
46 }
47 if new_start.is_none() && change.new_index().is_some() {
48 new_start = Some(change.new_index().unwrap() as u32);
49 }
50 match change.tag() {
51 similar::ChangeTag::Equal => {
52 lines.push(DiffLine::Context(change.to_string()));
53 old_count += 1;
54 new_count += 1;
55 }
56 similar::ChangeTag::Delete => {
57 lines.push(DiffLine::Remove(change.to_string()));
58 old_count += 1;
59 }
60 similar::ChangeTag::Insert => {
61 lines.push(DiffLine::Add(change.to_string()));
62 new_count += 1;
63 }
64 }
65 }
66
67 if !lines.is_empty() {
68 hunks.push(Hunk {
69 old_start: old_start.unwrap_or(0) + 1,
70 old_count,
71 new_start: new_start.unwrap_or(0) + 1,
72 new_count,
73 lines,
74 });
75 }
76 }
77
78 Self {
79 old_path: PathBuf::from(old_path),
80 new_path: PathBuf::from(new_path),
81 hunks,
82 }
83 }
84
85 pub fn render(&self) -> String {
86 let mut out = String::new();
87 out.push_str(&format!(
88 "--- {}\n+++ {}\n",
89 self.old_path.display(),
90 self.new_path.display()
91 ));
92
93 for hunk in &self.hunks {
94 out.push_str(&format!(
95 "@@ -{},{} +{},{} @@\n",
96 hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
97 ));
98 for line in &hunk.lines {
99 match line {
100 DiffLine::Context(s) => out.push_str(&format!(" {}", s)),
101 DiffLine::Add(s) => out.push_str(&format!("+{}", s)),
102 DiffLine::Remove(s) => out.push_str(&format!("-{}", s)),
103 }
104 }
105 }
106
107 out
108 }
109
110 pub fn apply(&self, original: &str) -> anyhow::Result<String> {
111 let mut old_lines: Vec<&str> = original.lines().collect();
112 let mut offset: i64 = 0;
113
114 for hunk in &self.hunks {
115 let start = (hunk.old_start as i64 - 1 + offset) as usize;
116 let mut new_lines = Vec::new();
117
118 for line in &hunk.lines {
119 match line {
120 DiffLine::Context(s) => {
121 new_lines.push(s.trim_end_matches('\n').trim_end_matches('\r'));
122 }
123 DiffLine::Remove(_) => {}
124 DiffLine::Add(s) => {
125 new_lines.push(s.trim_end_matches('\n').trim_end_matches('\r'));
126 }
127 }
128 }
129
130 let end = start + hunk.old_count as usize;
131 if end <= old_lines.len() {
132 let removed = end - start;
133 old_lines.splice(start..end, new_lines.iter().copied());
134 offset += new_lines.len() as i64 - removed as i64;
135 }
136 }
137
138 Ok(old_lines.join("\n"))
139 }
140
141 pub fn reverse(&self) -> Self {
142 let hunks = self
143 .hunks
144 .iter()
145 .map(|h| {
146 let lines = h
147 .lines
148 .iter()
149 .map(|l| match l {
150 DiffLine::Add(s) => DiffLine::Remove(s.clone()),
151 DiffLine::Remove(s) => DiffLine::Add(s.clone()),
152 DiffLine::Context(s) => DiffLine::Context(s.clone()),
153 })
154 .collect();
155 Hunk {
156 old_start: h.new_start,
157 old_count: h.new_count,
158 new_start: h.old_start,
159 new_count: h.old_count,
160 lines,
161 }
162 })
163 .collect();
164
165 Self {
166 old_path: self.new_path.clone(),
167 new_path: self.old_path.clone(),
168 hunks,
169 }
170 }
171
172 pub fn stats(&self) -> DiffStats {
173 let mut additions = 0;
174 let mut removals = 0;
175
176 for hunk in &self.hunks {
177 for line in &hunk.lines {
178 match line {
179 DiffLine::Add(_) => additions += 1,
180 DiffLine::Remove(_) => removals += 1,
181 DiffLine::Context(_) => {}
182 }
183 }
184 }
185
186 DiffStats {
187 additions,
188 removals,
189 files_changed: 1,
190 }
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn test_generate_no_changes() {
200 let diff = UnifiedDiff::generate("hello\n", "hello\n", "a.txt", "a.txt");
201 assert!(diff.hunks.is_empty());
202 }
203
204 #[test]
205 fn test_generate_add_line() {
206 let diff = UnifiedDiff::generate("line1\n", "line1\nline2\n", "a.txt", "a.txt");
207 assert!(!diff.hunks.is_empty());
208
209 let has_add = diff
210 .hunks
211 .iter()
212 .any(|h| h.lines.iter().any(|l| matches!(l, DiffLine::Add(_))));
213 assert!(has_add, "should have at least one Add line");
214 }
215
216 #[test]
217 fn test_generate_remove_line() {
218 let diff = UnifiedDiff::generate("line1\nline2\n", "line1\n", "a.txt", "a.txt");
219 assert!(!diff.hunks.is_empty());
220
221 let has_remove = diff
222 .hunks
223 .iter()
224 .any(|h| h.lines.iter().any(|l| matches!(l, DiffLine::Remove(_))));
225 assert!(has_remove, "should have at least one Remove line");
226 }
227
228 #[test]
229 fn test_generate_replace_line() {
230 let diff = UnifiedDiff::generate("old\n", "new\n", "a.txt", "a.txt");
231 assert!(!diff.hunks.is_empty());
232
233 let stats = diff.stats();
234 assert_eq!(stats.additions, 1);
235 assert_eq!(stats.removals, 1);
236 }
237
238 #[test]
239 fn test_stats_counts() {
240 let diff = UnifiedDiff::generate("a\nb\nc\n", "a\nx\nd\n", "old.txt", "new.txt");
241 let stats = diff.stats();
242 assert_eq!(stats.files_changed, 1);
243 assert!(stats.additions >= 2);
244 assert!(stats.removals >= 2);
245 }
246
247 #[test]
248 fn test_to_string_format() {
249 let diff = UnifiedDiff::generate("hello\n", "world\n", "a.txt", "b.txt");
250 let s = diff.render();
251 assert!(s.starts_with("--- a.txt\n+++ b.txt\n"));
252 assert!(s.contains("@@"));
253 }
254
255 #[test]
256 fn test_apply_roundtrip_add() {
257 let old = "line1\n";
258 let new = "line1\nline2\n";
259 let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
260 let applied = diff.apply(old).unwrap();
261 assert_eq!(applied, "line1\nline2");
262 }
263
264 #[test]
265 fn test_apply_roundtrip_remove() {
266 let old = "line1\nline2\n";
267 let new = "line1\n";
268 let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
269 let applied = diff.apply(old).unwrap();
270 assert_eq!(applied, "line1");
271 }
272
273 #[test]
274 fn test_apply_roundtrip_replace() {
275 let old = "a\nb\nc\n";
276 let new = "a\nx\nc\n";
277 let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
278 let applied = diff.apply(old).unwrap();
279 assert_eq!(applied, "a\nx\nc");
280 }
281
282 #[test]
283 fn test_reverse_then_apply() {
284 let old = "alpha\nbeta\n";
285 let new = "alpha\ngamma\n";
286 let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
287 let reversed = diff.reverse();
288 let applied = reversed.apply(new).unwrap();
289 assert_eq!(applied, "alpha\nbeta");
290 }
291
292 #[test]
293 fn test_generate_empty_to_content() {
294 let diff = UnifiedDiff::generate("", "new content\n", "a.txt", "a.txt");
295 assert!(!diff.hunks.is_empty());
296 let stats = diff.stats();
297 assert_eq!(stats.additions, 1);
298 assert_eq!(stats.removals, 0);
299 }
300
301 #[test]
302 fn test_generate_content_to_empty() {
303 let diff = UnifiedDiff::generate("old content\n", "", "a.txt", "a.txt");
304 assert!(!diff.hunks.is_empty());
305 let stats = diff.stats();
306 assert_eq!(stats.additions, 0);
307 assert_eq!(stats.removals, 1);
308 }
309
310 #[test]
311 fn test_apply_preserves_context() {
312 let old = "line1\nline2\nline3\nline4\n";
313 let new = "line1\nline2\nMODIFIED\nline4\n";
314 let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
315 let applied = diff.apply(old).unwrap();
316 assert_eq!(applied, "line1\nline2\nMODIFIED\nline4");
317 }
318
319 #[test]
320 fn test_multiline_hunk() {
321 let old = "a\nb\nc\nd\ne\n";
322 let new = "a\nB\nC\nd\ne\n";
323 let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
324 let stats = diff.stats();
325 assert!(stats.additions >= 2);
326 assert!(stats.removals >= 2);
327 }
328}