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
11pub 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
109pub 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 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 } 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 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 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
162fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
164 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; }
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}