1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use serde::Serialize;
8
9use crate::error::{Error, Result};
10
11pub struct GitClient {
13 repo_path: PathBuf,
14}
15
16#[derive(Debug, Clone, Serialize)]
18pub struct FileChurn {
19 pub path: PathBuf,
20 pub commits: usize,
21 pub lines_added: usize,
22 pub lines_deleted: usize,
23}
24
25#[derive(Debug, Clone, Serialize)]
27pub struct RepoInfo {
28 pub branch: Option<String>,
29 pub commit: Option<String>,
30 pub author: Option<String>,
31 pub date: Option<String>,
32}
33
34impl GitClient {
35 pub fn detect(path: &Path) -> Result<Self> {
38 let output = Command::new("git")
39 .args(["rev-parse", "--show-toplevel"])
40 .current_dir(path)
41 .output()
42 .map_err(|e| Error::GitError {
43 message: format!("failed to execute git: {e}"),
44 })?;
45
46 if !output.status.success() {
47 return Err(Error::NotGitRepo {
48 path: path.to_path_buf(),
49 });
50 }
51
52 let repo_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
53
54 Ok(Self {
55 repo_path: PathBuf::from(repo_path),
56 })
57 }
58
59 pub fn repo_path(&self) -> &Path {
61 &self.repo_path
62 }
63
64 pub fn repo_info(&self) -> Result<RepoInfo> {
66 let branch = self.run_git(&["rev-parse", "--abbrev-ref", "HEAD"]).ok();
67 let commit = self.run_git(&["rev-parse", "--short", "HEAD"]).ok();
68 let author = self.run_git(&["log", "-1", "--format=%an"]).ok();
69 let date = self.run_git(&["log", "-1", "--format=%ai"]).ok();
70
71 Ok(RepoInfo {
72 branch,
73 commit,
74 author,
75 date,
76 })
77 }
78
79 pub fn file_churn(&self, since: &str) -> Result<Vec<FileChurn>> {
83 let output = Command::new("git")
84 .args([
85 "log",
86 "--numstat",
87 "--format=%H",
88 &format!("--since={since}"),
89 ])
90 .current_dir(&self.repo_path)
91 .output()
92 .map_err(|e| Error::GitError {
93 message: format!("failed to execute git log: {e}"),
94 })?;
95
96 if !output.status.success() {
97 if self.run_git(&["rev-parse", "HEAD"]).is_err() {
100 return Ok(vec![]);
101 }
102 let stderr = String::from_utf8_lossy(&output.stderr);
103 return Err(Error::GitError {
104 message: format!("git log failed: {stderr}"),
105 });
106 }
107
108 let stdout = String::from_utf8_lossy(&output.stdout);
109 Ok(parse_numstat(&stdout))
110 }
111
112 pub fn commit_count(&self, since: &str) -> Result<usize> {
114 let output = self.run_git(&["rev-list", "--count", "HEAD", &format!("--since={since}")])?;
115 Ok(output.parse::<usize>().unwrap_or(0))
116 }
117
118 fn run_git(&self, args: &[&str]) -> Result<String> {
119 let output = Command::new("git")
120 .args(args)
121 .current_dir(&self.repo_path)
122 .output()
123 .map_err(|e| Error::GitError {
124 message: format!("failed to execute git: {e}"),
125 })?;
126
127 if !output.status.success() {
128 let stderr = String::from_utf8_lossy(&output.stderr);
129 return Err(Error::GitError {
130 message: stderr.trim().to_string(),
131 });
132 }
133
134 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
135 }
136}
137
138fn parse_numstat(output: &str) -> Vec<FileChurn> {
140 let mut file_map: HashMap<PathBuf, (usize, usize, usize)> = HashMap::new();
141
142 for line in output.lines() {
143 let line = line.trim();
144 if line.is_empty() {
145 continue;
146 }
147
148 let parts: Vec<&str> = line.split('\t').collect();
149 if parts.len() == 3 {
150 let added = parts[0].parse::<usize>().unwrap_or(0);
151 let deleted = parts[1].parse::<usize>().unwrap_or(0);
152 let path = PathBuf::from(parts[2]);
153
154 let entry = file_map.entry(path).or_insert((0, 0, 0));
155 entry.0 += 1;
156 entry.1 += added;
157 entry.2 += deleted;
158 }
159 }
160
161 let mut churns: Vec<FileChurn> = file_map
162 .into_iter()
163 .map(|(path, (commits, added, deleted))| FileChurn {
164 path,
165 commits,
166 lines_added: added,
167 lines_deleted: deleted,
168 })
169 .collect();
170
171 churns.sort_by(|a, b| b.commits.cmp(&a.commits));
172 churns
173}
174
175pub fn parse_since(input: &str) -> String {
179 let input = input.trim();
180
181 if input.len() == 10 && input.chars().nth(4) == Some('-') {
182 return input.to_string();
183 }
184
185 if let Some(num_str) = input.strip_suffix('d') {
186 if let Ok(n) = num_str.parse::<u32>() {
187 return format!("{n} days ago");
188 }
189 }
190 if let Some(num_str) = input.strip_suffix('w') {
191 if let Ok(n) = num_str.parse::<u32>() {
192 return format!("{} days ago", n * 7);
193 }
194 }
195 if let Some(num_str) = input.strip_suffix('m') {
196 if let Ok(n) = num_str.parse::<u32>() {
197 return format!("{n} months ago");
198 }
199 }
200 if let Some(num_str) = input.strip_suffix('y') {
201 if let Ok(n) = num_str.parse::<u32>() {
202 return format!("{n} years ago");
203 }
204 }
205
206 input.to_string()
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn test_parse_numstat_empty() {
215 let result = parse_numstat("");
216 assert!(result.is_empty());
217 }
218
219 #[test]
220 fn test_parse_numstat_single_commit() {
221 let input = "abc1234\n5\t3\tsrc/main.rs\n2\t1\tsrc/lib.rs\n";
222 let result = parse_numstat(input);
223 assert_eq!(result.len(), 2);
224 let main = result
225 .iter()
226 .find(|f| f.path == Path::new("src/main.rs"))
227 .unwrap();
228 assert_eq!(main.commits, 1);
229 assert_eq!(main.lines_added, 5);
230 assert_eq!(main.lines_deleted, 3);
231 }
232
233 #[test]
234 fn test_parse_numstat_multiple_commits_same_file() {
235 let input = "abc1234\n5\t3\tsrc/main.rs\n\ndef5678\n10\t2\tsrc/main.rs\n";
236 let result = parse_numstat(input);
237 assert_eq!(result.len(), 1);
238 let main = &result[0];
239 assert_eq!(main.commits, 2);
240 assert_eq!(main.lines_added, 15);
241 assert_eq!(main.lines_deleted, 5);
242 }
243
244 #[test]
245 fn test_parse_numstat_binary_files() {
246 let input = "abc1234\n-\t-\timage.png\n5\t3\tsrc/main.rs\n";
247 let result = parse_numstat(input);
248 let png = result
249 .iter()
250 .find(|f| f.path == Path::new("image.png"))
251 .unwrap();
252 assert_eq!(png.commits, 1);
253 assert_eq!(png.lines_added, 0);
254 }
255
256 #[test]
257 fn test_parse_since_days() {
258 assert_eq!(parse_since("30d"), "30 days ago");
259 assert_eq!(parse_since("7d"), "7 days ago");
260 }
261
262 #[test]
263 fn test_parse_since_weeks() {
264 assert_eq!(parse_since("4w"), "28 days ago");
265 }
266
267 #[test]
268 fn test_parse_since_months() {
269 assert_eq!(parse_since("6m"), "6 months ago");
270 }
271
272 #[test]
273 fn test_parse_since_years() {
274 assert_eq!(parse_since("1y"), "1 years ago");
275 }
276
277 #[test]
278 fn test_parse_since_date() {
279 assert_eq!(parse_since("2025-01-01"), "2025-01-01");
280 }
281
282 #[test]
283 fn test_parse_since_passthrough() {
284 assert_eq!(parse_since("3 months ago"), "3 months ago");
285 }
286
287 #[test]
288 fn test_detect_in_git_repo() {
289 let temp = tempfile::TempDir::new().unwrap();
290 Command::new("git")
291 .args(["init"])
292 .current_dir(temp.path())
293 .output()
294 .unwrap();
295 let client = GitClient::detect(temp.path());
296 assert!(client.is_ok());
297 }
298
299 #[test]
300 fn test_detect_not_git_repo() {
301 let temp = tempfile::TempDir::new().unwrap();
302 let result = GitClient::detect(temp.path());
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn test_file_churn_empty_repo() {
308 let temp = tempfile::TempDir::new().unwrap();
309 Command::new("git")
310 .args(["init"])
311 .current_dir(temp.path())
312 .output()
313 .unwrap();
314 Command::new("git")
315 .args(["config", "user.email", "test@test.com"])
316 .current_dir(temp.path())
317 .output()
318 .unwrap();
319 Command::new("git")
320 .args(["config", "user.name", "Test"])
321 .current_dir(temp.path())
322 .output()
323 .unwrap();
324 let client = GitClient::detect(temp.path()).unwrap();
325 let churns = client.file_churn("90 days ago").unwrap();
326 assert!(churns.is_empty());
327 }
328
329 #[test]
330 fn test_file_churn_with_commits() {
331 let temp = tempfile::TempDir::new().unwrap();
332 Command::new("git")
333 .args(["init"])
334 .current_dir(temp.path())
335 .output()
336 .unwrap();
337 Command::new("git")
338 .args(["config", "user.email", "test@test.com"])
339 .current_dir(temp.path())
340 .output()
341 .unwrap();
342 Command::new("git")
343 .args(["config", "user.name", "Test"])
344 .current_dir(temp.path())
345 .output()
346 .unwrap();
347
348 std::fs::write(temp.path().join("hello.rs"), "fn main() {}\n").unwrap();
349 Command::new("git")
350 .args(["add", "."])
351 .current_dir(temp.path())
352 .output()
353 .unwrap();
354 Command::new("git")
355 .args(["commit", "-m", "init"])
356 .current_dir(temp.path())
357 .output()
358 .unwrap();
359
360 std::fs::write(
361 temp.path().join("hello.rs"),
362 "fn main() {\n println!(\"hello\");\n}\n",
363 )
364 .unwrap();
365 Command::new("git")
366 .args(["add", "."])
367 .current_dir(temp.path())
368 .output()
369 .unwrap();
370 Command::new("git")
371 .args(["commit", "-m", "update"])
372 .current_dir(temp.path())
373 .output()
374 .unwrap();
375
376 let client = GitClient::detect(temp.path()).unwrap();
377 let churns = client.file_churn("90 days ago").unwrap();
378 assert_eq!(churns.len(), 1);
379 assert_eq!(churns[0].path, PathBuf::from("hello.rs"));
380 assert_eq!(churns[0].commits, 2);
381 }
382}