1use rustc_hash::FxHashMap;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use serde::Serialize;
11
12const SECS_PER_DAY: f64 = 86_400.0;
14
15const HALF_LIFE_DAYS: f64 = 90.0;
18
19#[derive(Debug, Clone)]
21pub struct SinceDuration {
22 pub git_after: String,
24 pub display: String,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, bitcode::Encode, bitcode::Decode)]
30#[serde(rename_all = "snake_case")]
31pub enum ChurnTrend {
32 Accelerating,
34 Stable,
36 Cooling,
38}
39
40impl std::fmt::Display for ChurnTrend {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Self::Accelerating => write!(f, "accelerating"),
44 Self::Stable => write!(f, "stable"),
45 Self::Cooling => write!(f, "cooling"),
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
52pub struct FileChurn {
53 pub path: PathBuf,
55 pub commits: u32,
57 pub weighted_commits: f64,
59 pub lines_added: u32,
61 pub lines_deleted: u32,
63 pub trend: ChurnTrend,
65}
66
67pub struct ChurnResult {
69 pub files: FxHashMap<PathBuf, FileChurn>,
71 pub shallow_clone: bool,
73}
74
75pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
86 if is_iso_date(input) {
88 return Ok(SinceDuration {
89 git_after: input.to_string(),
90 display: input.to_string(),
91 });
92 }
93
94 let (num_str, unit) = split_number_unit(input)?;
96 let num: u64 = num_str
97 .parse()
98 .map_err(|_| format!("invalid number in --since: {input}"))?;
99
100 if num == 0 {
101 return Err("--since duration must be greater than 0".to_string());
102 }
103
104 match unit {
105 "d" | "day" | "days" => {
106 let s = if num == 1 { "" } else { "s" };
107 Ok(SinceDuration {
108 git_after: format!("{num} day{s} ago"),
109 display: format!("{num} day{s}"),
110 })
111 }
112 "w" | "week" | "weeks" => {
113 let s = if num == 1 { "" } else { "s" };
114 Ok(SinceDuration {
115 git_after: format!("{num} week{s} ago"),
116 display: format!("{num} week{s}"),
117 })
118 }
119 "m" | "month" | "months" => {
120 let s = if num == 1 { "" } else { "s" };
121 Ok(SinceDuration {
122 git_after: format!("{num} month{s} ago"),
123 display: format!("{num} month{s}"),
124 })
125 }
126 "y" | "year" | "years" => {
127 let s = if num == 1 { "" } else { "s" };
128 Ok(SinceDuration {
129 git_after: format!("{num} year{s} ago"),
130 display: format!("{num} year{s}"),
131 })
132 }
133 _ => Err(format!(
134 "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
135 )),
136 }
137}
138
139pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
143 let shallow = is_shallow_clone(root);
144
145 let output = Command::new("git")
146 .args([
147 "log",
148 "--numstat",
149 "--no-merges",
150 "--no-renames",
151 "--format=format:%at",
152 &format!("--after={}", since.git_after),
153 ])
154 .current_dir(root)
155 .output();
156
157 let output = match output {
158 Ok(o) => o,
159 Err(e) => {
160 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
161 return None;
162 }
163 };
164
165 if !output.status.success() {
166 let stderr = String::from_utf8_lossy(&output.stderr);
167 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
168 return None;
169 }
170
171 let stdout = String::from_utf8_lossy(&output.stdout);
172 let files = parse_git_log(&stdout, root);
173
174 Some(ChurnResult {
175 files,
176 shallow_clone: shallow,
177 })
178}
179
180#[must_use]
182pub fn is_shallow_clone(root: &Path) -> bool {
183 Command::new("git")
184 .args(["rev-parse", "--is-shallow-repository"])
185 .current_dir(root)
186 .output()
187 .map(|o| {
188 String::from_utf8_lossy(&o.stdout)
189 .trim()
190 .eq_ignore_ascii_case("true")
191 })
192 .unwrap_or(false)
193}
194
195#[must_use]
197pub fn is_git_repo(root: &Path) -> bool {
198 Command::new("git")
199 .args(["rev-parse", "--git-dir"])
200 .current_dir(root)
201 .stdout(std::process::Stdio::null())
202 .stderr(std::process::Stdio::null())
203 .status()
204 .map(|s| s.success())
205 .unwrap_or(false)
206}
207
208const MAX_CHURN_CACHE_SIZE: usize = 16 * 1024 * 1024;
212
213#[derive(bitcode::Encode, bitcode::Decode)]
215struct CachedFileChurn {
216 path: String,
217 commits: u32,
218 weighted_commits: f64,
219 lines_added: u32,
220 lines_deleted: u32,
221 trend: ChurnTrend,
222}
223
224#[derive(bitcode::Encode, bitcode::Decode)]
226struct ChurnCache {
227 head_sha: String,
228 git_after: String,
229 files: Vec<CachedFileChurn>,
230 shallow_clone: bool,
231}
232
233fn get_head_sha(root: &Path) -> Option<String> {
235 Command::new("git")
236 .args(["rev-parse", "HEAD"])
237 .current_dir(root)
238 .output()
239 .ok()
240 .filter(|o| o.status.success())
241 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
242}
243
244fn load_churn_cache(cache_dir: &Path, head_sha: &str, git_after: &str) -> Option<ChurnResult> {
246 let cache_file = cache_dir.join("churn.bin");
247 let data = std::fs::read(&cache_file).ok()?;
248 if data.len() > MAX_CHURN_CACHE_SIZE {
249 return None;
250 }
251 let cache: ChurnCache = bitcode::decode(&data).ok()?;
252 if cache.head_sha != head_sha || cache.git_after != git_after {
253 return None;
254 }
255 let mut files = FxHashMap::default();
256 for entry in cache.files {
257 let path = PathBuf::from(&entry.path);
258 files.insert(
259 path.clone(),
260 FileChurn {
261 path,
262 commits: entry.commits,
263 weighted_commits: entry.weighted_commits,
264 lines_added: entry.lines_added,
265 lines_deleted: entry.lines_deleted,
266 trend: entry.trend,
267 },
268 );
269 }
270 Some(ChurnResult {
271 files,
272 shallow_clone: cache.shallow_clone,
273 })
274}
275
276fn save_churn_cache(cache_dir: &Path, head_sha: &str, git_after: &str, result: &ChurnResult) {
278 let files: Vec<CachedFileChurn> = result
279 .files
280 .values()
281 .map(|f| CachedFileChurn {
282 path: f.path.to_string_lossy().to_string(),
283 commits: f.commits,
284 weighted_commits: f.weighted_commits,
285 lines_added: f.lines_added,
286 lines_deleted: f.lines_deleted,
287 trend: f.trend,
288 })
289 .collect();
290 let cache = ChurnCache {
291 head_sha: head_sha.to_string(),
292 git_after: git_after.to_string(),
293 files,
294 shallow_clone: result.shallow_clone,
295 };
296 let _ = std::fs::create_dir_all(cache_dir);
297 let data = bitcode::encode(&cache);
298 let tmp = cache_dir.join("churn.bin.tmp");
300 if std::fs::write(&tmp, data).is_ok() {
301 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
302 }
303}
304
305pub fn analyze_churn_cached(
311 root: &Path,
312 since: &SinceDuration,
313 cache_dir: &Path,
314 no_cache: bool,
315) -> Option<(ChurnResult, bool)> {
316 let head_sha = get_head_sha(root)?;
317
318 if !no_cache && let Some(cached) = load_churn_cache(cache_dir, &head_sha, &since.git_after) {
319 return Some((cached, true));
320 }
321
322 let result = analyze_churn(root, since)?;
323
324 if !no_cache {
325 save_churn_cache(cache_dir, &head_sha, &since.git_after, &result);
326 }
327
328 Some((result, false))
329}
330
331struct FileAccum {
335 commit_timestamps: Vec<u64>,
337 weighted_commits: f64,
339 lines_added: u32,
340 lines_deleted: u32,
341}
342
343#[expect(
345 clippy::cast_possible_truncation,
346 reason = "commit count per file is bounded by git history depth"
347)]
348fn parse_git_log(stdout: &str, root: &Path) -> FxHashMap<PathBuf, FileChurn> {
349 let now_secs = std::time::SystemTime::now()
350 .duration_since(std::time::UNIX_EPOCH)
351 .unwrap_or_default()
352 .as_secs();
353
354 let mut accum: FxHashMap<PathBuf, FileAccum> = FxHashMap::default();
355 let mut current_timestamp: Option<u64> = None;
356
357 for line in stdout.lines() {
358 let line = line.trim();
359 if line.is_empty() {
360 continue;
361 }
362
363 if let Ok(ts) = line.parse::<u64>() {
365 current_timestamp = Some(ts);
366 continue;
367 }
368
369 if let Some((added, deleted, path)) = parse_numstat_line(line) {
371 let abs_path = root.join(path);
372 let ts = current_timestamp.unwrap_or(now_secs);
373 let age_days = (now_secs.saturating_sub(ts)) as f64 / SECS_PER_DAY;
374 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
375
376 let entry = accum.entry(abs_path).or_insert_with(|| FileAccum {
377 commit_timestamps: Vec::new(),
378 weighted_commits: 0.0,
379 lines_added: 0,
380 lines_deleted: 0,
381 });
382 entry.commit_timestamps.push(ts);
383 entry.weighted_commits += weight;
384 entry.lines_added += added;
385 entry.lines_deleted += deleted;
386 }
387 }
388
389 accum
391 .into_iter()
392 .map(|(path, acc)| {
393 let commits = acc.commit_timestamps.len() as u32;
394 let trend = compute_trend(&acc.commit_timestamps);
395 let churn = FileChurn {
396 path: path.clone(),
397 commits,
398 weighted_commits: (acc.weighted_commits * 100.0).round() / 100.0,
399 lines_added: acc.lines_added,
400 lines_deleted: acc.lines_deleted,
401 trend,
402 };
403 (path, churn)
404 })
405 .collect()
406}
407
408fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
411 let mut parts = line.splitn(3, '\t');
412 let added_str = parts.next()?;
413 let deleted_str = parts.next()?;
414 let path = parts.next()?;
415
416 let added: u32 = added_str.parse().ok()?;
418 let deleted: u32 = deleted_str.parse().ok()?;
419
420 Some((added, deleted, path))
421}
422
423fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
431 if timestamps.len() < 2 {
432 return ChurnTrend::Stable;
433 }
434
435 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
436 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
437
438 if max_ts == min_ts {
439 return ChurnTrend::Stable;
440 }
441
442 let midpoint = min_ts + (max_ts - min_ts) / 2;
443 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
444 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
445
446 if older < 1.0 {
447 return ChurnTrend::Stable;
448 }
449
450 let ratio = recent / older;
451 if ratio > 1.5 {
452 ChurnTrend::Accelerating
453 } else if ratio < 0.67 {
454 ChurnTrend::Cooling
455 } else {
456 ChurnTrend::Stable
457 }
458}
459
460fn is_iso_date(input: &str) -> bool {
461 input.len() == 10
462 && input.as_bytes().get(4) == Some(&b'-')
463 && input.as_bytes().get(7) == Some(&b'-')
464 && input[..4].bytes().all(|b| b.is_ascii_digit())
465 && input[5..7].bytes().all(|b| b.is_ascii_digit())
466 && input[8..10].bytes().all(|b| b.is_ascii_digit())
467}
468
469fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
470 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
471 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
472 })?;
473 if pos == 0 {
474 return Err(format!(
475 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
476 ));
477 }
478 Ok((&input[..pos], &input[pos..]))
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
488 fn parse_since_months_short() {
489 let d = parse_since("6m").unwrap();
490 assert_eq!(d.git_after, "6 months ago");
491 assert_eq!(d.display, "6 months");
492 }
493
494 #[test]
495 fn parse_since_months_long() {
496 let d = parse_since("6months").unwrap();
497 assert_eq!(d.git_after, "6 months ago");
498 assert_eq!(d.display, "6 months");
499 }
500
501 #[test]
502 fn parse_since_days() {
503 let d = parse_since("90d").unwrap();
504 assert_eq!(d.git_after, "90 days ago");
505 assert_eq!(d.display, "90 days");
506 }
507
508 #[test]
509 fn parse_since_year_singular() {
510 let d = parse_since("1y").unwrap();
511 assert_eq!(d.git_after, "1 year ago");
512 assert_eq!(d.display, "1 year");
513 }
514
515 #[test]
516 fn parse_since_years_plural() {
517 let d = parse_since("2years").unwrap();
518 assert_eq!(d.git_after, "2 years ago");
519 assert_eq!(d.display, "2 years");
520 }
521
522 #[test]
523 fn parse_since_weeks() {
524 let d = parse_since("2w").unwrap();
525 assert_eq!(d.git_after, "2 weeks ago");
526 assert_eq!(d.display, "2 weeks");
527 }
528
529 #[test]
530 fn parse_since_iso_date() {
531 let d = parse_since("2025-06-01").unwrap();
532 assert_eq!(d.git_after, "2025-06-01");
533 assert_eq!(d.display, "2025-06-01");
534 }
535
536 #[test]
537 fn parse_since_month_singular() {
538 let d = parse_since("1month").unwrap();
539 assert_eq!(d.display, "1 month");
540 }
541
542 #[test]
543 fn parse_since_day_singular() {
544 let d = parse_since("1day").unwrap();
545 assert_eq!(d.display, "1 day");
546 }
547
548 #[test]
549 fn parse_since_zero_rejected() {
550 assert!(parse_since("0m").is_err());
551 }
552
553 #[test]
554 fn parse_since_no_unit_rejected() {
555 assert!(parse_since("90").is_err());
556 }
557
558 #[test]
559 fn parse_since_unknown_unit_rejected() {
560 assert!(parse_since("6x").is_err());
561 }
562
563 #[test]
564 fn parse_since_no_number_rejected() {
565 assert!(parse_since("months").is_err());
566 }
567
568 #[test]
571 fn numstat_normal() {
572 let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
573 assert_eq!(a, 10);
574 assert_eq!(d, 5);
575 assert_eq!(p, "src/file.ts");
576 }
577
578 #[test]
579 fn numstat_binary_skipped() {
580 assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
581 }
582
583 #[test]
584 fn numstat_zero_lines() {
585 let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
586 assert_eq!(a, 0);
587 assert_eq!(d, 0);
588 assert_eq!(p, "src/empty.ts");
589 }
590
591 #[test]
594 fn trend_empty_is_stable() {
595 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
596 }
597
598 #[test]
599 fn trend_single_commit_is_stable() {
600 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
601 }
602
603 #[test]
604 fn trend_accelerating() {
605 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
607 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
608 }
609
610 #[test]
611 fn trend_cooling() {
612 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
614 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
615 }
616
617 #[test]
618 fn trend_stable_even_distribution() {
619 let timestamps = vec![100, 200, 300, 700, 800, 900];
621 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
622 }
623
624 #[test]
625 fn trend_same_timestamp_is_stable() {
626 let timestamps = vec![500, 500, 500];
627 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
628 }
629
630 #[test]
633 fn iso_date_valid() {
634 assert!(is_iso_date("2025-06-01"));
635 assert!(is_iso_date("2025-12-31"));
636 }
637
638 #[test]
639 fn iso_date_with_time_rejected() {
640 assert!(!is_iso_date("2025-06-01T00:00:00"));
642 }
643
644 #[test]
645 fn iso_date_invalid() {
646 assert!(!is_iso_date("6months"));
647 assert!(!is_iso_date("2025"));
648 assert!(!is_iso_date("not-a-date"));
649 assert!(!is_iso_date("abcd-ef-gh"));
650 }
651
652 #[test]
655 fn trend_display() {
656 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
657 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
658 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
659 }
660
661 #[test]
664 fn parse_git_log_single_commit() {
665 let root = Path::new("/project");
666 let output = "1700000000\n10\t5\tsrc/index.ts\n";
667 let result = parse_git_log(output, root);
668 assert_eq!(result.len(), 1);
669 let churn = &result[&PathBuf::from("/project/src/index.ts")];
670 assert_eq!(churn.commits, 1);
671 assert_eq!(churn.lines_added, 10);
672 assert_eq!(churn.lines_deleted, 5);
673 }
674
675 #[test]
676 fn parse_git_log_multiple_commits_same_file() {
677 let root = Path::new("/project");
678 let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
679 let result = parse_git_log(output, root);
680 assert_eq!(result.len(), 1);
681 let churn = &result[&PathBuf::from("/project/src/index.ts")];
682 assert_eq!(churn.commits, 2);
683 assert_eq!(churn.lines_added, 13);
684 assert_eq!(churn.lines_deleted, 7);
685 }
686
687 #[test]
688 fn parse_git_log_multiple_files() {
689 let root = Path::new("/project");
690 let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
691 let result = parse_git_log(output, root);
692 assert_eq!(result.len(), 2);
693 assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
694 assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
695 }
696
697 #[test]
698 fn parse_git_log_empty_output() {
699 let root = Path::new("/project");
700 let result = parse_git_log("", root);
701 assert!(result.is_empty());
702 }
703
704 #[test]
705 fn parse_git_log_skips_binary_files() {
706 let root = Path::new("/project");
707 let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
708 let result = parse_git_log(output, root);
709 assert_eq!(result.len(), 1);
710 assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
711 }
712
713 #[test]
714 fn parse_git_log_weighted_commits_are_positive() {
715 let root = Path::new("/project");
716 let now_secs = std::time::SystemTime::now()
718 .duration_since(std::time::UNIX_EPOCH)
719 .unwrap()
720 .as_secs();
721 let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
722 let result = parse_git_log(&output, root);
723 let churn = &result[&PathBuf::from("/project/src/a.ts")];
724 assert!(
725 churn.weighted_commits > 0.0,
726 "weighted_commits should be positive for recent commits"
727 );
728 }
729
730 #[test]
733 fn trend_boundary_1_5x_ratio() {
734 let timestamps = vec![100, 200, 600, 800, 1000];
740 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
741 }
742
743 #[test]
744 fn trend_just_above_1_5x() {
745 let timestamps = vec![100, 600, 800, 1000];
750 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
751 }
752
753 #[test]
754 fn trend_boundary_0_67x_ratio() {
755 let timestamps = vec![100, 200, 300, 600, 1000];
761 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
762 }
763
764 #[test]
765 fn trend_two_timestamps_different() {
766 let timestamps = vec![100, 200];
771 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
772 }
773
774 #[test]
777 fn parse_since_week_singular() {
778 let d = parse_since("1week").unwrap();
779 assert_eq!(d.git_after, "1 week ago");
780 assert_eq!(d.display, "1 week");
781 }
782
783 #[test]
784 fn parse_since_weeks_long() {
785 let d = parse_since("3weeks").unwrap();
786 assert_eq!(d.git_after, "3 weeks ago");
787 assert_eq!(d.display, "3 weeks");
788 }
789
790 #[test]
791 fn parse_since_days_long() {
792 let d = parse_since("30days").unwrap();
793 assert_eq!(d.git_after, "30 days ago");
794 assert_eq!(d.display, "30 days");
795 }
796
797 #[test]
798 fn parse_since_year_long() {
799 let d = parse_since("1year").unwrap();
800 assert_eq!(d.git_after, "1 year ago");
801 assert_eq!(d.display, "1 year");
802 }
803
804 #[test]
805 fn parse_since_overflow_number_rejected() {
806 let result = parse_since("99999999999999999999d");
808 assert!(result.is_err());
809 let err = result.unwrap_err();
810 assert!(err.contains("invalid number"));
811 }
812
813 #[test]
814 fn parse_since_zero_days_rejected() {
815 assert!(parse_since("0d").is_err());
816 }
817
818 #[test]
819 fn parse_since_zero_weeks_rejected() {
820 assert!(parse_since("0w").is_err());
821 }
822
823 #[test]
824 fn parse_since_zero_years_rejected() {
825 assert!(parse_since("0y").is_err());
826 }
827
828 #[test]
831 fn numstat_missing_path() {
832 assert!(parse_numstat_line("10\t5").is_none());
834 }
835
836 #[test]
837 fn numstat_single_field() {
838 assert!(parse_numstat_line("10").is_none());
839 }
840
841 #[test]
842 fn numstat_empty_string() {
843 assert!(parse_numstat_line("").is_none());
844 }
845
846 #[test]
847 fn numstat_only_added_is_binary() {
848 assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
850 }
851
852 #[test]
853 fn numstat_only_deleted_is_binary() {
854 assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
856 }
857
858 #[test]
859 fn numstat_path_with_spaces() {
860 let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
861 assert_eq!(a, 3);
862 assert_eq!(d, 1);
863 assert_eq!(p, "path with spaces/file.ts");
864 }
865
866 #[test]
867 fn numstat_large_numbers() {
868 let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
869 assert_eq!(a, 9999);
870 assert_eq!(d, 8888);
871 assert_eq!(p, "src/big.ts");
872 }
873
874 #[test]
877 fn iso_date_wrong_separator_positions() {
878 assert!(!is_iso_date("20-25-0601"));
880 assert!(!is_iso_date("202506-01-"));
881 }
882
883 #[test]
884 fn iso_date_too_short() {
885 assert!(!is_iso_date("2025-06-0"));
886 }
887
888 #[test]
889 fn iso_date_letters_in_day() {
890 assert!(!is_iso_date("2025-06-ab"));
891 }
892
893 #[test]
894 fn iso_date_letters_in_month() {
895 assert!(!is_iso_date("2025-ab-01"));
896 }
897
898 #[test]
901 fn split_number_unit_valid() {
902 let (num, unit) = split_number_unit("42days").unwrap();
903 assert_eq!(num, "42");
904 assert_eq!(unit, "days");
905 }
906
907 #[test]
908 fn split_number_unit_single_digit() {
909 let (num, unit) = split_number_unit("1m").unwrap();
910 assert_eq!(num, "1");
911 assert_eq!(unit, "m");
912 }
913
914 #[test]
915 fn split_number_unit_no_digits() {
916 let err = split_number_unit("abc").unwrap_err();
917 assert!(err.contains("must start with a number"));
918 }
919
920 #[test]
921 fn split_number_unit_no_unit() {
922 let err = split_number_unit("123").unwrap_err();
923 assert!(err.contains("requires a unit suffix"));
924 }
925
926 #[test]
929 fn parse_git_log_numstat_before_timestamp_uses_now() {
930 let root = Path::new("/project");
931 let output = "10\t5\tsrc/no_ts.ts\n";
933 let result = parse_git_log(output, root);
934 assert_eq!(result.len(), 1);
935 let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
936 assert_eq!(churn.commits, 1);
937 assert_eq!(churn.lines_added, 10);
938 assert_eq!(churn.lines_deleted, 5);
939 assert!(
941 churn.weighted_commits > 0.9,
942 "weight should be near 1.0 when timestamp defaults to now"
943 );
944 }
945
946 #[test]
947 fn parse_git_log_whitespace_lines_ignored() {
948 let root = Path::new("/project");
949 let output = " \n1700000000\n \n10\t5\tsrc/a.ts\n \n";
950 let result = parse_git_log(output, root);
951 assert_eq!(result.len(), 1);
952 }
953
954 #[test]
955 fn parse_git_log_trend_is_computed_per_file() {
956 let root = Path::new("/project");
957 let output = "\
9591000\n5\t1\tsrc/old.ts\n\
9602000\n3\t1\tsrc/old.ts\n\
9611000\n1\t0\tsrc/hot.ts\n\
9621800\n1\t0\tsrc/hot.ts\n\
9631900\n1\t0\tsrc/hot.ts\n\
9641950\n1\t0\tsrc/hot.ts\n\
9652000\n1\t0\tsrc/hot.ts\n";
966 let result = parse_git_log(output, root);
967 let old = &result[&PathBuf::from("/project/src/old.ts")];
968 let hot = &result[&PathBuf::from("/project/src/hot.ts")];
969 assert_eq!(old.commits, 2);
970 assert_eq!(hot.commits, 5);
971 assert_eq!(hot.trend, ChurnTrend::Accelerating);
973 }
974
975 #[test]
976 fn parse_git_log_weighted_decay_for_old_commits() {
977 let root = Path::new("/project");
978 let now = std::time::SystemTime::now()
979 .duration_since(std::time::UNIX_EPOCH)
980 .unwrap()
981 .as_secs();
982 let old_ts = now - (180 * 86_400);
984 let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
985 let result = parse_git_log(&output, root);
986 let churn = &result[&PathBuf::from("/project/src/old.ts")];
987 assert!(
988 churn.weighted_commits < 0.5,
989 "180-day-old commit should weigh ~0.25, got {}",
990 churn.weighted_commits
991 );
992 assert!(
993 churn.weighted_commits > 0.1,
994 "180-day-old commit should weigh ~0.25, got {}",
995 churn.weighted_commits
996 );
997 }
998
999 #[test]
1000 fn parse_git_log_path_stored_as_absolute() {
1001 let root = Path::new("/my/project");
1002 let output = "1700000000\n1\t0\tlib/utils.ts\n";
1003 let result = parse_git_log(output, root);
1004 let key = PathBuf::from("/my/project/lib/utils.ts");
1005 assert!(result.contains_key(&key));
1006 assert_eq!(result[&key].path, key);
1007 }
1008
1009 #[test]
1010 fn parse_git_log_weighted_commits_rounded() {
1011 let root = Path::new("/project");
1012 let now = std::time::SystemTime::now()
1013 .duration_since(std::time::UNIX_EPOCH)
1014 .unwrap()
1015 .as_secs();
1016 let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1018 let result = parse_git_log(&output, root);
1019 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1020 let decimals = format!("{:.2}", churn.weighted_commits);
1022 assert_eq!(
1023 churn.weighted_commits.to_string().len(),
1024 decimals.len().min(churn.weighted_commits.to_string().len()),
1025 "weighted_commits should be rounded to at most 2 decimal places"
1026 );
1027 }
1028
1029 #[test]
1032 fn trend_serde_serialization() {
1033 assert_eq!(
1034 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1035 "\"accelerating\""
1036 );
1037 assert_eq!(
1038 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1039 "\"stable\""
1040 );
1041 assert_eq!(
1042 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1043 "\"cooling\""
1044 );
1045 }
1046}