1use crate::error::Result;
2use crate::git::DiffStats;
3
4#[derive(Debug, Clone)]
6pub struct FileDiff {
7 pub filename: String,
9 pub content: String,
11 pub insertions: usize,
13 pub deletions: usize,
15}
16
17fn extract_filename_from_diff_header(line: &str) -> Option<String> {
18 const PREFIX: &str = "diff --git ";
19 if !line.starts_with(PREFIX) {
20 return None;
21 }
22
23 let rest = &line[PREFIX.len()..];
24
25 if let Some(b_pos) = rest.find(" b/") {
27 return rest[..b_pos]
28 .strip_prefix("a/")
29 .map(|filename| filename.to_string());
30 }
31
32 if let Some(stripped) = rest.strip_prefix('"')
34 && let Some(end) = stripped.find('"')
35 {
36 return stripped[..end]
37 .strip_prefix("a/")
38 .map(|filename| filename.to_string());
39 }
40
41 rest.split_whitespace()
43 .next()
44 .and_then(|s| s.strip_prefix("a/"))
45 .map(|s| s.to_string())
46}
47
48pub fn parse_diff_stats(diff: &str) -> Result<DiffStats> {
50 let mut files_changed = Vec::new();
51 let mut insertions = 0;
52 let mut deletions = 0;
53
54 for line in diff.lines() {
55 if line.starts_with("diff --git") {
56 if let Some(filename) = extract_filename_from_diff_header(line) {
57 files_changed.push(filename);
58 }
59 } else if line.starts_with('+') && !line.starts_with("+++") {
60 insertions += 1;
61 } else if line.starts_with('-') && !line.starts_with("---") {
62 deletions += 1;
63 }
64 }
65
66 Ok(DiffStats {
67 files_changed,
68 insertions,
69 deletions,
70 })
71}
72
73pub fn split_diff_by_file(diff: &str) -> Vec<FileDiff> {
78 if diff.is_empty() {
79 return Vec::new();
80 }
81
82 let mut files: Vec<FileDiff> = Vec::new();
83 let mut current_filename: Option<String> = None;
84 let mut current_lines: Vec<&str> = Vec::new();
85 let mut current_insertions = 0usize;
86 let mut current_deletions = 0usize;
87
88 for line in diff.lines() {
89 if line.starts_with("diff --git") {
90 if let Some(filename) = current_filename.take() {
92 let content = current_lines.join("\n");
93 files.push(FileDiff {
94 filename,
95 content,
96 insertions: current_insertions,
97 deletions: current_deletions,
98 });
99 current_lines.clear();
100 current_insertions = 0;
101 current_deletions = 0;
102 }
103 current_filename = extract_filename_from_diff_header(line);
104 current_lines.push(line);
105 } else {
106 if current_filename.is_some() {
107 if line.starts_with('+') && !line.starts_with("+++") {
108 current_insertions += 1;
109 } else if line.starts_with('-') && !line.starts_with("---") {
110 current_deletions += 1;
111 }
112 }
113 current_lines.push(line);
114 }
115 }
116
117 if let Some(filename) = current_filename {
119 let content = current_lines.join("\n");
120 files.push(FileDiff {
121 filename,
122 content,
123 insertions: current_insertions,
124 deletions: current_deletions,
125 });
126 }
127
128 files
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn test_parse_diff_stats() {
137 let diff = r#"diff --git a/src/main.rs b/src/main.rs
138index 1234567..abcdefg 100644
139--- a/src/main.rs
140+++ b/src/main.rs
141@@ -1,3 +1,5 @@
142 fn main() {
143+ println!("Hello");
144+ println!("World");
145- println!("Old");
146 }
147"#;
148
149 let stats = parse_diff_stats(diff).unwrap();
150 assert_eq!(stats.files_changed, vec!["src/main.rs"]);
151 assert_eq!(stats.insertions, 2);
152 assert_eq!(stats.deletions, 1);
153 }
154
155 #[test]
158 fn test_parse_diff_stats_empty_diff() {
159 let diff = "";
160 let stats = parse_diff_stats(diff).unwrap();
161 assert!(stats.files_changed.is_empty());
162 assert_eq!(stats.insertions, 0);
163 assert_eq!(stats.deletions, 0);
164 }
165
166 #[test]
167 fn test_parse_diff_stats_multiple_files() {
168 let diff = r#"diff --git a/src/main.rs b/src/main.rs
169--- a/src/main.rs
170+++ b/src/main.rs
171+line1
172diff --git a/src/lib.rs b/src/lib.rs
173--- a/src/lib.rs
174+++ b/src/lib.rs
175+line2
176-old_line
177diff --git a/Cargo.toml b/Cargo.toml
178--- a/Cargo.toml
179+++ b/Cargo.toml
180-removed
181"#;
182 let stats = parse_diff_stats(diff).unwrap();
183 assert_eq!(stats.files_changed.len(), 3);
184 assert!(stats.files_changed.contains(&"src/main.rs".to_string()));
185 assert!(stats.files_changed.contains(&"src/lib.rs".to_string()));
186 assert!(stats.files_changed.contains(&"Cargo.toml".to_string()));
187 assert_eq!(stats.insertions, 2);
188 assert_eq!(stats.deletions, 2);
189 }
190
191 #[test]
192 fn test_parse_diff_stats_only_insertions() {
193 let diff = r#"diff --git a/new_file.rs b/new_file.rs
194--- /dev/null
195+++ b/new_file.rs
196+fn new_function() {
197+ println!("Hello");
198+}
199"#;
200 let stats = parse_diff_stats(diff).unwrap();
201 assert_eq!(stats.insertions, 3);
202 assert_eq!(stats.deletions, 0);
203 }
204
205 #[test]
206 fn test_parse_diff_stats_only_deletions() {
207 let diff = r#"diff --git a/old_file.rs b/old_file.rs
208--- a/old_file.rs
209+++ /dev/null
210-fn deleted() {
211- // gone
212-}
213"#;
214 let stats = parse_diff_stats(diff).unwrap();
215 assert_eq!(stats.insertions, 0);
216 assert_eq!(stats.deletions, 3);
217 }
218
219 #[test]
220 fn test_parse_diff_stats_file_with_spaces() {
221 let diff = r#"diff --git a/path with spaces/file name.rs b/path with spaces/file name.rs
222--- a/path with spaces/file name.rs
223+++ b/path with spaces/file name.rs
224+new content
225"#;
226 let stats = parse_diff_stats(diff).unwrap();
227 assert_eq!(stats.files_changed.len(), 1);
228 assert_eq!(stats.files_changed[0], "path with spaces/file name.rs");
229 assert_eq!(stats.insertions, 1);
230 }
231
232 #[test]
233 fn test_parse_diff_stats_chinese_filename() {
234 let diff = r#"diff --git a/src/中文文件.rs b/src/中文文件.rs
235--- a/src/中文文件.rs
236+++ b/src/中文文件.rs
237+println!("你好");
238"#;
239 let stats = parse_diff_stats(diff).unwrap();
240 assert_eq!(stats.files_changed, vec!["src/中文文件.rs".to_string()]);
241 assert_eq!(stats.insertions, 1);
242 }
243
244 #[test]
245 fn test_parse_diff_stats_binary_file() {
246 let diff = r#"diff --git a/image.png b/image.png
248Binary files a/image.png and b/image.png differ
249"#;
250 let stats = parse_diff_stats(diff).unwrap();
251 assert_eq!(stats.files_changed, vec!["image.png".to_string()]);
252 assert_eq!(stats.insertions, 0);
254 assert_eq!(stats.deletions, 0);
255 }
256
257 #[test]
260 fn test_split_diff_by_file_empty() {
261 let files = split_diff_by_file("");
262 assert!(files.is_empty());
263 }
264
265 #[test]
266 fn test_split_diff_by_file_single() {
267 let diff = "diff --git a/src/main.rs b/src/main.rs\n\
268 index 1234567..abcdefg 100644\n\
269 --- a/src/main.rs\n\
270 +++ b/src/main.rs\n\
271 @@ -1,3 +1,5 @@\n\
272 +line1\n\
273 +line2\n\
274 -old_line";
275 let files = split_diff_by_file(diff);
276 assert_eq!(files.len(), 1);
277 assert_eq!(files[0].filename, "src/main.rs");
278 assert_eq!(files[0].insertions, 2);
279 assert_eq!(files[0].deletions, 1);
280 assert!(files[0].content.starts_with("diff --git"));
281 }
282
283 #[test]
284 fn test_split_diff_by_file_multiple() {
285 let diff = "diff --git a/src/main.rs b/src/main.rs\n\
286 --- a/src/main.rs\n\
287 +++ b/src/main.rs\n\
288 +line1\n\
289 diff --git a/src/lib.rs b/src/lib.rs\n\
290 --- a/src/lib.rs\n\
291 +++ b/src/lib.rs\n\
292 +line2\n\
293 -old_line\n\
294 diff --git a/Cargo.toml b/Cargo.toml\n\
295 --- a/Cargo.toml\n\
296 +++ b/Cargo.toml\n\
297 -removed";
298 let files = split_diff_by_file(diff);
299 assert_eq!(files.len(), 3);
300 assert_eq!(files[0].filename, "src/main.rs");
301 assert_eq!(files[0].insertions, 1);
302 assert_eq!(files[0].deletions, 0);
303 assert_eq!(files[1].filename, "src/lib.rs");
304 assert_eq!(files[1].insertions, 1);
305 assert_eq!(files[1].deletions, 1);
306 assert_eq!(files[2].filename, "Cargo.toml");
307 assert_eq!(files[2].insertions, 0);
308 assert_eq!(files[2].deletions, 1);
309 }
310
311 #[test]
312 fn test_split_diff_by_file_binary() {
313 let diff = "diff --git a/image.png b/image.png\n\
314 Binary files a/image.png and b/image.png differ";
315 let files = split_diff_by_file(diff);
316 assert_eq!(files.len(), 1);
317 assert_eq!(files[0].filename, "image.png");
318 assert_eq!(files[0].insertions, 0);
319 assert_eq!(files[0].deletions, 0);
320 }
321}