cargo_context_core/collect/
diff.rs1use 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
75pub fn git_diff(root: &Path, range: Option<&str>) -> Result<Diff> {
81 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 let patch = run_git(root, &mk_patch_args(range))?;
93 let hunks_by_path = parse_patch(&patch);
94
95 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 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
166fn 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
204fn 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 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 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 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 let (_, b) = header_rest.rsplit_once(" b/")?;
259 Some(PathBuf::from(b))
260}
261
262fn parse_hunk_header(rest: &str) -> Option<DiffHunk> {
263 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 let tmp = tempfile::tempdir().unwrap();
361 let d = git_diff(tmp.path(), None).unwrap();
362 assert!(d.is_empty());
363 }
364}