Skip to main content

cargo_context_core/collect/
diff.rs

1//! Git diff capture.
2//!
3//! Shells out to `git` rather than linking `libgit2` / `gitoxide`. Shelling
4//! out inherits the user's config (rename detection, diff filters, pager-off)
5//! and avoids a heavy native dep — which matters for a CLI that's expected to
6//! run on every developer machine.
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum FileStatus {
18    Added,
19    Modified,
20    Deleted,
21    Renamed,
22    Copied,
23    TypeChanged,
24    Unknown,
25}
26
27impl FileStatus {
28    fn from_code(code: char) -> Self {
29        match code {
30            'A' => FileStatus::Added,
31            'M' => FileStatus::Modified,
32            'D' => FileStatus::Deleted,
33            'R' => FileStatus::Renamed,
34            'C' => FileStatus::Copied,
35            'T' => FileStatus::TypeChanged,
36            _ => FileStatus::Unknown,
37        }
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DiffHunk {
43    pub old_start: u32,
44    pub old_lines: u32,
45    pub new_start: u32,
46    pub new_lines: u32,
47    pub body: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct FileDiff {
52    pub path: PathBuf,
53    pub old_path: Option<PathBuf>,
54    pub status: FileStatus,
55    pub hunks: Vec<DiffHunk>,
56    pub binary: bool,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Diff {
61    pub range: Option<String>,
62    pub files: Vec<FileDiff>,
63}
64
65impl Diff {
66    pub fn is_empty(&self) -> bool {
67        self.files.is_empty()
68    }
69
70    pub fn changed_paths(&self) -> impl Iterator<Item = &Path> {
71        self.files.iter().map(|f| f.path.as_path())
72    }
73}
74
75/// Capture the diff for `root`.
76///
77/// - `None` range → unified working-tree + index diff against `HEAD`
78///   (what `git diff HEAD` would show).
79/// - `Some("HEAD~3..HEAD")` → ref-range diff.
80pub fn git_diff(root: &Path, range: Option<&str>) -> Result<Diff> {
81    // 1. name-status for structural info + rename detection
82    let name_status = run_git(root, &mk_name_status_args(range))?;
83    let files = parse_name_status(&name_status);
84    if files.is_empty() {
85        return Ok(Diff {
86            range: range.map(str::to_owned),
87            files: Vec::new(),
88        });
89    }
90
91    // 2. unified patch for hunks
92    let patch = run_git(root, &mk_patch_args(range))?;
93    let hunks_by_path = parse_patch(&patch);
94
95    // Merge hunks into files.
96    let files = files
97        .into_iter()
98        .map(|mut f| {
99            if let Some(hunks) = hunks_by_path.get(&f.path) {
100                f.hunks = hunks.clone();
101            }
102            f
103        })
104        .collect();
105
106    Ok(Diff {
107        range: range.map(str::to_owned),
108        files,
109    })
110}
111
112fn mk_name_status_args(range: Option<&str>) -> Vec<String> {
113    let mut args = vec![
114        "diff".into(),
115        "--name-status".into(),
116        "--find-renames".into(),
117        "-z".into(),
118    ];
119    match range {
120        Some(r) => args.push(r.into()),
121        None => args.push("HEAD".into()),
122    }
123    args
124}
125
126fn mk_patch_args(range: Option<&str>) -> Vec<String> {
127    let mut args = vec![
128        "diff".into(),
129        "--no-color".into(),
130        "--find-renames".into(),
131        "--unified=3".into(),
132    ];
133    match range {
134        Some(r) => args.push(r.into()),
135        None => args.push("HEAD".into()),
136    }
137    args
138}
139
140fn run_git(root: &Path, args: &[String]) -> Result<String> {
141    let output = Command::new("git")
142        .current_dir(root)
143        .args(args)
144        .output()
145        .map_err(|e| Error::Tool(format!("failed to spawn git: {e}")))?;
146    if !output.status.success() {
147        let stderr = String::from_utf8_lossy(&output.stderr);
148        // A non-repo, missing HEAD, or "use --no-index" nudge is legitimately
149        // "no diff to capture" — don't promote it to an error.
150        let lower = stderr.to_lowercase();
151        if lower.contains("not a git repository")
152            || lower.contains("unknown revision")
153            || lower.contains("use --no-index")
154        {
155            return Ok(String::new());
156        }
157        return Err(Error::Tool(format!(
158            "git {:?} failed: {}",
159            args,
160            stderr.trim()
161        )));
162    }
163    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
164}
165
166/// Parse `git diff --name-status -z` output.
167///
168/// With `-z`, every field — including the status code — is NUL-terminated.
169/// Format:
170///   `<status>\0<path>\0`                                  (most cases)
171///   `R<score>\0<old_path>\0<new_path>\0`                  (rename)
172///   `C<score>\0<old_path>\0<new_path>\0`                  (copy)
173fn parse_name_status(raw: &str) -> Vec<FileDiff> {
174    let mut out = Vec::new();
175    let mut iter = raw.split('\0').filter(|s| !s.is_empty());
176    while let Some(status_str) = iter.next() {
177        let code = status_str.chars().next().unwrap_or('?');
178        let status = FileStatus::from_code(code);
179        let first_path = match iter.next() {
180            Some(p) => p,
181            None => break,
182        };
183        let (path, old_path) = match status {
184            FileStatus::Renamed | FileStatus::Copied => {
185                let new_path = match iter.next() {
186                    Some(p) => PathBuf::from(p),
187                    None => break,
188                };
189                (new_path, Some(PathBuf::from(first_path)))
190            }
191            _ => (PathBuf::from(first_path), None),
192        };
193        out.push(FileDiff {
194            path,
195            old_path,
196            status,
197            hunks: Vec::new(),
198            binary: false,
199        });
200    }
201    out
202}
203
204/// Parse a unified patch into a map of path → hunks.
205///
206/// Recognizes `diff --git a/... b/...` headers (uses the `b/` path so it
207/// matches the "new" side the name-status lookup uses), `@@ -a,b +c,d @@`
208/// hunk headers, and `Binary files ... differ` markers.
209fn parse_patch(raw: &str) -> std::collections::HashMap<PathBuf, Vec<DiffHunk>> {
210    let mut map: std::collections::HashMap<PathBuf, Vec<DiffHunk>> =
211        std::collections::HashMap::new();
212    let mut current_path: Option<PathBuf> = None;
213    let mut current_hunk: Option<DiffHunk> = None;
214
215    for line in raw.lines() {
216        if let Some(rest) = line.strip_prefix("diff --git ") {
217            // Flush previous hunk.
218            if let (Some(path), Some(hunk)) = (current_path.clone(), current_hunk.take()) {
219                map.entry(path).or_default().push(hunk);
220            }
221            current_path = parse_b_path(rest);
222            continue;
223        }
224        if line.starts_with("Binary files ") {
225            continue;
226        }
227        if let Some(rest) = line.strip_prefix("@@ ") {
228            // Flush previous hunk.
229            if let (Some(path), Some(hunk)) = (current_path.clone(), current_hunk.take()) {
230                map.entry(path).or_default().push(hunk);
231            }
232            if let Some(h) = parse_hunk_header(rest) {
233                current_hunk = Some(h);
234            }
235            continue;
236        }
237        if let Some(h) = current_hunk.as_mut() {
238            // Skip the usual file markers; everything else is real hunk body.
239            if line.starts_with("--- ") || line.starts_with("+++ ") {
240                continue;
241            }
242            h.body.push_str(line);
243            h.body.push('\n');
244        }
245    }
246
247    if let (Some(path), Some(hunk)) = (current_path, current_hunk) {
248        map.entry(path).or_default().push(hunk);
249    }
250
251    map
252}
253
254fn parse_b_path(header_rest: &str) -> Option<PathBuf> {
255    // Example: `a/src/foo.rs b/src/foo.rs`
256    // Use rsplit so paths containing spaces (rare but legal) get the final
257    // ` b/` split.
258    let (_, b) = header_rest.rsplit_once(" b/")?;
259    Some(PathBuf::from(b))
260}
261
262fn parse_hunk_header(rest: &str) -> Option<DiffHunk> {
263    // Example: `-5,7 +5,9 @@ context line`
264    let (ranges, _) = rest.split_once(" @@").unwrap_or((rest, ""));
265    let mut parts = ranges.split_whitespace();
266    let old = parts.next()?.strip_prefix('-')?;
267    let new = parts.next()?.strip_prefix('+')?;
268    let (old_start, old_lines) = parse_range(old);
269    let (new_start, new_lines) = parse_range(new);
270    Some(DiffHunk {
271        old_start,
272        old_lines,
273        new_start,
274        new_lines,
275        body: String::new(),
276    })
277}
278
279fn parse_range(s: &str) -> (u32, u32) {
280    match s.split_once(',') {
281        Some((a, b)) => (a.parse().unwrap_or(0), b.parse().unwrap_or(1)),
282        None => (s.parse().unwrap_or(0), 1),
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn parses_name_status_simple() {
292        let raw = "M\0src/foo.rs\0A\0src/bar.rs\0";
293        let v = parse_name_status(raw);
294        assert_eq!(v.len(), 2);
295        assert_eq!(v[0].path, PathBuf::from("src/foo.rs"));
296        assert_eq!(v[0].status, FileStatus::Modified);
297        assert_eq!(v[1].status, FileStatus::Added);
298    }
299
300    #[test]
301    fn parses_name_status_rename() {
302        let raw = "R100\0old.rs\0new.rs\0";
303        let v = parse_name_status(raw);
304        assert_eq!(v.len(), 1);
305        assert_eq!(v[0].status, FileStatus::Renamed);
306        assert_eq!(v[0].path, PathBuf::from("new.rs"));
307        assert_eq!(v[0].old_path.as_deref(), Some(Path::new("old.rs")));
308    }
309
310    #[test]
311    fn parses_hunk_header() {
312        let h = parse_hunk_header("-5,7 +10,9 @@ fn foo").unwrap();
313        assert_eq!(h.old_start, 5);
314        assert_eq!(h.old_lines, 7);
315        assert_eq!(h.new_start, 10);
316        assert_eq!(h.new_lines, 9);
317    }
318
319    #[test]
320    fn parses_hunk_header_single_line() {
321        let h = parse_hunk_header("-5 +5 @@").unwrap();
322        assert_eq!(h.old_lines, 1);
323        assert_eq!(h.new_lines, 1);
324    }
325
326    #[test]
327    fn parses_b_path() {
328        let p = parse_b_path("a/src/foo.rs b/src/foo.rs").unwrap();
329        assert_eq!(p, PathBuf::from("src/foo.rs"));
330    }
331
332    #[test]
333    fn parses_patch_with_multiple_files() {
334        let raw = "\
335diff --git a/a.rs b/a.rs
336--- a/a.rs
337+++ b/a.rs
338@@ -1,2 +1,3 @@
339 keep
340+added
341 keep
342diff --git a/b.rs b/b.rs
343--- a/b.rs
344+++ b/b.rs
345@@ -5,1 +5,1 @@
346-old
347+new
348";
349        let map = parse_patch(raw);
350        assert_eq!(map.len(), 2);
351        assert_eq!(map[&PathBuf::from("a.rs")].len(), 1);
352        assert_eq!(map[&PathBuf::from("b.rs")].len(), 1);
353        assert_eq!(map[&PathBuf::from("a.rs")][0].new_lines, 3);
354    }
355
356    #[test]
357    fn git_diff_returns_empty_outside_repo() {
358        // tempfile isn't a git repo; git returns non-zero — we translate that
359        // to empty (matches the "nothing to say" reading).
360        let tmp = tempfile::tempdir().unwrap();
361        let d = git_diff(tmp.path(), None).unwrap();
362        assert!(d.is_empty());
363    }
364}