Skip to main content

covy_core/
diff.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use roaring::RoaringBitmap;
5
6use crate::error::CovyError;
7use crate::model::{DiffStatus, FileDiff};
8
9const DIFF_CACHE_DIR: &str = ".covy/state/diff-cache";
10
11/// Parse git diff output to extract changed files and line numbers.
12pub fn git_diff(base: &str, head: &str) -> Result<Vec<FileDiff>, CovyError> {
13    let (base_hash, head_hash) = resolve_refs(base, head)?;
14    let cache_path = diff_cache_path(&base_hash, &head_hash);
15
16    if let Some(cached) = load_cached_diff(&cache_path) {
17        return parse_diff_output(&cached);
18    }
19
20    let stdout = run_git_diff(base, head)?;
21    let _ = save_cached_diff(&cache_path, &stdout);
22    parse_diff_output(&stdout)
23}
24
25fn run_git_diff(base: &str, head: &str) -> Result<String, CovyError> {
26    let output = Command::new("git")
27        .args([
28            "diff",
29            "--unified=0",
30            "--no-color",
31            "--no-ext-diff",
32            "--diff-filter=ACMR",
33            &format!("{base}..{head}"),
34        ])
35        .output()
36        .map_err(|e| {
37            if e.kind() == std::io::ErrorKind::NotFound {
38                CovyError::GitNotFound
39            } else {
40                CovyError::Git(format!("Failed to run git diff: {e}"))
41            }
42        })?;
43
44    if !output.status.success() {
45        let stderr = String::from_utf8_lossy(&output.stderr);
46        return Err(CovyError::Git(format!("git diff failed: {stderr}")));
47    }
48
49    Ok(String::from_utf8_lossy(&output.stdout).to_string())
50}
51
52fn resolve_refs(base: &str, head: &str) -> Result<(String, String), CovyError> {
53    let output = Command::new("git")
54        .args(["rev-parse", base, head])
55        .output()
56        .map_err(|e| {
57            if e.kind() == std::io::ErrorKind::NotFound {
58                CovyError::GitNotFound
59            } else {
60                CovyError::Git(format!("Failed to run git rev-parse: {e}"))
61            }
62        })?;
63
64    if !output.status.success() {
65        let stderr = String::from_utf8_lossy(&output.stderr);
66        return Err(CovyError::Git(format!("git rev-parse failed: {stderr}")));
67    }
68
69    let stdout = String::from_utf8_lossy(&output.stdout);
70    let mut lines = stdout.lines();
71    let base_hash = lines.next().unwrap_or_default().trim().to_string();
72    let head_hash = lines.next().unwrap_or_default().trim().to_string();
73
74    if base_hash.is_empty() || head_hash.is_empty() {
75        return Err(CovyError::Git(
76            "git rev-parse returned empty ref hash".to_string(),
77        ));
78    }
79
80    Ok((base_hash, head_hash))
81}
82
83fn diff_cache_key(base_hash: &str, head_hash: &str) -> String {
84    let mut hasher = blake3::Hasher::new();
85    hasher.update(base_hash.as_bytes());
86    hasher.update(head_hash.as_bytes());
87    hasher.finalize().to_hex().to_string()
88}
89
90fn diff_cache_path(base_hash: &str, head_hash: &str) -> PathBuf {
91    Path::new(DIFF_CACHE_DIR).join(format!("{}.diff", diff_cache_key(base_hash, head_hash)))
92}
93
94fn load_cached_diff(path: &Path) -> Option<String> {
95    if !path.exists() {
96        return None;
97    }
98    std::fs::read_to_string(path).ok()
99}
100
101fn save_cached_diff(path: &Path, content: &str) -> Result<(), CovyError> {
102    if let Some(parent) = path.parent() {
103        std::fs::create_dir_all(parent)?;
104    }
105    std::fs::write(path, content)?;
106    Ok(())
107}
108
109/// Parse the raw output of `git diff --unified=0`.
110pub fn parse_diff_output(diff_text: &str) -> Result<Vec<FileDiff>, CovyError> {
111    let mut diffs = Vec::new();
112    let mut current_path: Option<String> = None;
113    let mut current_old_path: Option<String> = None;
114    let mut current_status = DiffStatus::Modified;
115    let mut current_lines = RoaringBitmap::new();
116
117    for line in diff_text.lines() {
118        if line.starts_with("diff --git") {
119            // Flush previous file
120            if let Some(path) = current_path.take() {
121                diffs.push(FileDiff {
122                    path,
123                    old_path: current_old_path.take(),
124                    status: current_status,
125                    changed_lines: std::mem::take(&mut current_lines),
126                });
127            }
128            current_status = DiffStatus::Modified;
129            current_old_path = None;
130        } else if line.starts_with("+++ b/") {
131            current_path = Some(line[6..].to_string());
132        } else if line.starts_with("+++ /dev/null") {
133            // File was deleted — we skip deleted files (filtered by --diff-filter)
134        } else if line.starts_with("new file") {
135            current_status = DiffStatus::Added;
136        } else if line.starts_with("rename from ") {
137            current_old_path = Some(line["rename from ".len()..].to_string());
138            current_status = DiffStatus::Renamed;
139        } else if line.starts_with("@@") {
140            // Parse @@ -old,count +new,count @@ ...
141            if let Some(new_range) = parse_hunk_header(line) {
142                for line_no in new_range.0..=new_range.1 {
143                    current_lines.insert(line_no);
144                }
145            }
146        }
147    }
148
149    // Flush last file
150    if let Some(path) = current_path {
151        diffs.push(FileDiff {
152            path,
153            old_path: current_old_path,
154            status: current_status,
155            changed_lines: current_lines,
156        });
157    }
158
159    Ok(diffs)
160}
161
162/// Parse a hunk header like `@@ -10,3 +20,5 @@` and return (start, end) for the new side.
163fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
164    // Find the +N,M or +N part
165    let plus_idx = line.find('+').unwrap_or(0);
166    let rest = &line[plus_idx + 1..];
167    let end = rest.find(' ').unwrap_or(rest.len());
168    let range_str = &rest[..end];
169
170    if let Some(comma) = range_str.find(',') {
171        let start: u32 = range_str[..comma].parse().ok()?;
172        let count: u32 = range_str[comma + 1..].parse().ok()?;
173        if count == 0 {
174            return None; // Pure deletion in old, no new lines
175        }
176        Some((start, start + count - 1))
177    } else {
178        let start: u32 = range_str.parse().ok()?;
179        Some((start, start))
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_parse_hunk_single_line() {
189        assert_eq!(parse_hunk_header("@@ -1 +1 @@"), Some((1, 1)));
190    }
191
192    #[test]
193    fn test_parse_hunk_range() {
194        assert_eq!(
195            parse_hunk_header("@@ -10,3 +20,5 @@ fn foo"),
196            Some((20, 24))
197        );
198    }
199
200    #[test]
201    fn test_parse_hunk_deletion_only() {
202        assert_eq!(parse_hunk_header("@@ -10,3 +9,0 @@"), None);
203    }
204
205    #[test]
206    fn test_parse_diff_output_basic() {
207        let diff = r#"diff --git a/src/main.rs b/src/main.rs
208index abc123..def456 100644
209--- a/src/main.rs
210+++ b/src/main.rs
211@@ -1,3 +1,5 @@
212+use std::io;
213+
214 fn main() {
215-    println!("old");
216+    println!("new");
217+    io::stdout().flush().unwrap();
218 }
219"#;
220        let result = parse_diff_output(diff).unwrap();
221        assert_eq!(result.len(), 1);
222        assert_eq!(result[0].path, "src/main.rs");
223        assert_eq!(result[0].status, DiffStatus::Modified);
224        assert!(result[0].changed_lines.contains(1));
225        assert!(result[0].changed_lines.contains(5));
226    }
227
228    #[test]
229    fn test_parse_diff_new_file() {
230        let diff = r#"diff --git a/new.rs b/new.rs
231new file mode 100644
232index 0000000..abc123
233--- /dev/null
234+++ b/new.rs
235@@ -0,0 +1,3 @@
236+fn new_func() {
237+    todo!()
238+}
239"#;
240        let result = parse_diff_output(diff).unwrap();
241        assert_eq!(result.len(), 1);
242        assert_eq!(result[0].status, DiffStatus::Added);
243        assert_eq!(result[0].changed_lines.len(), 3);
244    }
245
246    #[test]
247    fn test_parse_diff_rename() {
248        let diff = r#"diff --git a/old.rs b/new.rs
249similarity index 90%
250rename from old.rs
251rename to new.rs
252--- a/old.rs
253+++ b/new.rs
254@@ -1 +1 @@
255-old line
256+new line
257"#;
258        let result = parse_diff_output(diff).unwrap();
259        assert_eq!(result.len(), 1);
260        assert_eq!(result[0].status, DiffStatus::Renamed);
261        assert_eq!(result[0].old_path.as_deref(), Some("old.rs"));
262        assert_eq!(result[0].path, "new.rs");
263    }
264
265    #[test]
266    fn test_diff_cache_key_stable() {
267        let a = diff_cache_key("abc", "def");
268        let b = diff_cache_key("abc", "def");
269        let c = diff_cache_key("def", "abc");
270        assert_eq!(a, b);
271        assert_ne!(a, c);
272    }
273
274    #[test]
275    fn test_diff_cache_path_ext() {
276        let p = diff_cache_path("abc", "def");
277        assert!(p.to_string_lossy().contains(".covy/state/diff-cache"));
278        assert_eq!(p.extension().and_then(|e| e.to_str()), Some("diff"));
279    }
280}