Skip to main content

timebomb/
diff.rs

1use crate::error::{Error, Result};
2use std::collections::HashMap;
3use std::ops::RangeInclusive;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7/// Returns changed line ranges per relative file path.
8///
9/// Runs both `git diff --unified=0 <base>` (unstaged) and
10/// `git diff --unified=0 --cached <base>` (staged) and merges the results.
11/// `validate_git_ref` is called before any subprocess is spawned.
12pub 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    // Merge: extend vecs for the same file key
22    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
30/// Run `git diff --unified=0 [--cached] <base>` and parse the output into
31/// line ranges per file.
32fn 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
70/// Pure function — parse unified diff text into line ranges per file.
71///
72/// Lines starting with `+++ b/` give the current file (stripping the `b/` prefix).
73/// Hunk headers match `^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@`:
74///   - capture 1 = new_start (usize)
75///   - capture 2 = new_count (usize, absent means 1)
76///   - if new_count == 0: pure deletion — no added lines, skip
77///   - range = `new_start..=(new_start + new_count - 1)`
78///
79/// `/dev/null` as the `+++` target means file deleted — skip.
80static 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            // "/dev/null" means the file was deleted — no added lines to track
93            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                // Unexpected format — skip this file
99                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            // Missing count means exactly one line
114            let new_count: usize = caps
115                .get(2)
116                .and_then(|m| m.as_str().parse().ok())
117                .unwrap_or(1);
118
119            // Pure deletion: new_count == 0 — no added lines
120            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    // ── parse_unified_diff unit tests ─────────────────────────────────────────
137
138    #[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        // @@ -1 +5 @@ — no comma → count defaults to 1
162        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        // @@ -3,2 +3,0 @@ — count is 0, so no range should be added
180        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        // Either the key is absent or has an empty vec — either way, no ranges
189        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        // +++ /dev/null means the file was deleted — should produce no entry
223        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        // First hunk: +1,2 → 1..=2
258        assert_eq!(*ranges[0].start(), 1);
259        assert_eq!(*ranges[0].end(), 2);
260        // Second hunk: +11,3 → 11..=13
261        assert_eq!(*ranges[1].start(), 11);
262        assert_eq!(*ranges[1].end(), 13);
263    }
264}