1use crate::error::{Error, Result};
2use std::collections::HashMap;
3use std::ops::RangeInclusive;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7pub fn git_changed_line_ranges(
13 repo_root: &Path,
14 base: &str,
15) -> Result<HashMap<PathBuf, Vec<RangeInclusive<usize>>>> {
16 crate::git::validate_git_ref(base)?;
17
18 let unstaged = run_git_diff_lines(repo_root, base, false)?;
19 let staged = run_git_diff_lines(repo_root, base, true)?;
20
21 let mut merged: HashMap<PathBuf, Vec<RangeInclusive<usize>>> = unstaged;
23 for (path, ranges) in staged {
24 merged.entry(path).or_default().extend(ranges);
25 }
26
27 Ok(merged)
28}
29
30fn run_git_diff_lines(
33 repo_root: &Path,
34 base: &str,
35 cached: bool,
36) -> Result<HashMap<PathBuf, Vec<RangeInclusive<usize>>>> {
37 let mut cmd = Command::new("git");
38 cmd.arg("diff").arg("--unified=0");
39 if cached {
40 cmd.arg("--cached");
41 }
42 cmd.arg(base);
43 cmd.current_dir(repo_root);
44
45 let output = cmd.output().map_err(|e| {
46 if e.kind() == std::io::ErrorKind::NotFound {
47 Error::InvalidArgument("'git' command not found — is git installed?".to_string())
48 } else {
49 Error::InvalidArgument(format!("failed to spawn git: {}", e))
50 }
51 })?;
52
53 if !output.status.success() {
54 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
55 if stderr.to_lowercase().contains("not a git repository") {
56 return Err(Error::InvalidArgument(
57 "git diff failed: not a git repository".to_string(),
58 ));
59 }
60 return Err(Error::InvalidArgument(format!(
61 "invalid git ref '{}': {}",
62 base, stderr
63 )));
64 }
65
66 let stdout = String::from_utf8_lossy(&output.stdout);
67 Ok(parse_unified_diff(&stdout))
68}
69
70static HUNK_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
81 regex::Regex::new(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@").expect("hardcoded regex is valid")
82});
83
84pub fn parse_unified_diff(output: &str) -> HashMap<PathBuf, Vec<RangeInclusive<usize>>> {
85 let hunk_re = &*HUNK_RE;
86
87 let mut result: HashMap<PathBuf, Vec<RangeInclusive<usize>>> = HashMap::new();
88 let mut current_file: Option<PathBuf> = None;
89
90 for line in output.lines() {
91 if let Some(rest) = line.strip_prefix("+++ ") {
92 if rest == "/dev/null" {
94 current_file = None;
95 } else if let Some(path_str) = rest.strip_prefix("b/") {
96 current_file = Some(PathBuf::from(path_str));
97 } else {
98 current_file = None;
100 }
101 continue;
102 }
103
104 if line.starts_with("@@") {
105 let Some(ref file) = current_file else {
106 continue;
107 };
108 let Some(caps) = hunk_re.captures(line) else {
109 continue;
110 };
111
112 let new_start: usize = caps[1].parse().unwrap_or(1);
113 let new_count: usize = caps
115 .get(2)
116 .and_then(|m| m.as_str().parse().ok())
117 .unwrap_or(1);
118
119 if new_count == 0 {
121 continue;
122 }
123
124 let range = new_start..=(new_start + new_count - 1);
125 result.entry(file.clone()).or_default().push(range);
126 }
127 }
128
129 result
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
139 fn test_parse_unified_diff_basic() {
140 let diff = "\
141diff --git a/src/main.rs b/src/main.rs
142--- a/src/main.rs
143+++ b/src/main.rs
144@@ -1,3 +2,4 @@
145+line a
146+line b
147+line c
148+line d
149";
150 let map = parse_unified_diff(diff);
151 let ranges = map
152 .get(Path::new("src/main.rs"))
153 .expect("file should be present");
154 assert_eq!(ranges.len(), 1);
155 assert_eq!(*ranges[0].start(), 2);
156 assert_eq!(*ranges[0].end(), 5);
157 }
158
159 #[test]
160 fn test_parse_unified_diff_single_line_no_count() {
161 let diff = "\
163--- a/foo.rs
164+++ b/foo.rs
165@@ -1 +5 @@
166+added line
167";
168 let map = parse_unified_diff(diff);
169 let ranges = map
170 .get(Path::new("foo.rs"))
171 .expect("file should be present");
172 assert_eq!(ranges.len(), 1);
173 assert_eq!(*ranges[0].start(), 5);
174 assert_eq!(*ranges[0].end(), 5);
175 }
176
177 #[test]
178 fn test_parse_unified_diff_pure_deletion() {
179 let diff = "\
181--- a/bar.rs
182+++ b/bar.rs
183@@ -3,2 +3,0 @@
184-deleted line 1
185-deleted line 2
186";
187 let map = parse_unified_diff(diff);
188 let ranges = map.get(Path::new("bar.rs"));
190 let is_empty = ranges.map(|v| v.is_empty()).unwrap_or(true);
191 assert!(is_empty, "pure deletion should produce no line ranges");
192 }
193
194 #[test]
195 fn test_parse_unified_diff_multiple_files() {
196 let diff = "\
197--- a/alpha.rs
198+++ b/alpha.rs
199@@ -1,1 +1,2 @@
200+added in alpha 1
201+added in alpha 2
202--- a/beta.rs
203+++ b/beta.rs
204@@ -5,1 +5,1 @@
205-old line
206+new line
207";
208 let map = parse_unified_diff(diff);
209 assert!(
210 map.contains_key(Path::new("alpha.rs")),
211 "alpha.rs should be present"
212 );
213 assert!(
214 map.contains_key(Path::new("beta.rs")),
215 "beta.rs should be present"
216 );
217 assert_eq!(map.len(), 2);
218 }
219
220 #[test]
221 fn test_parse_unified_diff_dev_null_skipped() {
222 let diff = "\
224--- a/gone.rs
225+++ /dev/null
226@@ -1,3 +0,0 @@
227-line 1
228-line 2
229-line 3
230";
231 let map = parse_unified_diff(diff);
232 assert!(
233 !map.contains_key(Path::new("gone.rs")),
234 "/dev/null target should not produce an entry"
235 );
236 assert!(map.is_empty(), "map should be empty for deleted-file diff");
237 }
238
239 #[test]
240 fn test_parse_unified_diff_multiple_hunks_same_file() {
241 let diff = "\
242--- a/multi.rs
243+++ b/multi.rs
244@@ -1,1 +1,2 @@
245+hunk1 line1
246+hunk1 line2
247@@ -10,1 +11,3 @@
248+hunk2 line1
249+hunk2 line2
250+hunk2 line3
251";
252 let map = parse_unified_diff(diff);
253 let ranges = map
254 .get(Path::new("multi.rs"))
255 .expect("multi.rs should be present");
256 assert_eq!(ranges.len(), 2, "should have two separate hunk ranges");
257 assert_eq!(*ranges[0].start(), 1);
259 assert_eq!(*ranges[0].end(), 2);
260 assert_eq!(*ranges[1].start(), 11);
262 assert_eq!(*ranges[1].end(), 13);
263 }
264}