1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use chrono::{DateTime, Local};
6
7use crate::model::{BranchLog, Commit, ProjectLog};
8use crate::period::TimeRange;
9
10pub fn default_author() -> Option<String> {
11 Command::new("git")
12 .args(["config", "--global", "user.name"])
13 .output()
14 .ok()
15 .and_then(|out| {
16 if out.status.success() {
17 String::from_utf8(out.stdout)
18 .ok()
19 .map(|s| s.trim().to_string())
20 .filter(|s| !s.is_empty())
21 } else {
22 None
23 }
24 })
25}
26
27fn list_branches(repo: &Path) -> Result<Vec<String>> {
28 let output = Command::new("git")
29 .args([
30 "-C",
31 &repo.to_string_lossy(),
32 "branch",
33 "--format=%(refname:short)",
34 ])
35 .output()
36 .context("Failed to run git branch")?;
37
38 if !output.status.success() {
39 return Ok(vec![]);
40 }
41
42 Ok(String::from_utf8_lossy(&output.stdout)
43 .lines()
44 .map(|l| l.trim().to_string())
45 .filter(|l| !l.is_empty())
46 .collect())
47}
48
49fn log_branch(
50 repo: &Path,
51 branch: &str,
52 range: &TimeRange,
53 author: Option<&str>,
54) -> Result<Vec<Commit>> {
55 let since_str = range.since.to_rfc3339();
56
57 let mut args = vec![
58 "-C".to_string(),
59 repo.to_string_lossy().to_string(),
60 "log".to_string(),
61 branch.to_string(),
62 format!("--after={since_str}"),
63 "--format=%h%x00%s%x00%aI".to_string(),
64 "--no-merges".to_string(),
65 ];
66
67 if let Some(until) = &range.until {
68 args.push(format!("--before={}", until.to_rfc3339()));
69 }
70
71 if let Some(author) = author {
72 args.push(format!("--author={author}"));
73 }
74
75 let output = Command::new("git")
76 .args(&args)
77 .output()
78 .context("Failed to run git log")?;
79
80 if !output.status.success() {
81 return Ok(vec![]);
82 }
83
84 let now = Local::now();
85
86 Ok(String::from_utf8_lossy(&output.stdout)
87 .lines()
88 .filter(|l| !l.is_empty())
89 .filter_map(|line| parse_commit_line(line, now))
90 .collect())
91}
92
93fn parse_commit_line(line: &str, now: DateTime<Local>) -> Option<Commit> {
94 let parts: Vec<&str> = line.splitn(3, '\0').collect();
95 if parts.len() != 3 {
96 return None;
97 }
98
99 let time = DateTime::parse_from_rfc3339(parts[2])
100 .ok()?
101 .with_timezone(&Local);
102
103 Some(Commit {
104 hash: parts[0].to_string(),
105 message: parts[1].to_string(),
106 commit_type: detect_commit_type(parts[1]),
107 relative_time: format_relative(now, time),
108 time,
109 })
110}
111
112fn detect_commit_type(message: &str) -> Option<String> {
113 let prefix = message.split([':', '(']).next()?;
114 let trimmed = prefix.trim();
115 match trimmed {
116 "feat" | "fix" | "refactor" | "docs" | "test" | "chore" | "perf" | "ci" | "build"
117 | "style" => Some(trimmed.to_string()),
118 _ => None,
119 }
120}
121
122fn format_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
123 let duration = now.signed_duration_since(then);
124 let mins = duration.num_minutes();
125
126 if mins < 1 {
127 "just now".to_string()
128 } else if mins < 60 {
129 format!("{mins}m ago")
130 } else if mins < 1440 {
131 format!("{}h ago", duration.num_hours())
132 } else {
133 format!("{}d ago", duration.num_days())
134 }
135}
136
137pub fn collect_project_log(
138 repo: &Path,
139 range: &TimeRange,
140 author: Option<&str>,
141) -> Option<ProjectLog> {
142 let project_name = repo.file_name()?.to_string_lossy().to_string();
143 let branches = list_branches(repo).ok()?;
144
145 let mut branch_logs: Vec<BranchLog> = branches
146 .into_iter()
147 .filter_map(|branch_name| {
148 let commits = log_branch(repo, &branch_name, range, author).ok()?;
149 if commits.is_empty() {
150 None
151 } else {
152 Some(BranchLog {
153 name: branch_name,
154 commits,
155 })
156 }
157 })
158 .collect();
159
160 if branch_logs.is_empty() {
161 return None;
162 }
163
164 branch_logs.sort_by(|a, b| {
165 let a_primary = is_primary_branch(&a.name);
166 let b_primary = is_primary_branch(&b.name);
167 b_primary.cmp(&a_primary).then_with(|| a.name.cmp(&b.name))
168 });
169
170 Some(ProjectLog {
171 project: project_name,
172 path: repo.to_string_lossy().to_string(),
173 branches: branch_logs,
174 })
175}
176
177fn is_primary_branch(name: &str) -> bool {
178 matches!(name, "main" | "master")
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use chrono::Duration;
185
186 #[test]
187 fn format_relative_just_now() {
188 let now = Local::now();
189 assert_eq!(format_relative(now, now), "just now");
190 }
191
192 #[test]
193 fn format_relative_minutes() {
194 let now = Local::now();
195 let then = now - Duration::minutes(5);
196 assert_eq!(format_relative(now, then), "5m ago");
197 }
198
199 #[test]
200 fn format_relative_hours() {
201 let now = Local::now();
202 let then = now - Duration::hours(3);
203 assert_eq!(format_relative(now, then), "3h ago");
204 }
205
206 #[test]
207 fn format_relative_days() {
208 let now = Local::now();
209 let then = now - Duration::days(2);
210 assert_eq!(format_relative(now, then), "2d ago");
211 }
212
213 #[test]
214 fn detect_feat() {
215 assert_eq!(
216 detect_commit_type("feat: add spinner"),
217 Some("feat".to_string())
218 );
219 }
220
221 #[test]
222 fn detect_fix() {
223 assert_eq!(
224 detect_commit_type("fix: off-by-one error"),
225 Some("fix".to_string())
226 );
227 }
228
229 #[test]
230 fn detect_scoped() {
231 assert_eq!(
232 detect_commit_type("feat(auth): add OAuth"),
233 Some("feat".to_string())
234 );
235 }
236
237 #[test]
238 fn detect_none_for_regular_message() {
239 assert_eq!(detect_commit_type("update README"), None);
240 }
241
242 #[test]
243 fn detect_none_for_empty() {
244 assert_eq!(detect_commit_type(""), None);
245 }
246
247 #[test]
248 fn parse_commit_line_valid() {
249 let now = Local::now();
250 let time_str = now.to_rfc3339();
251 let line = format!("abc1234\x00feat: add feature\x00{time_str}");
252 let commit = parse_commit_line(&line, now);
253 assert!(commit.is_some());
254 let c = commit.unwrap_or_else(|| panic!("Expected Some"));
255 assert_eq!(c.hash, "abc1234");
256 assert_eq!(c.message, "feat: add feature");
257 assert_eq!(c.commit_type, Some("feat".to_string()));
258 }
259
260 #[test]
261 fn parse_commit_line_invalid() {
262 let now = Local::now();
263 assert!(parse_commit_line("incomplete line", now).is_none());
264 }
265
266 #[test]
267 fn primary_branch_detected() {
268 assert!(is_primary_branch("main"));
269 assert!(is_primary_branch("master"));
270 assert!(!is_primary_branch("feature/auth"));
271 assert!(!is_primary_branch("develop"));
272 }
273}