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)]
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
208struct FileAccum {
212 commit_timestamps: Vec<u64>,
214 weighted_commits: f64,
216 lines_added: u32,
217 lines_deleted: u32,
218}
219
220#[expect(
222 clippy::cast_possible_truncation,
223 reason = "commit count per file is bounded by git history depth"
224)]
225fn parse_git_log(stdout: &str, root: &Path) -> FxHashMap<PathBuf, FileChurn> {
226 let now_secs = std::time::SystemTime::now()
227 .duration_since(std::time::UNIX_EPOCH)
228 .unwrap_or_default()
229 .as_secs();
230
231 let mut accum: FxHashMap<PathBuf, FileAccum> = FxHashMap::default();
232 let mut current_timestamp: Option<u64> = None;
233
234 for line in stdout.lines() {
235 let line = line.trim();
236 if line.is_empty() {
237 continue;
238 }
239
240 if let Ok(ts) = line.parse::<u64>() {
242 current_timestamp = Some(ts);
243 continue;
244 }
245
246 if let Some((added, deleted, path)) = parse_numstat_line(line) {
248 let abs_path = root.join(path);
249 let ts = current_timestamp.unwrap_or(now_secs);
250 let age_days = (now_secs.saturating_sub(ts)) as f64 / SECS_PER_DAY;
251 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
252
253 let entry = accum.entry(abs_path).or_insert_with(|| FileAccum {
254 commit_timestamps: Vec::new(),
255 weighted_commits: 0.0,
256 lines_added: 0,
257 lines_deleted: 0,
258 });
259 entry.commit_timestamps.push(ts);
260 entry.weighted_commits += weight;
261 entry.lines_added += added;
262 entry.lines_deleted += deleted;
263 }
264 }
265
266 accum
268 .into_iter()
269 .map(|(path, acc)| {
270 let commits = acc.commit_timestamps.len() as u32;
271 let trend = compute_trend(&acc.commit_timestamps);
272 let churn = FileChurn {
273 path: path.clone(),
274 commits,
275 weighted_commits: (acc.weighted_commits * 100.0).round() / 100.0,
276 lines_added: acc.lines_added,
277 lines_deleted: acc.lines_deleted,
278 trend,
279 };
280 (path, churn)
281 })
282 .collect()
283}
284
285fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
288 let mut parts = line.splitn(3, '\t');
289 let added_str = parts.next()?;
290 let deleted_str = parts.next()?;
291 let path = parts.next()?;
292
293 let added: u32 = added_str.parse().ok()?;
295 let deleted: u32 = deleted_str.parse().ok()?;
296
297 Some((added, deleted, path))
298}
299
300fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
308 if timestamps.len() < 2 {
309 return ChurnTrend::Stable;
310 }
311
312 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
313 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
314
315 if max_ts == min_ts {
316 return ChurnTrend::Stable;
317 }
318
319 let midpoint = min_ts + (max_ts - min_ts) / 2;
320 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
321 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
322
323 if older < 1.0 {
324 return ChurnTrend::Stable;
325 }
326
327 let ratio = recent / older;
328 if ratio > 1.5 {
329 ChurnTrend::Accelerating
330 } else if ratio < 0.67 {
331 ChurnTrend::Cooling
332 } else {
333 ChurnTrend::Stable
334 }
335}
336
337fn is_iso_date(input: &str) -> bool {
338 input.len() == 10
339 && input.as_bytes().get(4) == Some(&b'-')
340 && input.as_bytes().get(7) == Some(&b'-')
341 && input[..4].bytes().all(|b| b.is_ascii_digit())
342 && input[5..7].bytes().all(|b| b.is_ascii_digit())
343 && input[8..10].bytes().all(|b| b.is_ascii_digit())
344}
345
346fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
347 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
348 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
349 })?;
350 if pos == 0 {
351 return Err(format!(
352 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
353 ));
354 }
355 Ok((&input[..pos], &input[pos..]))
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
365 fn parse_since_months_short() {
366 let d = parse_since("6m").unwrap();
367 assert_eq!(d.git_after, "6 months ago");
368 assert_eq!(d.display, "6 months");
369 }
370
371 #[test]
372 fn parse_since_months_long() {
373 let d = parse_since("6months").unwrap();
374 assert_eq!(d.git_after, "6 months ago");
375 assert_eq!(d.display, "6 months");
376 }
377
378 #[test]
379 fn parse_since_days() {
380 let d = parse_since("90d").unwrap();
381 assert_eq!(d.git_after, "90 days ago");
382 assert_eq!(d.display, "90 days");
383 }
384
385 #[test]
386 fn parse_since_year_singular() {
387 let d = parse_since("1y").unwrap();
388 assert_eq!(d.git_after, "1 year ago");
389 assert_eq!(d.display, "1 year");
390 }
391
392 #[test]
393 fn parse_since_years_plural() {
394 let d = parse_since("2years").unwrap();
395 assert_eq!(d.git_after, "2 years ago");
396 assert_eq!(d.display, "2 years");
397 }
398
399 #[test]
400 fn parse_since_weeks() {
401 let d = parse_since("2w").unwrap();
402 assert_eq!(d.git_after, "2 weeks ago");
403 assert_eq!(d.display, "2 weeks");
404 }
405
406 #[test]
407 fn parse_since_iso_date() {
408 let d = parse_since("2025-06-01").unwrap();
409 assert_eq!(d.git_after, "2025-06-01");
410 assert_eq!(d.display, "2025-06-01");
411 }
412
413 #[test]
414 fn parse_since_month_singular() {
415 let d = parse_since("1month").unwrap();
416 assert_eq!(d.display, "1 month");
417 }
418
419 #[test]
420 fn parse_since_day_singular() {
421 let d = parse_since("1day").unwrap();
422 assert_eq!(d.display, "1 day");
423 }
424
425 #[test]
426 fn parse_since_zero_rejected() {
427 assert!(parse_since("0m").is_err());
428 }
429
430 #[test]
431 fn parse_since_no_unit_rejected() {
432 assert!(parse_since("90").is_err());
433 }
434
435 #[test]
436 fn parse_since_unknown_unit_rejected() {
437 assert!(parse_since("6x").is_err());
438 }
439
440 #[test]
441 fn parse_since_no_number_rejected() {
442 assert!(parse_since("months").is_err());
443 }
444
445 #[test]
448 fn numstat_normal() {
449 let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
450 assert_eq!(a, 10);
451 assert_eq!(d, 5);
452 assert_eq!(p, "src/file.ts");
453 }
454
455 #[test]
456 fn numstat_binary_skipped() {
457 assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
458 }
459
460 #[test]
461 fn numstat_zero_lines() {
462 let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
463 assert_eq!(a, 0);
464 assert_eq!(d, 0);
465 assert_eq!(p, "src/empty.ts");
466 }
467
468 #[test]
471 fn trend_empty_is_stable() {
472 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
473 }
474
475 #[test]
476 fn trend_single_commit_is_stable() {
477 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
478 }
479
480 #[test]
481 fn trend_accelerating() {
482 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
484 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
485 }
486
487 #[test]
488 fn trend_cooling() {
489 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
491 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
492 }
493
494 #[test]
495 fn trend_stable_even_distribution() {
496 let timestamps = vec![100, 200, 300, 700, 800, 900];
498 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
499 }
500
501 #[test]
502 fn trend_same_timestamp_is_stable() {
503 let timestamps = vec![500, 500, 500];
504 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
505 }
506
507 #[test]
510 fn iso_date_valid() {
511 assert!(is_iso_date("2025-06-01"));
512 assert!(is_iso_date("2025-12-31"));
513 }
514
515 #[test]
516 fn iso_date_with_time_rejected() {
517 assert!(!is_iso_date("2025-06-01T00:00:00"));
519 }
520
521 #[test]
522 fn iso_date_invalid() {
523 assert!(!is_iso_date("6months"));
524 assert!(!is_iso_date("2025"));
525 assert!(!is_iso_date("not-a-date"));
526 assert!(!is_iso_date("abcd-ef-gh"));
527 }
528
529 #[test]
532 fn trend_display() {
533 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
534 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
535 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
536 }
537
538 #[test]
541 fn parse_git_log_single_commit() {
542 let root = Path::new("/project");
543 let output = "1700000000\n10\t5\tsrc/index.ts\n";
544 let result = parse_git_log(output, root);
545 assert_eq!(result.len(), 1);
546 let churn = &result[&PathBuf::from("/project/src/index.ts")];
547 assert_eq!(churn.commits, 1);
548 assert_eq!(churn.lines_added, 10);
549 assert_eq!(churn.lines_deleted, 5);
550 }
551
552 #[test]
553 fn parse_git_log_multiple_commits_same_file() {
554 let root = Path::new("/project");
555 let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
556 let result = parse_git_log(output, root);
557 assert_eq!(result.len(), 1);
558 let churn = &result[&PathBuf::from("/project/src/index.ts")];
559 assert_eq!(churn.commits, 2);
560 assert_eq!(churn.lines_added, 13);
561 assert_eq!(churn.lines_deleted, 7);
562 }
563
564 #[test]
565 fn parse_git_log_multiple_files() {
566 let root = Path::new("/project");
567 let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
568 let result = parse_git_log(output, root);
569 assert_eq!(result.len(), 2);
570 assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
571 assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
572 }
573
574 #[test]
575 fn parse_git_log_empty_output() {
576 let root = Path::new("/project");
577 let result = parse_git_log("", root);
578 assert!(result.is_empty());
579 }
580
581 #[test]
582 fn parse_git_log_skips_binary_files() {
583 let root = Path::new("/project");
584 let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
585 let result = parse_git_log(output, root);
586 assert_eq!(result.len(), 1);
587 assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
588 }
589
590 #[test]
591 fn parse_git_log_weighted_commits_are_positive() {
592 let root = Path::new("/project");
593 let now_secs = std::time::SystemTime::now()
595 .duration_since(std::time::UNIX_EPOCH)
596 .unwrap()
597 .as_secs();
598 let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
599 let result = parse_git_log(&output, root);
600 let churn = &result[&PathBuf::from("/project/src/a.ts")];
601 assert!(
602 churn.weighted_commits > 0.0,
603 "weighted_commits should be positive for recent commits"
604 );
605 }
606
607 #[test]
610 fn trend_boundary_1_5x_ratio() {
611 let timestamps = vec![100, 200, 600, 800, 1000];
617 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
618 }
619
620 #[test]
621 fn trend_just_above_1_5x() {
622 let timestamps = vec![100, 600, 800, 1000];
627 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
628 }
629
630 #[test]
631 fn trend_boundary_0_67x_ratio() {
632 let timestamps = vec![100, 200, 300, 600, 1000];
638 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
639 }
640
641 #[test]
642 fn trend_two_timestamps_different() {
643 let timestamps = vec![100, 200];
648 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
649 }
650
651 #[test]
654 fn parse_since_week_singular() {
655 let d = parse_since("1week").unwrap();
656 assert_eq!(d.git_after, "1 week ago");
657 assert_eq!(d.display, "1 week");
658 }
659
660 #[test]
661 fn parse_since_weeks_long() {
662 let d = parse_since("3weeks").unwrap();
663 assert_eq!(d.git_after, "3 weeks ago");
664 assert_eq!(d.display, "3 weeks");
665 }
666
667 #[test]
668 fn parse_since_days_long() {
669 let d = parse_since("30days").unwrap();
670 assert_eq!(d.git_after, "30 days ago");
671 assert_eq!(d.display, "30 days");
672 }
673
674 #[test]
675 fn parse_since_year_long() {
676 let d = parse_since("1year").unwrap();
677 assert_eq!(d.git_after, "1 year ago");
678 assert_eq!(d.display, "1 year");
679 }
680
681 #[test]
682 fn parse_since_overflow_number_rejected() {
683 let result = parse_since("99999999999999999999d");
685 assert!(result.is_err());
686 let err = result.unwrap_err();
687 assert!(err.contains("invalid number"));
688 }
689
690 #[test]
691 fn parse_since_zero_days_rejected() {
692 assert!(parse_since("0d").is_err());
693 }
694
695 #[test]
696 fn parse_since_zero_weeks_rejected() {
697 assert!(parse_since("0w").is_err());
698 }
699
700 #[test]
701 fn parse_since_zero_years_rejected() {
702 assert!(parse_since("0y").is_err());
703 }
704
705 #[test]
708 fn numstat_missing_path() {
709 assert!(parse_numstat_line("10\t5").is_none());
711 }
712
713 #[test]
714 fn numstat_single_field() {
715 assert!(parse_numstat_line("10").is_none());
716 }
717
718 #[test]
719 fn numstat_empty_string() {
720 assert!(parse_numstat_line("").is_none());
721 }
722
723 #[test]
724 fn numstat_only_added_is_binary() {
725 assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
727 }
728
729 #[test]
730 fn numstat_only_deleted_is_binary() {
731 assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
733 }
734
735 #[test]
736 fn numstat_path_with_spaces() {
737 let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
738 assert_eq!(a, 3);
739 assert_eq!(d, 1);
740 assert_eq!(p, "path with spaces/file.ts");
741 }
742
743 #[test]
744 fn numstat_large_numbers() {
745 let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
746 assert_eq!(a, 9999);
747 assert_eq!(d, 8888);
748 assert_eq!(p, "src/big.ts");
749 }
750
751 #[test]
754 fn iso_date_wrong_separator_positions() {
755 assert!(!is_iso_date("20-25-0601"));
757 assert!(!is_iso_date("202506-01-"));
758 }
759
760 #[test]
761 fn iso_date_too_short() {
762 assert!(!is_iso_date("2025-06-0"));
763 }
764
765 #[test]
766 fn iso_date_letters_in_day() {
767 assert!(!is_iso_date("2025-06-ab"));
768 }
769
770 #[test]
771 fn iso_date_letters_in_month() {
772 assert!(!is_iso_date("2025-ab-01"));
773 }
774
775 #[test]
778 fn split_number_unit_valid() {
779 let (num, unit) = split_number_unit("42days").unwrap();
780 assert_eq!(num, "42");
781 assert_eq!(unit, "days");
782 }
783
784 #[test]
785 fn split_number_unit_single_digit() {
786 let (num, unit) = split_number_unit("1m").unwrap();
787 assert_eq!(num, "1");
788 assert_eq!(unit, "m");
789 }
790
791 #[test]
792 fn split_number_unit_no_digits() {
793 let err = split_number_unit("abc").unwrap_err();
794 assert!(err.contains("must start with a number"));
795 }
796
797 #[test]
798 fn split_number_unit_no_unit() {
799 let err = split_number_unit("123").unwrap_err();
800 assert!(err.contains("requires a unit suffix"));
801 }
802
803 #[test]
806 fn parse_git_log_numstat_before_timestamp_uses_now() {
807 let root = Path::new("/project");
808 let output = "10\t5\tsrc/no_ts.ts\n";
810 let result = parse_git_log(output, root);
811 assert_eq!(result.len(), 1);
812 let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
813 assert_eq!(churn.commits, 1);
814 assert_eq!(churn.lines_added, 10);
815 assert_eq!(churn.lines_deleted, 5);
816 assert!(
818 churn.weighted_commits > 0.9,
819 "weight should be near 1.0 when timestamp defaults to now"
820 );
821 }
822
823 #[test]
824 fn parse_git_log_whitespace_lines_ignored() {
825 let root = Path::new("/project");
826 let output = " \n1700000000\n \n10\t5\tsrc/a.ts\n \n";
827 let result = parse_git_log(output, root);
828 assert_eq!(result.len(), 1);
829 }
830
831 #[test]
832 fn parse_git_log_trend_is_computed_per_file() {
833 let root = Path::new("/project");
834 let output = "\
8361000\n5\t1\tsrc/old.ts\n\
8372000\n3\t1\tsrc/old.ts\n\
8381000\n1\t0\tsrc/hot.ts\n\
8391800\n1\t0\tsrc/hot.ts\n\
8401900\n1\t0\tsrc/hot.ts\n\
8411950\n1\t0\tsrc/hot.ts\n\
8422000\n1\t0\tsrc/hot.ts\n";
843 let result = parse_git_log(output, root);
844 let old = &result[&PathBuf::from("/project/src/old.ts")];
845 let hot = &result[&PathBuf::from("/project/src/hot.ts")];
846 assert_eq!(old.commits, 2);
847 assert_eq!(hot.commits, 5);
848 assert_eq!(hot.trend, ChurnTrend::Accelerating);
850 }
851
852 #[test]
853 fn parse_git_log_weighted_decay_for_old_commits() {
854 let root = Path::new("/project");
855 let now = std::time::SystemTime::now()
856 .duration_since(std::time::UNIX_EPOCH)
857 .unwrap()
858 .as_secs();
859 let old_ts = now - (180 * 86_400);
861 let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
862 let result = parse_git_log(&output, root);
863 let churn = &result[&PathBuf::from("/project/src/old.ts")];
864 assert!(
865 churn.weighted_commits < 0.5,
866 "180-day-old commit should weigh ~0.25, got {}",
867 churn.weighted_commits
868 );
869 assert!(
870 churn.weighted_commits > 0.1,
871 "180-day-old commit should weigh ~0.25, got {}",
872 churn.weighted_commits
873 );
874 }
875
876 #[test]
877 fn parse_git_log_path_stored_as_absolute() {
878 let root = Path::new("/my/project");
879 let output = "1700000000\n1\t0\tlib/utils.ts\n";
880 let result = parse_git_log(output, root);
881 let key = PathBuf::from("/my/project/lib/utils.ts");
882 assert!(result.contains_key(&key));
883 assert_eq!(result[&key].path, key);
884 }
885
886 #[test]
887 fn parse_git_log_weighted_commits_rounded() {
888 let root = Path::new("/project");
889 let now = std::time::SystemTime::now()
890 .duration_since(std::time::UNIX_EPOCH)
891 .unwrap()
892 .as_secs();
893 let output = format!("{now}\n1\t0\tsrc/a.ts\n");
895 let result = parse_git_log(&output, root);
896 let churn = &result[&PathBuf::from("/project/src/a.ts")];
897 let decimals = format!("{:.2}", churn.weighted_commits);
899 assert_eq!(
900 churn.weighted_commits.to_string().len(),
901 decimals.len().min(churn.weighted_commits.to_string().len()),
902 "weighted_commits should be rounded to at most 2 decimal places"
903 );
904 }
905
906 #[test]
909 fn trend_serde_serialization() {
910 assert_eq!(
911 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
912 "\"accelerating\""
913 );
914 assert_eq!(
915 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
916 "\"stable\""
917 );
918 assert_eq!(
919 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
920 "\"cooling\""
921 );
922 }
923}