Skip to main content

rust_diff_analyzer/git/
diff_parser.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use std::path::{Path, PathBuf};
5
6use masterror::AppError;
7
8use super::hunk::{Hunk, HunkLine};
9use crate::error::DiffParseError;
10
11/// A file diff containing all hunks for a single file
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct FileDiff {
14    /// Path to the file (new path if renamed)
15    pub path: PathBuf,
16    /// Original path (if renamed)
17    pub old_path: Option<PathBuf>,
18    /// Whether the file was deleted in this diff
19    pub is_deleted: bool,
20    /// Hunks in this file diff
21    pub hunks: Vec<Hunk>,
22}
23
24impl FileDiff {
25    /// Creates a new file diff
26    ///
27    /// # Arguments
28    ///
29    /// * `path` - Path to the file
30    ///
31    /// # Returns
32    ///
33    /// A new FileDiff with empty hunks
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use std::path::PathBuf;
39    ///
40    /// use rust_diff_analyzer::git::FileDiff;
41    ///
42    /// let diff = FileDiff::new(PathBuf::from("src/lib.rs"));
43    /// assert!(diff.hunks.is_empty());
44    /// ```
45    pub fn new(path: PathBuf) -> Self {
46        Self {
47            path,
48            old_path: None,
49            is_deleted: false,
50            hunks: Vec::new(),
51        }
52    }
53
54    /// Returns total number of added lines
55    ///
56    /// # Returns
57    ///
58    /// Sum of added lines across all hunks
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use std::path::PathBuf;
64    ///
65    /// use rust_diff_analyzer::git::FileDiff;
66    ///
67    /// let diff = FileDiff::new(PathBuf::from("src/lib.rs"));
68    /// assert_eq!(diff.total_added(), 0);
69    /// ```
70    pub fn total_added(&self) -> usize {
71        self.hunks.iter().map(|h| h.added_count()).sum()
72    }
73
74    /// Returns total number of removed lines
75    ///
76    /// # Returns
77    ///
78    /// Sum of removed lines across all hunks
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use std::path::PathBuf;
84    ///
85    /// use rust_diff_analyzer::git::FileDiff;
86    ///
87    /// let diff = FileDiff::new(PathBuf::from("src/lib.rs"));
88    /// assert_eq!(diff.total_removed(), 0);
89    /// ```
90    pub fn total_removed(&self) -> usize {
91        self.hunks.iter().map(|h| h.removed_count()).sum()
92    }
93
94    /// Returns all added line numbers
95    ///
96    /// # Returns
97    ///
98    /// Vector of all added line numbers
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use std::path::PathBuf;
104    ///
105    /// use rust_diff_analyzer::git::FileDiff;
106    ///
107    /// let diff = FileDiff::new(PathBuf::from("src/lib.rs"));
108    /// assert!(diff.all_added_lines().is_empty());
109    /// ```
110    pub fn all_added_lines(&self) -> Vec<usize> {
111        self.hunks.iter().flat_map(|h| h.added_lines()).collect()
112    }
113
114    /// Returns all removed line numbers
115    ///
116    /// # Returns
117    ///
118    /// Vector of all removed line numbers
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use std::path::PathBuf;
124    ///
125    /// use rust_diff_analyzer::git::FileDiff;
126    ///
127    /// let diff = FileDiff::new(PathBuf::from("src/lib.rs"));
128    /// assert!(diff.all_removed_lines().is_empty());
129    /// ```
130    pub fn all_removed_lines(&self) -> Vec<usize> {
131        self.hunks.iter().flat_map(|h| h.removed_lines()).collect()
132    }
133
134    /// Returns new-file line positions of all removals across hunks
135    ///
136    /// See [`Hunk::removed_positions_in_new`] for the attribution rule.
137    ///
138    /// # Returns
139    ///
140    /// Vector of new-file line numbers, one per removed line
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use std::path::PathBuf;
146    ///
147    /// use rust_diff_analyzer::git::FileDiff;
148    ///
149    /// let diff = FileDiff::new(PathBuf::from("src/lib.rs"));
150    /// assert!(diff.all_removed_positions_in_new().is_empty());
151    /// ```
152    pub fn all_removed_positions_in_new(&self) -> Vec<usize> {
153        self.hunks
154            .iter()
155            .flat_map(|h| h.removed_positions_in_new())
156            .collect()
157    }
158
159    /// Checks if file path ends with .rs extension
160    ///
161    /// # Returns
162    ///
163    /// `true` if file is a Rust source file
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use std::path::PathBuf;
169    ///
170    /// use rust_diff_analyzer::git::FileDiff;
171    ///
172    /// let diff = FileDiff::new(PathBuf::from("src/lib.rs"));
173    /// assert!(diff.is_rust_file());
174    ///
175    /// let diff = FileDiff::new(PathBuf::from("README.md"));
176    /// assert!(!diff.is_rust_file());
177    /// ```
178    pub fn is_rust_file(&self) -> bool {
179        self.path
180            .extension()
181            .map(|ext| ext == "rs")
182            .unwrap_or(false)
183    }
184}
185
186/// Parses unified diff format into structured file diffs
187///
188/// # Arguments
189///
190/// * `input` - Unified diff content as string
191///
192/// # Returns
193///
194/// Vector of file diffs or parse error
195///
196/// # Errors
197///
198/// Returns error if diff format is invalid
199///
200/// # Examples
201///
202/// ```
203/// use rust_diff_analyzer::git::parse_diff;
204///
205/// let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
206/// index 1234567..abcdefg 100644
207/// --- a/src/lib.rs
208/// +++ b/src/lib.rs
209/// @@ -1,3 +1,4 @@
210///  fn main() {
211/// +    println!("Hello");
212///  }
213/// "#;
214///
215/// let files = parse_diff(diff).unwrap();
216/// assert_eq!(files.len(), 1);
217/// ```
218pub fn parse_diff(input: &str) -> Result<Vec<FileDiff>, AppError> {
219    let mut files = Vec::new();
220    let mut current_file: Option<FileDiff> = None;
221    let mut current_hunk: Option<Hunk> = None;
222    let mut old_line = 0;
223    let mut new_line = 0;
224
225    for line in input.lines() {
226        if line.starts_with("diff --git") {
227            if let Some(mut file) = current_file.take() {
228                if let Some(hunk) = current_hunk.take() {
229                    file.hunks.push(hunk);
230                }
231                files.push(file);
232            }
233
234            let path = parse_diff_header(line)?;
235            current_file = Some(FileDiff::new(path));
236            current_hunk = None;
237        } else if line.starts_with("@@") {
238            if let Some(ref mut file) = current_file {
239                if let Some(hunk) = current_hunk.take() {
240                    file.hunks.push(hunk);
241                }
242
243                let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?;
244                current_hunk = Some(Hunk::new(old_start, old_count, new_start, new_count));
245                old_line = old_start;
246                new_line = new_start;
247            }
248        } else if let Some(ref mut hunk) = current_hunk {
249            let mut chars = line.chars();
250            match chars.next() {
251                Some('+') => {
252                    hunk.lines
253                        .push(HunkLine::added(new_line, chars.as_str().to_string()));
254                    new_line += 1;
255                }
256                Some('-') => {
257                    hunk.lines
258                        .push(HunkLine::removed(old_line, chars.as_str().to_string()));
259                    old_line += 1;
260                }
261                Some(' ') | None => {
262                    hunk.lines.push(HunkLine::context(
263                        old_line,
264                        new_line,
265                        chars.as_str().to_string(),
266                    ));
267                    old_line += 1;
268                    new_line += 1;
269                }
270                Some(_) => {}
271            }
272        } else if let Some(ref mut file) = current_file {
273            apply_file_metadata(line, file);
274        }
275    }
276
277    if let Some(mut file) = current_file {
278        if let Some(hunk) = current_hunk {
279            file.hunks.push(hunk);
280        }
281        files.push(file);
282    }
283
284    Ok(files)
285}
286
287/// Applies file-level metadata lines (`---`, `+++`, `rename`, `deleted file
288/// mode`) that appear between a `diff --git` header and the first hunk.
289fn apply_file_metadata(line: &str, file: &mut FileDiff) {
290    if let Some(rest) = line.strip_prefix("+++ ") {
291        let target = extract_path_token(rest);
292        if target == "/dev/null" {
293            file.is_deleted = true;
294        } else {
295            let path = target.strip_prefix("b/").unwrap_or(&target);
296            file.path = PathBuf::from(path);
297            if file.old_path.as_deref() == Some(file.path.as_path()) {
298                file.old_path = None;
299            }
300        }
301    } else if let Some(rest) = line.strip_prefix("--- ") {
302        let target = extract_path_token(rest);
303        if target != "/dev/null" {
304            let path = target.strip_prefix("a/").unwrap_or(&target);
305            if Path::new(path) != file.path {
306                file.old_path = Some(PathBuf::from(path));
307            }
308        }
309    } else if let Some(rest) = line.strip_prefix("rename from ") {
310        file.old_path = Some(PathBuf::from(unquote_git_path(rest.trim_end())));
311    } else if let Some(rest) = line.strip_prefix("rename to ") {
312        file.path = PathBuf::from(unquote_git_path(rest.trim_end()));
313    } else if line.starts_with("deleted file mode") {
314        file.is_deleted = true;
315    }
316}
317
318/// Extracts a path from the remainder of a `---`/`+++` line, unquoting
319/// git-quoted paths and dropping a trailing tab-separated timestamp.
320fn extract_path_token(rest: &str) -> String {
321    if rest.starts_with('"') {
322        unquote_git_path(rest.trim_end())
323    } else {
324        rest.split('\t')
325            .next()
326            .unwrap_or(rest)
327            .trim_end()
328            .to_string()
329    }
330}
331
332/// Decodes a git-quoted path (`"a/\321\204.rs"`) into its unquoted form.
333///
334/// Handles the C-style escapes git emits with `core.quotepath=true`:
335/// `\\`, `\"`, `\t`, `\n`, `\r` and octal byte escapes (`\321`). Input
336/// without surrounding double quotes is returned unchanged.
337fn unquote_git_path(raw: &str) -> String {
338    let inner = match raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
339        Some(inner) => inner,
340        None => return raw.to_string(),
341    };
342
343    let mut bytes = Vec::with_capacity(inner.len());
344    let mut iter = inner.bytes().peekable();
345    while let Some(byte) = iter.next() {
346        if byte != b'\\' {
347            bytes.push(byte);
348            continue;
349        }
350        match iter.next() {
351            Some(b'n') => bytes.push(b'\n'),
352            Some(b't') => bytes.push(b'\t'),
353            Some(b'r') => bytes.push(b'\r'),
354            Some(digit @ b'0'..=b'7') => {
355                let mut value = u32::from(digit - b'0');
356                for _ in 0..2 {
357                    match iter.peek().copied() {
358                        Some(next @ b'0'..=b'7') => {
359                            value = value * 8 + u32::from(next - b'0');
360                            iter.next();
361                        }
362                        _ => break,
363                    }
364                }
365                bytes.push(value as u8);
366            }
367            Some(other) => bytes.push(other),
368            None => {}
369        }
370    }
371
372    String::from_utf8_lossy(&bytes).into_owned()
373}
374
375fn parse_diff_header(line: &str) -> Result<PathBuf, AppError> {
376    let invalid_header = || {
377        AppError::from(DiffParseError {
378            message: format!("invalid diff header: {}", line),
379        })
380    };
381
382    let rest = line
383        .strip_prefix("diff --git ")
384        .ok_or_else(invalid_header)?;
385    let b_part = if rest.ends_with('"') {
386        rest.rfind(" \"").map(|pos| &rest[pos + 1..])
387    } else {
388        rest.rfind(" b/").map(|pos| &rest[pos + 1..])
389    };
390    let b_part = b_part
391        .or_else(|| rest.split_whitespace().nth(1))
392        .ok_or_else(invalid_header)?;
393
394    let unquoted = unquote_git_path(b_part);
395    let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
396    Ok(PathBuf::from(path))
397}
398
399fn parse_hunk_header(line: &str) -> Result<(usize, usize, usize, usize), AppError> {
400    let invalid_header = || {
401        AppError::from(DiffParseError {
402            message: format!("invalid hunk header: {}", line),
403        })
404    };
405
406    let ranges = line
407        .strip_prefix("@@")
408        .ok_or_else(invalid_header)?
409        .split("@@")
410        .next()
411        .unwrap_or("")
412        .trim();
413
414    let mut parts = ranges.split_whitespace();
415    let old_part = parts.next().ok_or_else(invalid_header)?;
416    let new_part = parts.next().ok_or_else(invalid_header)?;
417
418    let old_range = old_part.strip_prefix('-').ok_or_else(|| {
419        AppError::from(DiffParseError {
420            message: format!("invalid old range: {}", old_part),
421        })
422    })?;
423
424    let new_range = new_part.strip_prefix('+').ok_or_else(|| {
425        AppError::from(DiffParseError {
426            message: format!("invalid new range: {}", new_part),
427        })
428    })?;
429
430    let (old_start, old_count) = parse_range(old_range)?;
431    let (new_start, new_count) = parse_range(new_range)?;
432
433    Ok((old_start, old_count, new_start, new_count))
434}
435
436fn parse_range(range: &str) -> Result<(usize, usize), AppError> {
437    let (start_part, count_part) = match range.split_once(',') {
438        Some((start, count)) => (start, Some(count)),
439        None => (range, None),
440    };
441
442    let start = start_part.parse::<usize>().map_err(|_| {
443        AppError::from(DiffParseError {
444            message: format!("invalid line number: {}", start_part),
445        })
446    })?;
447
448    let count = match count_part {
449        Some(count) => count.parse::<usize>().map_err(|_| {
450            AppError::from(DiffParseError {
451                message: format!("invalid line count: {}", count),
452            })
453        })?,
454        None => 1,
455    };
456
457    Ok((start, count))
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn test_parse_simple_diff() {
466        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
467index 1234567..abcdefg 100644
468--- a/src/lib.rs
469+++ b/src/lib.rs
470@@ -1,3 +1,4 @@
471 fn main() {
472+    println!("Hello");
473 }
474"#;
475
476        let files = parse_diff(diff).expect("parse should succeed");
477        assert_eq!(files.len(), 1);
478        assert_eq!(files[0].path, PathBuf::from("src/lib.rs"));
479        assert_eq!(files[0].hunks.len(), 1);
480        assert_eq!(files[0].total_added(), 1);
481    }
482
483    #[test]
484    fn test_parse_multiple_hunks() {
485        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
486--- a/src/lib.rs
487+++ b/src/lib.rs
488@@ -1,3 +1,4 @@
489 fn main() {
490+    println!("Hello");
491 }
492@@ -10,2 +11,3 @@
493 fn test() {
494+    assert!(true);
495 }
496"#;
497
498        let files = parse_diff(diff).expect("parse should succeed");
499        assert_eq!(files[0].hunks.len(), 2);
500        assert_eq!(files[0].total_added(), 2);
501    }
502
503    #[test]
504    fn test_parse_multiple_files() {
505        let diff = r#"diff --git a/src/a.rs b/src/a.rs
506--- a/src/a.rs
507+++ b/src/a.rs
508@@ -1,1 +1,2 @@
509 fn a() {}
510+fn a2() {}
511diff --git a/src/b.rs b/src/b.rs
512--- a/src/b.rs
513+++ b/src/b.rs
514@@ -1,1 +1,2 @@
515 fn b() {}
516+fn b2() {}
517"#;
518
519        let files = parse_diff(diff).expect("parse should succeed");
520        assert_eq!(files.len(), 2);
521        assert_eq!(files[0].path, PathBuf::from("src/a.rs"));
522        assert_eq!(files[1].path, PathBuf::from("src/b.rs"));
523    }
524
525    #[test]
526    fn test_is_rust_file() {
527        let rust_diff = FileDiff::new(PathBuf::from("src/lib.rs"));
528        assert!(rust_diff.is_rust_file());
529
530        let md_diff = FileDiff::new(PathBuf::from("README.md"));
531        assert!(!md_diff.is_rust_file());
532    }
533
534    #[test]
535    fn test_parse_multibyte_content_does_not_panic() {
536        let diff = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ \
537                    -1,2 +1,2 @@\n контекст\n-старая строка\n+новая строка\nПривет без префикса\n";
538
539        let files = parse_diff(diff).expect("parse should succeed");
540        assert_eq!(files[0].total_added(), 1);
541        assert_eq!(files[0].total_removed(), 1);
542        let hunk = &files[0].hunks[0];
543        assert_eq!(hunk.added_lines(), vec![2]);
544        assert_eq!(hunk.removed_lines(), vec![2]);
545    }
546
547    #[test]
548    fn test_parse_deleted_file() {
549        let diff = "diff --git a/src/old.rs b/src/old.rs\ndeleted file mode 100644\nindex \
550                    1234567..0000000\n--- a/src/old.rs\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-fn \
551                    gone() {}\n-fn also_gone() {}\n";
552
553        let files = parse_diff(diff).expect("parse should succeed");
554        assert!(files[0].is_deleted);
555        assert_eq!(files[0].path, PathBuf::from("src/old.rs"));
556        assert_eq!(files[0].total_removed(), 2);
557    }
558
559    #[test]
560    fn test_parse_new_file_is_not_deleted() {
561        let diff = "diff --git a/src/new.rs b/src/new.rs\nnew file mode 100644\n--- \
562                    /dev/null\n+++ b/src/new.rs\n@@ -0,0 +1,1 @@\n+fn fresh() {}\n";
563
564        let files = parse_diff(diff).expect("parse should succeed");
565        assert!(!files[0].is_deleted);
566        assert!(files[0].old_path.is_none());
567        assert_eq!(files[0].path, PathBuf::from("src/new.rs"));
568    }
569
570    #[test]
571    fn test_parse_renamed_file() {
572        let diff = "diff --git a/src/before.rs b/src/after.rs\nsimilarity index 90%\nrename from \
573                    src/before.rs\nrename to src/after.rs\n--- a/src/before.rs\n+++ \
574                    b/src/after.rs\n@@ -1,1 +1,2 @@\n fn kept() {}\n+fn added() {}\n";
575
576        let files = parse_diff(diff).expect("parse should succeed");
577        assert_eq!(files[0].path, PathBuf::from("src/after.rs"));
578        assert_eq!(files[0].old_path, Some(PathBuf::from("src/before.rs")));
579        assert!(!files[0].is_deleted);
580    }
581
582    #[test]
583    fn test_parse_path_with_spaces() {
584        let diff = "diff --git a/src/my file.rs b/src/my file.rs\n--- a/src/my file.rs\n+++ \
585                    b/src/my file.rs\n@@ -1,1 +1,2 @@\n fn a() {}\n+fn b() {}\n";
586
587        let files = parse_diff(diff).expect("parse should succeed");
588        assert_eq!(files[0].path, PathBuf::from("src/my file.rs"));
589        assert!(files[0].old_path.is_none());
590    }
591
592    #[test]
593    fn test_parse_quoted_path_with_octal_escapes() {
594        let diff = "diff --git \"a/src/\\321\\204.rs\" \"b/src/\\321\\204.rs\"\n--- \
595                    \"a/src/\\321\\204.rs\"\n+++ \"b/src/\\321\\204.rs\"\n@@ -1,1 +1,2 @@\n fn \
596                    a() {}\n+fn b() {}\n";
597
598        let files = parse_diff(diff).expect("parse should succeed");
599        assert_eq!(files[0].path, PathBuf::from("src/ф.rs"));
600    }
601
602    #[test]
603    fn test_unquote_escapes() {
604        assert_eq!(unquote_git_path("plain/path.rs"), "plain/path.rs");
605        assert_eq!(unquote_git_path("\"a b.rs\""), "a b.rs");
606        assert_eq!(unquote_git_path("\"tab\\there\""), "tab\there");
607        assert_eq!(unquote_git_path("\"q\\\"uote\""), "q\"uote");
608        assert_eq!(unquote_git_path("\"\\321\\204\""), "ф");
609    }
610
611    #[test]
612    fn test_removed_line_numbers_across_hunks() {
613        let diff = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ \
614                    -1,3 +1,2 @@\n fn a() {}\n-fn removed_one() {}\n fn c() {}\n@@ -10,3 +9,2 \
615                    @@\n fn x() {}\n-fn removed_two() {}\n fn z() {}\n";
616
617        let files = parse_diff(diff).expect("parse should succeed");
618        assert_eq!(files[0].all_removed_lines(), vec![2, 11]);
619        assert!(files[0].all_added_lines().is_empty());
620    }
621
622    #[test]
623    fn test_empty_line_in_hunk_is_empty_context() {
624        let diff = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ \
625                    -1,3 +1,4 @@\n fn a() {}\n\n+fn b() {}\n fn c() {}\n";
626
627        let files = parse_diff(diff).expect("parse should succeed");
628        assert_eq!(files[0].all_added_lines(), vec![3]);
629    }
630}