1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use chrono::{DateTime, Local};
6
7use crate::model::{BranchLog, Commit, ProjectLog, RepoOrigin};
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
137fn get_remote_url(repo: &Path) -> Option<String> {
138 let output = Command::new("git")
139 .args(["-C", &repo.to_string_lossy(), "remote", "get-url", "origin"])
140 .output()
141 .ok()?;
142
143 if !output.status.success() {
144 return None;
145 }
146
147 let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
148 if url.is_empty() {
149 None
150 } else {
151 Some(url)
152 }
153}
154
155fn extract_hostname(url: &str) -> Option<&str> {
156 if let Some(rest) = url.strip_prefix("git@") {
158 return rest.split(':').next();
159 }
160 if let Some(rest) = url.strip_prefix("ssh://") {
162 let after_at = rest.split('@').next_back()?;
163 return after_at
164 .split('/')
165 .next()
166 .map(|h| h.split(':').next().unwrap_or(h));
167 }
168 if url.starts_with("https://") || url.starts_with("http://") {
170 let without_scheme = url.split("://").nth(1)?;
171 let after_auth = without_scheme.split('@').next_back()?;
172 return after_auth.split('/').next();
173 }
174 None
175}
176
177fn classify_host(hostname: &str) -> RepoOrigin {
178 let lower = hostname.to_lowercase();
179 if lower == "github.com" {
180 RepoOrigin::GitHub
181 } else if lower == "gitlab.com" {
182 RepoOrigin::GitLab
183 } else if lower == "bitbucket.org" {
184 RepoOrigin::Bitbucket
185 } else if lower.contains("gitlab") {
186 RepoOrigin::GitLabSelfHosted
187 } else {
188 RepoOrigin::Custom(hostname.to_string())
189 }
190}
191
192pub fn remote_to_browser_url(raw: &str) -> Option<String> {
194 let mut url = raw.trim().to_string();
195
196 if url.starts_with("git@") {
198 url = url.replacen("git@", "https://", 1);
199 if let Some(pos) = url.find(':') {
200 let after_scheme = &url["https://".len()..];
202 if let Some(colon) = after_scheme.find(':') {
203 let abs = "https://".len() + colon;
204 url.replace_range(abs..abs + 1, "/");
205 } else {
206 url.replace_range(pos..pos + 1, "/");
207 }
208 }
209 }
210
211 if url.starts_with("ssh://") {
213 url = url.replacen("ssh://", "https://", 1);
214 if let Some(at) = url.find('@') {
215 url = format!("https://{}", &url[at + 1..]);
216 }
217 }
218
219 if url.ends_with(".git") {
220 url.truncate(url.len() - 4);
221 }
222
223 if url.starts_with("https://") || url.starts_with("http://") {
224 Some(url)
225 } else {
226 None
227 }
228}
229
230pub fn browser_url(repo: &Path) -> Option<String> {
231 let raw = get_remote_url(repo)?;
232 remote_to_browser_url(&raw)
233}
234
235pub fn detect_origin(repo: &Path) -> Option<RepoOrigin> {
236 let url = get_remote_url(repo)?;
237 let hostname = extract_hostname(&url)?;
238 Some(classify_host(hostname))
239}
240
241pub fn collect_project_log(
242 repo: &Path,
243 range: &TimeRange,
244 author: Option<&str>,
245) -> Option<ProjectLog> {
246 let project_name = repo.file_name()?.to_string_lossy().to_string();
247 let branches = list_branches(repo).ok()?;
248
249 let mut branch_logs: Vec<BranchLog> = branches
250 .into_iter()
251 .filter_map(|branch_name| {
252 let commits = log_branch(repo, &branch_name, range, author).ok()?;
253 if commits.is_empty() {
254 None
255 } else {
256 Some(BranchLog {
257 name: branch_name,
258 commits,
259 })
260 }
261 })
262 .collect();
263
264 if branch_logs.is_empty() {
265 return None;
266 }
267
268 branch_logs.sort_by(|a, b| {
269 let a_primary = is_primary_branch(&a.name);
270 let b_primary = is_primary_branch(&b.name);
271 b_primary.cmp(&a_primary).then_with(|| a.name.cmp(&b.name))
272 });
273
274 Some(ProjectLog {
275 project: project_name,
276 path: repo.to_string_lossy().to_string(),
277 origin: detect_origin(repo),
278 remote_url: browser_url(repo),
279 branches: branch_logs,
280 })
281}
282
283fn is_primary_branch(name: &str) -> bool {
284 matches!(name, "main" | "master")
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use chrono::Duration;
291
292 #[test]
293 fn format_relative_just_now() {
294 let now = Local::now();
295 assert_eq!(format_relative(now, now), "just now");
296 }
297
298 #[test]
299 fn format_relative_minutes() {
300 let now = Local::now();
301 let then = now - Duration::minutes(5);
302 assert_eq!(format_relative(now, then), "5m ago");
303 }
304
305 #[test]
306 fn format_relative_hours() {
307 let now = Local::now();
308 let then = now - Duration::hours(3);
309 assert_eq!(format_relative(now, then), "3h ago");
310 }
311
312 #[test]
313 fn format_relative_days() {
314 let now = Local::now();
315 let then = now - Duration::days(2);
316 assert_eq!(format_relative(now, then), "2d ago");
317 }
318
319 #[test]
320 fn detect_feat() {
321 assert_eq!(
322 detect_commit_type("feat: add spinner"),
323 Some("feat".to_string())
324 );
325 }
326
327 #[test]
328 fn detect_fix() {
329 assert_eq!(
330 detect_commit_type("fix: off-by-one error"),
331 Some("fix".to_string())
332 );
333 }
334
335 #[test]
336 fn detect_scoped() {
337 assert_eq!(
338 detect_commit_type("feat(auth): add OAuth"),
339 Some("feat".to_string())
340 );
341 }
342
343 #[test]
344 fn detect_none_for_regular_message() {
345 assert_eq!(detect_commit_type("update README"), None);
346 }
347
348 #[test]
349 fn detect_none_for_empty() {
350 assert_eq!(detect_commit_type(""), None);
351 }
352
353 #[test]
354 fn parse_commit_line_valid() {
355 let now = Local::now();
356 let time_str = now.to_rfc3339();
357 let line = format!("abc1234\x00feat: add feature\x00{time_str}");
358 let commit = parse_commit_line(&line, now);
359 assert!(commit.is_some());
360 let c = commit.unwrap_or_else(|| panic!("Expected Some"));
361 assert_eq!(c.hash, "abc1234");
362 assert_eq!(c.message, "feat: add feature");
363 assert_eq!(c.commit_type, Some("feat".to_string()));
364 }
365
366 #[test]
367 fn parse_commit_line_invalid() {
368 let now = Local::now();
369 assert!(parse_commit_line("incomplete line", now).is_none());
370 }
371
372 #[test]
373 fn primary_branch_detected() {
374 assert!(is_primary_branch("main"));
375 assert!(is_primary_branch("master"));
376 assert!(!is_primary_branch("feature/auth"));
377 assert!(!is_primary_branch("develop"));
378 }
379
380 #[test]
381 fn extract_hostname_https() {
382 assert_eq!(
383 extract_hostname("https://github.com/user/repo.git"),
384 Some("github.com")
385 );
386 assert_eq!(
387 extract_hostname("https://gitlab.com/group/project"),
388 Some("gitlab.com")
389 );
390 }
391
392 #[test]
393 fn extract_hostname_http() {
394 assert_eq!(
395 extract_hostname("http://gitea.local/org/repo"),
396 Some("gitea.local")
397 );
398 }
399
400 #[test]
401 fn extract_hostname_ssh_git_at() {
402 assert_eq!(
403 extract_hostname("git@github.com:user/repo.git"),
404 Some("github.com")
405 );
406 assert_eq!(
407 extract_hostname("git@gitlab.company.de:group/project.git"),
408 Some("gitlab.company.de")
409 );
410 }
411
412 #[test]
413 fn extract_hostname_ssh_scheme() {
414 assert_eq!(
415 extract_hostname("ssh://git@bitbucket.org/team/repo.git"),
416 Some("bitbucket.org")
417 );
418 assert_eq!(
419 extract_hostname("ssh://git@gitlab.internal:2222/group/repo.git"),
420 Some("gitlab.internal")
421 );
422 }
423
424 #[test]
425 fn extract_hostname_https_with_auth() {
426 assert_eq!(
427 extract_hostname("https://token@github.com/user/repo.git"),
428 Some("github.com")
429 );
430 }
431
432 #[test]
433 fn extract_hostname_empty() {
434 assert_eq!(extract_hostname(""), None);
435 assert_eq!(extract_hostname("not-a-url"), None);
436 }
437
438 #[test]
439 fn classify_github() {
440 assert_eq!(classify_host("github.com"), RepoOrigin::GitHub);
441 assert_eq!(classify_host("GitHub.com"), RepoOrigin::GitHub);
442 }
443
444 #[test]
445 fn classify_gitlab() {
446 assert_eq!(classify_host("gitlab.com"), RepoOrigin::GitLab);
447 }
448
449 #[test]
450 fn classify_bitbucket() {
451 assert_eq!(classify_host("bitbucket.org"), RepoOrigin::Bitbucket);
452 }
453
454 #[test]
455 fn classify_gitlab_self_hosted() {
456 assert_eq!(
457 classify_host("gitlab.company.de"),
458 RepoOrigin::GitLabSelfHosted
459 );
460 assert_eq!(
461 classify_host("gitlab.internal"),
462 RepoOrigin::GitLabSelfHosted
463 );
464 }
465
466 #[test]
467 fn classify_custom() {
468 assert_eq!(
469 classify_host("gitea.local"),
470 RepoOrigin::Custom("gitea.local".to_string())
471 );
472 assert_eq!(
473 classify_host("codeberg.org"),
474 RepoOrigin::Custom("codeberg.org".to_string())
475 );
476 }
477}