Skip to main content

gcop_rs/git/
diff.rs

1use crate::error::Result;
2use crate::git::DiffStats;
3
4/// diff information for a single file
5#[derive(Debug, Clone)]
6pub struct FileDiff {
7    /// Filename (relative to repository root)
8    pub filename: String,
9    /// A complete diff patch of this file (from "diff --git" to the next file boundary)
10    pub content: String,
11    /// Number of new rows
12    pub insertions: usize,
13    /// Number of rows to delete
14    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    // Position the boundaries of a/ and b/ via the " b/" delimiter to avoid whitespace paths being truncated.
26    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    // Handle quoted paths: diff --git "a/path with spaces.rs" "b/path with spaces.rs"
33    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    // Fallback: Maintain compatibility
42    rest.split_whitespace()
43        .next()
44        .and_then(|s| s.strip_prefix("a/"))
45        .map(|s| s.to_string())
46}
47
48/// Extract statistics from diff text
49pub 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
73/// Split raw diff text into `Vec<FileDiff>` on file boundaries
74///
75/// Each `FileDiff` contains a complete diff patch of a file and its statistics.
76/// Keep the original file order.
77pub 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            // New file boundary encountered, save previous file
91            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    // save last file
118    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    // === Added edge use cases ===
156
157    #[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        // Binary file diff format
247        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        // Binaries don't have +/- lines
253        assert_eq!(stats.insertions, 0);
254        assert_eq!(stats.deletions, 0);
255    }
256
257    // === split_diff_by_file test ===
258
259    #[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}