1use std::collections::HashSet;
2use std::path::Path;
3use std::process::Command;
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Local};
7
8use crate::model::{BranchLog, Commit, DiffStat, ProjectLog, RepoOrigin};
9use crate::period::TimeRange;
10
11pub fn default_author() -> Option<String> {
12 Command::new("git")
13 .args(["config", "--global", "user.name"])
14 .output()
15 .ok()
16 .and_then(|out| {
17 if out.status.success() {
18 String::from_utf8(out.stdout)
19 .ok()
20 .map(|s| s.trim().to_string())
21 .filter(|s| !s.is_empty())
22 } else {
23 None
24 }
25 })
26}
27
28fn list_branches(repo: &Path) -> Result<Vec<String>> {
29 let output = Command::new("git")
30 .args([
31 "-C",
32 &repo.to_string_lossy(),
33 "branch",
34 "--format=%(refname:short)",
35 ])
36 .output()
37 .context("Failed to run git branch")?;
38
39 if !output.status.success() {
40 return Ok(vec![]);
41 }
42
43 Ok(String::from_utf8_lossy(&output.stdout)
44 .lines()
45 .map(|l| l.trim().to_string())
46 .filter(|l| !l.is_empty())
47 .collect())
48}
49
50fn log_branch(
51 repo: &Path,
52 branch: &str,
53 range: &TimeRange,
54 author: Option<&str>,
55 with_stat: bool,
56) -> Result<(Vec<Commit>, Option<DiffStat>, HashSet<String>)> {
57 let since_str = range.since.to_rfc3339();
58
59 let mut args = vec![
60 "-C".to_string(),
61 repo.to_string_lossy().to_string(),
62 "log".to_string(),
63 branch.to_string(),
64 format!("--after={since_str}"),
65 "--format=%h%x00%s%x00%aI".to_string(),
66 "--no-merges".to_string(),
67 ];
68
69 if with_stat {
70 args.push("--numstat".to_string());
71 }
72
73 if let Some(until) = &range.until {
74 args.push(format!("--before={}", until.to_rfc3339()));
75 }
76
77 if let Some(author) = author {
78 args.push(format!("--author={author}"));
79 }
80
81 let output = Command::new("git")
82 .args(&args)
83 .output()
84 .context("Failed to run git log")?;
85
86 if !output.status.success() {
87 return Ok((vec![], None, HashSet::new()));
88 }
89
90 let now = Local::now();
91 let stdout = String::from_utf8_lossy(&output.stdout);
92 let (commits, branch_files) = parse_log_output(&stdout, now, with_stat);
93
94 let branch_stat = if with_stat && !commits.is_empty() {
95 let insertions: u32 = commits
96 .iter()
97 .filter_map(|c| c.diff_stat.as_ref())
98 .map(|s| s.insertions)
99 .sum();
100 let deletions: u32 = commits
101 .iter()
102 .filter_map(|c| c.diff_stat.as_ref())
103 .map(|s| s.deletions)
104 .sum();
105 Some(DiffStat {
106 files_changed: branch_files.len() as u32,
107 insertions,
108 deletions,
109 })
110 } else {
111 None
112 };
113
114 Ok((commits, branch_stat, branch_files))
115}
116
117fn parse_log_output(
118 stdout: &str,
119 now: DateTime<Local>,
120 with_stat: bool,
121) -> (Vec<Commit>, HashSet<String>) {
122 let mut commits = Vec::new();
123 let mut branch_files = HashSet::new();
124 let mut current_insertions: u32 = 0;
125 let mut current_deletions: u32 = 0;
126 let mut current_files: u32 = 0;
127
128 for line in stdout.lines() {
129 if line.is_empty() {
130 continue;
131 }
132 if line.contains('\0') {
133 if with_stat {
135 if let Some(prev) = commits.last_mut() {
136 let prev: &mut Commit = prev;
137 if current_files > 0 {
138 prev.diff_stat = Some(DiffStat {
139 files_changed: current_files,
140 insertions: current_insertions,
141 deletions: current_deletions,
142 });
143 }
144 }
145 }
146 current_insertions = 0;
147 current_deletions = 0;
148 current_files = 0;
149
150 if let Some(commit) = parse_commit_line(line, now) {
151 commits.push(commit);
152 }
153 } else if with_stat {
154 if let Some((ins, del, path)) = parse_numstat_line(line) {
155 current_insertions += ins;
156 current_deletions += del;
157 current_files += 1;
158 branch_files.insert(path);
159 }
160 }
161 }
162
163 if with_stat {
165 if let Some(last) = commits.last_mut() {
166 if current_files > 0 {
167 last.diff_stat = Some(DiffStat {
168 files_changed: current_files,
169 insertions: current_insertions,
170 deletions: current_deletions,
171 });
172 }
173 }
174 }
175
176 (commits, branch_files)
177}
178
179fn parse_numstat_line(line: &str) -> Option<(u32, u32, String)> {
180 let parts: Vec<&str> = line.split('\t').collect();
181 if parts.len() != 3 {
182 return None;
183 }
184 let ins = parts[0].parse::<u32>().unwrap_or(0);
185 let del = parts[1].parse::<u32>().unwrap_or(0);
186 Some((ins, del, parts[2].to_string()))
187}
188
189fn parse_commit_line(line: &str, now: DateTime<Local>) -> Option<Commit> {
190 let parts: Vec<&str> = line.splitn(3, '\0').collect();
191 if parts.len() != 3 {
192 return None;
193 }
194
195 let time = DateTime::parse_from_rfc3339(parts[2])
196 .ok()?
197 .with_timezone(&Local);
198
199 Some(Commit {
200 hash: parts[0].to_string(),
201 message: parts[1].to_string(),
202 commit_type: detect_commit_type(parts[1]),
203 relative_time: format_relative(now, time),
204 time,
205 url: None,
206 diff_stat: None,
207 })
208}
209
210fn detect_commit_type(message: &str) -> Option<String> {
211 let prefix = message.split([':', '(']).next()?;
212 let trimmed = prefix.trim();
213 match trimmed {
214 "feat" | "fix" | "refactor" | "docs" | "test" | "chore" | "perf" | "ci" | "build"
215 | "style" => Some(trimmed.to_string()),
216 _ => None,
217 }
218}
219
220fn format_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
221 let duration = now.signed_duration_since(then);
222 let mins = duration.num_minutes();
223
224 if mins < 1 {
225 "just now".to_string()
226 } else if mins < 60 {
227 format!("{mins}m ago")
228 } else if mins < 1440 {
229 format!("{}h ago", duration.num_hours())
230 } else {
231 format!("{}d ago", duration.num_days())
232 }
233}
234
235fn get_remote_url(repo: &Path) -> Option<String> {
236 let output = Command::new("git")
237 .args(["-C", &repo.to_string_lossy(), "remote", "get-url", "origin"])
238 .output()
239 .ok()?;
240
241 if !output.status.success() {
242 return None;
243 }
244
245 let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
246 if url.is_empty() {
247 None
248 } else {
249 Some(url)
250 }
251}
252
253fn extract_hostname(url: &str) -> Option<&str> {
254 if let Some(rest) = url.strip_prefix("git@") {
256 return rest.split(':').next();
257 }
258 if let Some(rest) = url.strip_prefix("ssh://") {
260 let after_at = rest.split('@').next_back()?;
261 return after_at
262 .split('/')
263 .next()
264 .map(|h| h.split(':').next().unwrap_or(h));
265 }
266 if url.starts_with("https://") || url.starts_with("http://") {
268 let without_scheme = url.split("://").nth(1)?;
269 let after_auth = without_scheme.split('@').next_back()?;
270 return after_auth.split('/').next();
271 }
272 None
273}
274
275fn classify_host(hostname: &str) -> RepoOrigin {
276 let lower = hostname.to_lowercase();
277 if lower == "github.com" {
278 RepoOrigin::GitHub
279 } else if lower == "gitlab.com" {
280 RepoOrigin::GitLab
281 } else if lower == "bitbucket.org" {
282 RepoOrigin::Bitbucket
283 } else if lower.contains("gitlab") {
284 RepoOrigin::GitLabSelfHosted
285 } else {
286 RepoOrigin::Custom(hostname.to_string())
287 }
288}
289
290pub fn remote_to_browser_url(raw: &str) -> Option<String> {
292 let mut url = raw.trim().to_string();
293
294 if url.starts_with("git@") {
296 url = url.replacen("git@", "https://", 1);
297 if let Some(pos) = url.find(':') {
298 let after_scheme = &url["https://".len()..];
300 if let Some(colon) = after_scheme.find(':') {
301 let abs = "https://".len() + colon;
302 url.replace_range(abs..abs + 1, "/");
303 } else {
304 url.replace_range(pos..pos + 1, "/");
305 }
306 }
307 }
308
309 if url.starts_with("ssh://") {
311 url = url.replacen("ssh://", "https://", 1);
312 if let Some(at) = url.find('@') {
313 url = format!("https://{}", &url[at + 1..]);
314 }
315 }
316
317 if url.ends_with(".git") {
318 url.truncate(url.len() - 4);
319 }
320
321 if url.starts_with("https://") || url.starts_with("http://") {
322 Some(url)
323 } else {
324 None
325 }
326}
327
328pub fn browser_url(repo: &Path) -> Option<String> {
329 let raw = get_remote_url(repo)?;
330 remote_to_browser_url(&raw)
331}
332
333pub fn detect_origin(repo: &Path) -> Option<RepoOrigin> {
334 let url = get_remote_url(repo)?;
335 let hostname = extract_hostname(&url)?;
336 Some(classify_host(hostname))
337}
338
339pub fn branch_url(remote_url: &str, origin: Option<&RepoOrigin>, branch: &str) -> String {
341 let encoded = urlencoded(branch);
342 match origin {
343 Some(RepoOrigin::GitLab | RepoOrigin::GitLabSelfHosted) => {
344 format!("{remote_url}/-/tree/{encoded}")
345 }
346 Some(RepoOrigin::Bitbucket) => {
347 format!("{remote_url}/branch/{encoded}")
348 }
349 _ => {
350 format!("{remote_url}/tree/{encoded}")
352 }
353 }
354}
355
356pub fn commit_url(remote_url: &str, origin: Option<&RepoOrigin>, hash: &str) -> String {
358 match origin {
359 Some(RepoOrigin::GitLab | RepoOrigin::GitLabSelfHosted) => {
360 format!("{remote_url}/-/commit/{hash}")
361 }
362 Some(RepoOrigin::Bitbucket) => {
363 format!("{remote_url}/commits/{hash}")
364 }
365 _ => {
366 format!("{remote_url}/commit/{hash}")
367 }
368 }
369}
370
371fn urlencoded(s: &str) -> String {
373 s.replace('%', "%25")
374 .replace(' ', "%20")
375 .replace('#', "%23")
376 .replace('?', "%3F")
377}
378
379pub fn collect_project_log(
380 repo: &Path,
381 range: &TimeRange,
382 author: Option<&str>,
383 with_stat: bool,
384) -> Option<ProjectLog> {
385 let project_name = repo.file_name()?.to_string_lossy().to_string();
386 let branches = list_branches(repo).ok()?;
387 let origin = detect_origin(repo);
388 let remote = browser_url(repo);
389
390 let mut project_files: HashSet<String> = HashSet::new();
391 let mut project_insertions: u32 = 0;
392 let mut project_deletions: u32 = 0;
393
394 let mut branch_logs: Vec<BranchLog> = branches
395 .into_iter()
396 .filter_map(|branch_name| {
397 let (mut commits, branch_stat, branch_file_set) =
398 log_branch(repo, &branch_name, range, author, with_stat).ok()?;
399 if commits.is_empty() {
400 None
401 } else {
402 if let Some(base) = &remote {
403 for c in &mut commits {
404 c.url = Some(commit_url(base, origin.as_ref(), &c.hash));
405 }
406 }
407 let b_url = remote
408 .as_deref()
409 .map(|base| branch_url(base, origin.as_ref(), &branch_name));
410
411 if let Some(stat) = &branch_stat {
412 project_insertions += stat.insertions;
413 project_deletions += stat.deletions;
414 project_files.extend(branch_file_set);
415 }
416
417 Some(BranchLog {
418 name: branch_name,
419 url: b_url,
420 commits,
421 diff_stat: branch_stat,
422 })
423 }
424 })
425 .collect();
426
427 if branch_logs.is_empty() {
428 return None;
429 }
430
431 branch_logs.sort_by(|a, b| {
432 let a_primary = is_primary_branch(&a.name);
433 let b_primary = is_primary_branch(&b.name);
434 b_primary.cmp(&a_primary).then_with(|| a.name.cmp(&b.name))
435 });
436
437 let project_stat = if with_stat {
438 Some(DiffStat {
439 files_changed: project_files.len() as u32,
440 insertions: project_insertions,
441 deletions: project_deletions,
442 })
443 } else {
444 None
445 };
446
447 Some(ProjectLog {
448 project: project_name,
449 path: repo.to_string_lossy().to_string(),
450 origin,
451 remote_url: remote,
452 branches: branch_logs,
453 diff_stat: project_stat,
454 })
455}
456
457fn is_primary_branch(name: &str) -> bool {
458 matches!(name, "main" | "master")
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use chrono::Duration;
465
466 #[test]
467 fn format_relative_just_now() {
468 let now = Local::now();
469 assert_eq!(format_relative(now, now), "just now");
470 }
471
472 #[test]
473 fn format_relative_minutes() {
474 let now = Local::now();
475 let then = now - Duration::minutes(5);
476 assert_eq!(format_relative(now, then), "5m ago");
477 }
478
479 #[test]
480 fn format_relative_hours() {
481 let now = Local::now();
482 let then = now - Duration::hours(3);
483 assert_eq!(format_relative(now, then), "3h ago");
484 }
485
486 #[test]
487 fn format_relative_days() {
488 let now = Local::now();
489 let then = now - Duration::days(2);
490 assert_eq!(format_relative(now, then), "2d ago");
491 }
492
493 #[test]
494 fn detect_feat() {
495 assert_eq!(
496 detect_commit_type("feat: add spinner"),
497 Some("feat".to_string())
498 );
499 }
500
501 #[test]
502 fn detect_fix() {
503 assert_eq!(
504 detect_commit_type("fix: off-by-one error"),
505 Some("fix".to_string())
506 );
507 }
508
509 #[test]
510 fn detect_scoped() {
511 assert_eq!(
512 detect_commit_type("feat(auth): add OAuth"),
513 Some("feat".to_string())
514 );
515 }
516
517 #[test]
518 fn detect_none_for_regular_message() {
519 assert_eq!(detect_commit_type("update README"), None);
520 }
521
522 #[test]
523 fn detect_none_for_empty() {
524 assert_eq!(detect_commit_type(""), None);
525 }
526
527 #[test]
528 fn parse_commit_line_valid() {
529 let now = Local::now();
530 let time_str = now.to_rfc3339();
531 let line = format!("abc1234\x00feat: add feature\x00{time_str}");
532 let commit = parse_commit_line(&line, now);
533 assert!(commit.is_some());
534 let c = commit.unwrap_or_else(|| panic!("Expected Some"));
535 assert_eq!(c.hash, "abc1234");
536 assert_eq!(c.message, "feat: add feature");
537 assert_eq!(c.commit_type, Some("feat".to_string()));
538 }
539
540 #[test]
541 fn parse_commit_line_invalid() {
542 let now = Local::now();
543 assert!(parse_commit_line("incomplete line", now).is_none());
544 }
545
546 #[test]
547 fn primary_branch_detected() {
548 assert!(is_primary_branch("main"));
549 assert!(is_primary_branch("master"));
550 assert!(!is_primary_branch("feature/auth"));
551 assert!(!is_primary_branch("develop"));
552 }
553
554 #[test]
555 fn extract_hostname_https() {
556 assert_eq!(
557 extract_hostname("https://github.com/user/repo.git"),
558 Some("github.com")
559 );
560 assert_eq!(
561 extract_hostname("https://gitlab.com/group/project"),
562 Some("gitlab.com")
563 );
564 }
565
566 #[test]
567 fn extract_hostname_http() {
568 assert_eq!(
569 extract_hostname("http://gitea.local/org/repo"),
570 Some("gitea.local")
571 );
572 }
573
574 #[test]
575 fn extract_hostname_ssh_git_at() {
576 assert_eq!(
577 extract_hostname("git@github.com:user/repo.git"),
578 Some("github.com")
579 );
580 assert_eq!(
581 extract_hostname("git@gitlab.company.de:group/project.git"),
582 Some("gitlab.company.de")
583 );
584 }
585
586 #[test]
587 fn extract_hostname_ssh_scheme() {
588 assert_eq!(
589 extract_hostname("ssh://git@bitbucket.org/team/repo.git"),
590 Some("bitbucket.org")
591 );
592 assert_eq!(
593 extract_hostname("ssh://git@gitlab.internal:2222/group/repo.git"),
594 Some("gitlab.internal")
595 );
596 }
597
598 #[test]
599 fn extract_hostname_https_with_auth() {
600 assert_eq!(
601 extract_hostname("https://token@github.com/user/repo.git"),
602 Some("github.com")
603 );
604 }
605
606 #[test]
607 fn extract_hostname_empty() {
608 assert_eq!(extract_hostname(""), None);
609 assert_eq!(extract_hostname("not-a-url"), None);
610 }
611
612 #[test]
613 fn classify_github() {
614 assert_eq!(classify_host("github.com"), RepoOrigin::GitHub);
615 assert_eq!(classify_host("GitHub.com"), RepoOrigin::GitHub);
616 }
617
618 #[test]
619 fn classify_gitlab() {
620 assert_eq!(classify_host("gitlab.com"), RepoOrigin::GitLab);
621 }
622
623 #[test]
624 fn classify_bitbucket() {
625 assert_eq!(classify_host("bitbucket.org"), RepoOrigin::Bitbucket);
626 }
627
628 #[test]
629 fn classify_gitlab_self_hosted() {
630 assert_eq!(
631 classify_host("gitlab.company.de"),
632 RepoOrigin::GitLabSelfHosted
633 );
634 assert_eq!(
635 classify_host("gitlab.internal"),
636 RepoOrigin::GitLabSelfHosted
637 );
638 }
639
640 #[test]
641 fn classify_custom() {
642 assert_eq!(
643 classify_host("gitea.local"),
644 RepoOrigin::Custom("gitea.local".to_string())
645 );
646 assert_eq!(
647 classify_host("codeberg.org"),
648 RepoOrigin::Custom("codeberg.org".to_string())
649 );
650 }
651
652 #[test]
653 fn branch_url_github() {
654 let url = branch_url(
655 "https://github.com/user/repo",
656 Some(&RepoOrigin::GitHub),
657 "main",
658 );
659 assert_eq!(url, "https://github.com/user/repo/tree/main");
660 }
661
662 #[test]
663 fn branch_url_github_with_slash() {
664 let url = branch_url(
665 "https://github.com/user/repo",
666 Some(&RepoOrigin::GitHub),
667 "feature/auth",
668 );
669 assert_eq!(url, "https://github.com/user/repo/tree/feature/auth");
670 }
671
672 #[test]
673 fn branch_url_gitlab() {
674 let url = branch_url(
675 "https://gitlab.com/group/project",
676 Some(&RepoOrigin::GitLab),
677 "develop",
678 );
679 assert_eq!(url, "https://gitlab.com/group/project/-/tree/develop");
680 }
681
682 #[test]
683 fn branch_url_gitlab_self_hosted() {
684 let url = branch_url(
685 "https://gitlab.company.de/team/repo",
686 Some(&RepoOrigin::GitLabSelfHosted),
687 "main",
688 );
689 assert_eq!(url, "https://gitlab.company.de/team/repo/-/tree/main");
690 }
691
692 #[test]
693 fn branch_url_bitbucket() {
694 let url = branch_url(
695 "https://bitbucket.org/team/repo",
696 Some(&RepoOrigin::Bitbucket),
697 "main",
698 );
699 assert_eq!(url, "https://bitbucket.org/team/repo/branch/main");
700 }
701
702 #[test]
703 fn branch_url_no_origin_defaults_to_tree() {
704 let url = branch_url("https://gitea.local/org/repo", None, "main");
705 assert_eq!(url, "https://gitea.local/org/repo/tree/main");
706 }
707
708 #[test]
709 fn commit_url_github() {
710 let url = commit_url(
711 "https://github.com/user/repo",
712 Some(&RepoOrigin::GitHub),
713 "abc1234",
714 );
715 assert_eq!(url, "https://github.com/user/repo/commit/abc1234");
716 }
717
718 #[test]
719 fn commit_url_gitlab() {
720 let url = commit_url(
721 "https://gitlab.com/group/project",
722 Some(&RepoOrigin::GitLab),
723 "abc1234",
724 );
725 assert_eq!(url, "https://gitlab.com/group/project/-/commit/abc1234");
726 }
727
728 #[test]
729 fn commit_url_bitbucket() {
730 let url = commit_url(
731 "https://bitbucket.org/team/repo",
732 Some(&RepoOrigin::Bitbucket),
733 "abc1234",
734 );
735 assert_eq!(url, "https://bitbucket.org/team/repo/commits/abc1234");
736 }
737
738 #[test]
739 fn commit_url_no_origin_defaults_to_commit() {
740 let url = commit_url("https://gitea.local/org/repo", None, "abc1234");
741 assert_eq!(url, "https://gitea.local/org/repo/commit/abc1234");
742 }
743
744 #[test]
745 fn urlencoded_special_chars() {
746 assert_eq!(urlencoded("feature/auth"), "feature/auth");
747 assert_eq!(urlencoded("my branch"), "my%20branch");
748 assert_eq!(urlencoded("fix#123"), "fix%23123");
749 }
750
751 #[test]
752 fn parse_numstat_line_normal() {
753 let result = parse_numstat_line("3\t1\tsrc/foo.rs");
754 assert_eq!(result, Some((3, 1, "src/foo.rs".to_string())));
755 }
756
757 #[test]
758 fn parse_numstat_line_binary() {
759 let result = parse_numstat_line("-\t-\timage.png");
760 assert_eq!(result, Some((0, 0, "image.png".to_string())));
761 }
762
763 #[test]
764 fn parse_numstat_line_invalid() {
765 assert!(parse_numstat_line("not a numstat line").is_none());
766 assert!(parse_numstat_line("").is_none());
767 }
768
769 #[test]
770 fn parse_log_output_with_stat() {
771 let now = Local::now();
772 let ts = now.to_rfc3339();
773 let input = format!(
774 "abc1234\x00feat: add feature\x00{ts}\n\
775 3\t1\tsrc/main.rs\n\
776 10\t0\tsrc/lib.rs\n\
777 \n\
778 def5678\x00fix: bug\x00{ts}\n\
779 2\t5\tsrc/main.rs\n"
780 );
781 let (commits, files) = parse_log_output(&input, now, true);
782 assert_eq!(commits.len(), 2);
783
784 let s0 = commits[0]
785 .diff_stat
786 .as_ref()
787 .expect("commit 0 should have diff_stat");
788 assert_eq!(s0.insertions, 13);
789 assert_eq!(s0.deletions, 1);
790 assert_eq!(s0.files_changed, 2);
791
792 let s1 = commits[1]
793 .diff_stat
794 .as_ref()
795 .expect("commit 1 should have diff_stat");
796 assert_eq!(s1.insertions, 2);
797 assert_eq!(s1.deletions, 5);
798 assert_eq!(s1.files_changed, 1);
799
800 assert_eq!(files.len(), 2);
802 assert!(files.contains("src/main.rs"));
803 assert!(files.contains("src/lib.rs"));
804 }
805
806 #[test]
807 fn parse_log_output_without_stat() {
808 let now = Local::now();
809 let ts = now.to_rfc3339();
810 let input = format!("abc1234\x00feat: add feature\x00{ts}\n");
811 let (commits, files) = parse_log_output(&input, now, false);
812 assert_eq!(commits.len(), 1);
813 assert!(commits[0].diff_stat.is_none());
814 assert!(files.is_empty());
815 }
816}