Skip to main content

git_spawn/parse/
diff.rs

1//! Parser for `git diff --name-status -z`.
2//!
3//! Each entry is a single status character followed by a tab and the path,
4//! all NUL-terminated. Renames/copies include a numeric similarity index
5//! (e.g. `R100`) and a second path following another NUL.
6
7use crate::error::{Error, Result};
8
9/// One parsed entry from `git diff --name-status -z`.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct DiffEntry {
13    /// Kind of change.
14    pub kind: DiffKind,
15    /// Affected path (post-rename, if applicable).
16    pub path: String,
17    /// Original path for renames/copies.
18    pub original_path: Option<String>,
19    /// Similarity index for renames/copies (e.g. 100 means identical).
20    pub similarity: Option<u32>,
21}
22
23/// Classification of a diff entry.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub enum DiffKind {
27    /// Added (`A`).
28    Added,
29    /// Deleted (`D`).
30    Deleted,
31    /// Modified (`M`).
32    Modified,
33    /// Renamed (`R`).
34    Renamed,
35    /// Copied (`C`).
36    Copied,
37    /// Type changed (`T`).
38    TypeChanged,
39    /// Unmerged (`U`).
40    Unmerged,
41    /// Unknown (`X` or anything else).
42    Other(char),
43}
44
45impl From<char> for DiffKind {
46    fn from(c: char) -> Self {
47        match c {
48            'A' => Self::Added,
49            'D' => Self::Deleted,
50            'M' => Self::Modified,
51            'R' => Self::Renamed,
52            'C' => Self::Copied,
53            'T' => Self::TypeChanged,
54            'U' => Self::Unmerged,
55            c => Self::Other(c),
56        }
57    }
58}
59
60/// Parse the output of `git diff --name-status -z`.
61///
62/// # Errors
63/// Returns [`Error::ParseError`] if an entry is malformed.
64///
65/// # Example
66/// ```
67/// use git_spawn::parse::{parse_diff_name_status, DiffKind};
68/// let input = "M\0foo.txt\0A\0bar.txt\0R100\0old.rs\0new.rs\0";
69/// let entries = parse_diff_name_status(input).unwrap();
70/// assert_eq!(entries.len(), 3);
71/// assert_eq!(entries[0].kind, DiffKind::Modified);
72/// assert_eq!(entries[2].kind, DiffKind::Renamed);
73/// assert_eq!(entries[2].similarity, Some(100));
74/// assert_eq!(entries[2].original_path.as_deref(), Some("old.rs"));
75/// assert_eq!(entries[2].path, "new.rs");
76/// ```
77pub fn parse_diff_name_status(input: &str) -> Result<Vec<DiffEntry>> {
78    let mut out = Vec::new();
79    let mut iter = input.split('\0');
80    while let Some(status) = iter.next() {
81        if status.is_empty() {
82            continue;
83        }
84        let mut chars = status.chars();
85        let first = chars
86            .next()
87            .ok_or_else(|| Error::parse_error("diff entry missing status character"))?;
88        let kind = DiffKind::from(first);
89        let similarity: Option<u32> = {
90            let rest: String = chars.collect();
91            if rest.is_empty() {
92                None
93            } else {
94                rest.parse().ok()
95            }
96        };
97        let is_rename_or_copy = matches!(kind, DiffKind::Renamed | DiffKind::Copied);
98        let (original, path) = if is_rename_or_copy {
99            let orig = iter
100                .next()
101                .ok_or_else(|| Error::parse_error("rename/copy missing original path"))?;
102            let new = iter
103                .next()
104                .ok_or_else(|| Error::parse_error("rename/copy missing new path"))?;
105            (Some(orig.to_string()), new.to_string())
106        } else {
107            let path = iter
108                .next()
109                .ok_or_else(|| Error::parse_error("diff entry missing path"))?;
110            (None, path.to_string())
111        };
112        out.push(DiffEntry {
113            kind,
114            path,
115            original_path: original,
116            similarity,
117        });
118    }
119    Ok(out)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn simple_changes() {
128        let input = "M\0foo.txt\0A\0bar.txt\0D\0baz.txt\0";
129        let entries = parse_diff_name_status(input).unwrap();
130        assert_eq!(entries.len(), 3);
131        assert_eq!(entries[0].path, "foo.txt");
132        assert_eq!(entries[2].kind, DiffKind::Deleted);
133    }
134
135    #[test]
136    fn rename_with_similarity() {
137        let input = "R090\0a.rs\0b.rs\0";
138        let entries = parse_diff_name_status(input).unwrap();
139        assert_eq!(entries.len(), 1);
140        assert_eq!(entries[0].similarity, Some(90));
141        assert_eq!(entries[0].original_path.as_deref(), Some("a.rs"));
142        assert_eq!(entries[0].path, "b.rs");
143    }
144
145    #[test]
146    fn empty_ok() {
147        assert!(parse_diff_name_status("").unwrap().is_empty());
148    }
149}