1use crate::error::FileError;
4use crate::models::{DiffHunk, DiffLine, DiffStats, FileDiff};
5use similar::{ChangeTag, TextDiff};
6use std::path::PathBuf;
7
8#[derive(Debug, Clone)]
10pub struct DiffEngine;
11
12impl DiffEngine {
13 pub fn new() -> Self {
15 Self
16 }
17
18 pub fn generate_unified_diff(
28 &self,
29 old: &str,
30 new: &str,
31 path: PathBuf,
32 ) -> Result<FileDiff, FileError> {
33 let hunks = self.extract_hunks(old, new)?;
34 let stats = self.compute_stats_from_hunks(&hunks);
35
36 Ok(FileDiff { path, hunks, stats })
37 }
38
39 pub fn generate_side_by_side_diff(
49 &self,
50 old: &str,
51 new: &str,
52 path: PathBuf,
53 ) -> Result<FileDiff, FileError> {
54 self.generate_unified_diff(old, new, path)
57 }
58
59 pub fn apply_hunk(&self, source: &str, hunk: &DiffHunk) -> Result<String, FileError> {
68 let lines: Vec<&str> = source.lines().collect();
69 let mut result = Vec::new();
70
71 if hunk.old_start > 0 {
73 result.extend_from_slice(&lines[0..hunk.old_start - 1]);
74 }
75
76 for line in &hunk.lines {
78 match line {
79 DiffLine::Context(content) => {
80 result.push(content.as_str());
81 }
82 DiffLine::Added(content) => {
83 result.push(content.as_str());
84 }
85 DiffLine::Removed(_) => {
86 }
88 }
89 }
90
91 let end_line = hunk.old_start + hunk.old_count - 1;
93 if end_line < lines.len() {
94 result.extend_from_slice(&lines[end_line..]);
95 }
96
97 Ok(result.join("\n"))
98 }
99
100 fn extract_hunks(&self, old: &str, new: &str) -> Result<Vec<DiffHunk>, FileError> {
102 let text_diff = TextDiff::from_lines(old, new);
103
104 let mut hunks = Vec::new();
105 let mut current_hunk: Option<DiffHunk> = None;
106 let mut old_line_num = 1;
107 let mut new_line_num = 1;
108
109 for change in text_diff.iter_all_changes() {
110 let line_content = change.value();
111
112 match change.tag() {
113 ChangeTag::Delete => {
114 if current_hunk.is_none() {
115 current_hunk = Some(DiffHunk {
116 old_start: old_line_num,
117 old_count: 0,
118 new_start: new_line_num,
119 new_count: 0,
120 lines: Vec::new(),
121 });
122 }
123
124 if let Some(ref mut hunk) = current_hunk {
125 hunk.old_count += 1;
126 hunk.lines
127 .push(DiffLine::Removed(line_content.trim_end().to_string()));
128 }
129
130 old_line_num += 1;
131 }
132 ChangeTag::Insert => {
133 if current_hunk.is_none() {
134 current_hunk = Some(DiffHunk {
135 old_start: old_line_num,
136 old_count: 0,
137 new_start: new_line_num,
138 new_count: 0,
139 lines: Vec::new(),
140 });
141 }
142
143 if let Some(ref mut hunk) = current_hunk {
144 hunk.new_count += 1;
145 hunk.lines
146 .push(DiffLine::Added(line_content.trim_end().to_string()));
147 }
148
149 new_line_num += 1;
150 }
151 ChangeTag::Equal => {
152 if let Some(ref mut hunk) = current_hunk {
153 hunk.old_count += 1;
155 hunk.new_count += 1;
156 hunk.lines
157 .push(DiffLine::Context(line_content.trim_end().to_string()));
158 }
159
160 old_line_num += 1;
161 new_line_num += 1;
162 }
163 }
164 }
165
166 if let Some(hunk) = current_hunk {
167 if !hunk.lines.is_empty() {
168 hunks.push(hunk);
169 }
170 }
171
172 Ok(hunks)
173 }
174
175 pub fn compute_stats(&self, diff: &FileDiff) -> DiffStats {
183 let mut additions = 0;
184 let mut deletions = 0;
185
186 for hunk in &diff.hunks {
187 for line in &hunk.lines {
188 match line {
189 DiffLine::Added(_) => additions += 1,
190 DiffLine::Removed(_) => deletions += 1,
191 DiffLine::Context(_) => {}
192 }
193 }
194 }
195
196 DiffStats {
197 additions,
198 deletions,
199 files_changed: if diff.hunks.is_empty() { 0 } else { 1 },
200 }
201 }
202
203 fn compute_stats_from_hunks(&self, hunks: &[DiffHunk]) -> DiffStats {
205 let mut additions = 0;
206 let mut deletions = 0;
207
208 for hunk in hunks {
209 for line in &hunk.lines {
210 match line {
211 DiffLine::Added(_) => additions += 1,
212 DiffLine::Removed(_) => deletions += 1,
213 DiffLine::Context(_) => {}
214 }
215 }
216 }
217
218 DiffStats {
219 additions,
220 deletions,
221 files_changed: if hunks.is_empty() { 0 } else { 1 },
222 }
223 }
224}
225
226impl Default for DiffEngine {
227 fn default() -> Self {
228 Self::new()
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_generate_unified_diff_simple() {
238 let engine = DiffEngine::new();
239 let old = "line 1\nline 2\nline 3\n";
240 let new = "line 1\nline 2 modified\nline 3\n";
241
242 let diff = engine
243 .generate_unified_diff(old, new, PathBuf::from("test.txt"))
244 .unwrap();
245
246 assert!(!diff.hunks.is_empty());
247 assert_eq!(diff.stats.additions, 1);
248 assert_eq!(diff.stats.deletions, 1);
249 }
250
251 #[test]
252 fn test_generate_unified_diff_no_changes() {
253 let engine = DiffEngine::new();
254 let content = "line 1\nline 2\nline 3\n";
255
256 let diff = engine
257 .generate_unified_diff(content, content, PathBuf::from("test.txt"))
258 .unwrap();
259
260 assert_eq!(diff.stats.additions, 0);
261 assert_eq!(diff.stats.deletions, 0);
262 }
263
264 #[test]
265 fn test_apply_hunk_simple() {
266 let engine = DiffEngine::new();
267 let hunk = DiffHunk {
268 old_start: 2,
269 old_count: 1,
270 new_start: 2,
271 new_count: 1,
272 lines: vec![DiffLine::Added("new line".to_string())],
273 };
274
275 let source = "line 1\nline 2\nline 3\n";
276 let result = engine.apply_hunk(source, &hunk).unwrap();
277
278 assert!(result.contains("new line"));
279 }
280
281 #[test]
282 fn test_compute_stats() {
283 let engine = DiffEngine::new();
284 let diff = FileDiff {
285 path: PathBuf::from("test.txt"),
286 hunks: vec![DiffHunk {
287 old_start: 1,
288 old_count: 2,
289 new_start: 1,
290 new_count: 3,
291 lines: vec![
292 DiffLine::Removed("old".to_string()),
293 DiffLine::Added("new1".to_string()),
294 DiffLine::Added("new2".to_string()),
295 ],
296 }],
297 stats: DiffStats {
298 additions: 0,
299 deletions: 0,
300 files_changed: 0,
301 },
302 };
303
304 let stats = engine.compute_stats(&diff);
305 assert_eq!(stats.additions, 2);
306 assert_eq!(stats.deletions, 1);
307 assert_eq!(stats.files_changed, 1);
308 }
309}