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, Copy)]
55pub struct AuthorContribution {
56 pub commits: u32,
58 pub weighted_commits: f64,
60 pub first_commit_ts: u64,
62 pub last_commit_ts: u64,
64}
65
66#[derive(Debug, Clone)]
68pub struct FileChurn {
69 pub path: PathBuf,
71 pub commits: u32,
73 pub weighted_commits: f64,
75 pub lines_added: u32,
77 pub lines_deleted: u32,
79 pub trend: ChurnTrend,
81 pub authors: FxHashMap<u32, AuthorContribution>,
84}
85
86pub struct ChurnResult {
88 pub files: FxHashMap<PathBuf, FileChurn>,
90 pub shallow_clone: bool,
92 pub author_pool: Vec<String>,
95}
96
97pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
108 if is_iso_date(input) {
110 return Ok(SinceDuration {
111 git_after: input.to_string(),
112 display: input.to_string(),
113 });
114 }
115
116 let (num_str, unit) = split_number_unit(input)?;
118 let num: u64 = num_str
119 .parse()
120 .map_err(|_| format!("invalid number in --since: {input}"))?;
121
122 if num == 0 {
123 return Err("--since duration must be greater than 0".to_string());
124 }
125
126 match unit {
127 "d" | "day" | "days" => {
128 let s = if num == 1 { "" } else { "s" };
129 Ok(SinceDuration {
130 git_after: format!("{num} day{s} ago"),
131 display: format!("{num} day{s}"),
132 })
133 }
134 "w" | "week" | "weeks" => {
135 let s = if num == 1 { "" } else { "s" };
136 Ok(SinceDuration {
137 git_after: format!("{num} week{s} ago"),
138 display: format!("{num} week{s}"),
139 })
140 }
141 "m" | "month" | "months" => {
142 let s = if num == 1 { "" } else { "s" };
143 Ok(SinceDuration {
144 git_after: format!("{num} month{s} ago"),
145 display: format!("{num} month{s}"),
146 })
147 }
148 "y" | "year" | "years" => {
149 let s = if num == 1 { "" } else { "s" };
150 Ok(SinceDuration {
151 git_after: format!("{num} year{s} ago"),
152 display: format!("{num} year{s}"),
153 })
154 }
155 _ => Err(format!(
156 "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
157 )),
158 }
159}
160
161pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
165 let shallow = is_shallow_clone(root);
166 let state = analyze_churn_events(root, since, None)?;
167 Some(build_churn_result(state, shallow))
168}
169
170#[must_use]
172pub fn is_shallow_clone(root: &Path) -> bool {
173 Command::new("git")
174 .args(["rev-parse", "--is-shallow-repository"])
175 .current_dir(root)
176 .output()
177 .is_ok_and(|o| {
178 String::from_utf8_lossy(&o.stdout)
179 .trim()
180 .eq_ignore_ascii_case("true")
181 })
182}
183
184#[must_use]
186pub fn is_git_repo(root: &Path) -> bool {
187 Command::new("git")
188 .args(["rev-parse", "--git-dir"])
189 .current_dir(root)
190 .stdout(std::process::Stdio::null())
191 .stderr(std::process::Stdio::null())
192 .status()
193 .is_ok_and(|s| s.success())
194}
195
196const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
201
202const CHURN_CACHE_VERSION: u8 = 3;
206
207#[derive(Clone, bitcode::Encode, bitcode::Decode)]
209struct CachedCommitEvent {
210 timestamp: u64,
211 lines_added: u32,
212 lines_deleted: u32,
213 author_idx: Option<u32>,
214}
215
216#[derive(Clone, bitcode::Encode, bitcode::Decode)]
218struct CachedFileChurn {
219 path: String,
220 events: Vec<CachedCommitEvent>,
221}
222
223#[derive(Clone, bitcode::Encode, bitcode::Decode)]
225struct ChurnCache {
226 version: u8,
228 last_indexed_sha: String,
229 git_after: String,
230 files: Vec<CachedFileChurn>,
231 shallow_clone: bool,
232 author_pool: Vec<String>,
234}
235
236struct FileEvents {
238 events: Vec<CachedCommitEvent>,
239}
240
241struct ChurnEventState {
244 files: FxHashMap<PathBuf, FileEvents>,
245 author_pool: Vec<String>,
246}
247
248fn get_head_sha(root: &Path) -> Option<String> {
250 Command::new("git")
251 .args(["rev-parse", "HEAD"])
252 .current_dir(root)
253 .output()
254 .ok()
255 .filter(|o| o.status.success())
256 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
257}
258
259fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
261 Command::new("git")
262 .args(["merge-base", "--is-ancestor", ancestor, descendant])
263 .current_dir(root)
264 .status()
265 .is_ok_and(|s| s.success())
266}
267
268fn load_churn_cache(cache_dir: &Path, git_after: &str) -> Option<ChurnCache> {
271 let cache_file = cache_dir.join("churn.bin");
272 let data = std::fs::read(&cache_file).ok()?;
273 if data.len() > MAX_CHURN_CACHE_SIZE {
274 return None;
275 }
276 let cache: ChurnCache = bitcode::decode(&data).ok()?;
277 if cache.version != CHURN_CACHE_VERSION || cache.git_after != git_after {
278 return None;
279 }
280 Some(cache)
281}
282
283fn save_churn_cache(
285 cache_dir: &Path,
286 last_indexed_sha: &str,
287 git_after: &str,
288 state: &ChurnEventState,
289 shallow_clone: bool,
290) {
291 let files: Vec<CachedFileChurn> = state
292 .files
293 .iter()
294 .map(|f| CachedFileChurn {
295 path: f.0.to_string_lossy().to_string(),
296 events: f.1.events.clone(),
297 })
298 .collect();
299 let cache = ChurnCache {
300 version: CHURN_CACHE_VERSION,
301 last_indexed_sha: last_indexed_sha.to_string(),
302 git_after: git_after.to_string(),
303 files,
304 shallow_clone,
305 author_pool: state.author_pool.clone(),
306 };
307 let _ = std::fs::create_dir_all(cache_dir);
308 let data = bitcode::encode(&cache);
309 let tmp = cache_dir.join("churn.bin.tmp");
311 if std::fs::write(&tmp, data).is_ok() {
312 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
313 }
314}
315
316pub fn analyze_churn_cached(
324 root: &Path,
325 since: &SinceDuration,
326 cache_dir: &Path,
327 no_cache: bool,
328) -> Option<(ChurnResult, bool)> {
329 let head_sha = get_head_sha(root)?;
330
331 if !no_cache && let Some(cache) = load_churn_cache(cache_dir, &since.git_after) {
332 if cache.last_indexed_sha == head_sha {
333 let shallow_clone = cache.shallow_clone;
334 let state = cache.into_event_state();
335 return Some((build_churn_result(state, shallow_clone), true));
336 }
337
338 if is_ancestor(root, &cache.last_indexed_sha, &head_sha) {
339 let shallow_clone = is_shallow_clone(root);
340 let range = format!("{}..HEAD", cache.last_indexed_sha);
341 if let Some(delta) = analyze_churn_events(root, since, Some(&range)) {
342 let mut state = cache.into_event_state();
343 merge_churn_states(&mut state, delta);
344 save_churn_cache(
345 cache_dir,
346 &head_sha,
347 &since.git_after,
348 &state,
349 shallow_clone,
350 );
351 return Some((build_churn_result(state, shallow_clone), true));
352 }
353 }
354 }
355
356 let shallow_clone = is_shallow_clone(root);
357 let state = analyze_churn_events(root, since, None)?;
358 if !no_cache {
359 save_churn_cache(
360 cache_dir,
361 &head_sha,
362 &since.git_after,
363 &state,
364 shallow_clone,
365 );
366 }
367
368 let result = build_churn_result(state, shallow_clone);
369 Some((result, false))
370}
371
372impl ChurnCache {
375 fn into_event_state(self) -> ChurnEventState {
376 let files = self
377 .files
378 .into_iter()
379 .map(|entry| {
380 (
381 PathBuf::from(entry.path),
382 FileEvents {
383 events: entry.events,
384 },
385 )
386 })
387 .collect();
388 ChurnEventState {
389 files,
390 author_pool: self.author_pool,
391 }
392 }
393}
394
395fn analyze_churn_events(
397 root: &Path,
398 since: &SinceDuration,
399 revision_range: Option<&str>,
400) -> Option<ChurnEventState> {
401 let mut command = Command::new("git");
402 command.arg("log");
403 if let Some(range) = revision_range {
404 command.arg(range);
405 }
406 command
407 .args([
408 "--numstat",
409 "--no-merges",
410 "--no-renames",
411 "--use-mailmap",
412 "--format=format:%at|%ae",
413 &format!("--after={}", since.git_after),
414 ])
415 .current_dir(root);
416
417 let output = match command.output() {
418 Ok(o) => o,
419 Err(e) => {
420 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
421 return None;
422 }
423 };
424
425 if !output.status.success() {
426 let stderr = String::from_utf8_lossy(&output.stderr);
427 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
428 return None;
429 }
430
431 let stdout = String::from_utf8_lossy(&output.stdout);
432 Some(parse_git_log_events(&stdout, root))
433}
434
435fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
437 let mut base_author_index: FxHashMap<String, u32> = base
438 .author_pool
439 .iter()
440 .enumerate()
441 .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
442 .collect();
443
444 let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
445 for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
446 let Ok(old_idx) = u32::try_from(old_idx) else {
447 continue;
448 };
449 let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
450 author_mapping.insert(old_idx, new_idx);
451 }
452
453 for (path, mut file) in delta.files {
454 for event in &mut file.events {
455 event.author_idx = event
456 .author_idx
457 .and_then(|idx| author_mapping.get(&idx).copied());
458 }
459 base.files
460 .entry(path)
461 .and_modify(|existing| existing.events.append(&mut file.events))
462 .or_insert(file);
463 }
464}
465
466fn parse_git_log_events(stdout: &str, root: &Path) -> ChurnEventState {
468 let now_secs = std::time::SystemTime::now()
469 .duration_since(std::time::UNIX_EPOCH)
470 .unwrap_or_default()
471 .as_secs();
472
473 let mut files: FxHashMap<PathBuf, FileEvents> = FxHashMap::default();
474 let mut author_pool: Vec<String> = Vec::new();
475 let mut author_index: FxHashMap<String, u32> = FxHashMap::default();
476 let mut current_timestamp: Option<u64> = None;
477 let mut current_author_idx: Option<u32> = None;
478
479 for line in stdout.lines() {
480 let line = line.trim();
481 if line.is_empty() {
482 continue;
483 }
484
485 if let Some((ts_str, email)) = line.split_once('|')
487 && let Ok(ts) = ts_str.parse::<u64>()
488 {
489 current_timestamp = Some(ts);
490 current_author_idx = Some(intern_author(email, &mut author_pool, &mut author_index));
491 continue;
492 }
493
494 if let Ok(ts) = line.parse::<u64>() {
496 current_timestamp = Some(ts);
497 current_author_idx = None;
498 continue;
499 }
500
501 if let Some((added, deleted, path)) = parse_numstat_line(line) {
503 let abs_path = root.join(path);
504 let ts = current_timestamp.unwrap_or(now_secs);
505 files
506 .entry(abs_path)
507 .or_insert_with(|| FileEvents { events: Vec::new() })
508 .events
509 .push(CachedCommitEvent {
510 timestamp: ts,
511 lines_added: added,
512 lines_deleted: deleted,
513 author_idx: current_author_idx,
514 });
515 }
516 }
517
518 ChurnEventState { files, author_pool }
519}
520
521#[expect(
523 clippy::cast_possible_truncation,
524 reason = "commit count per file is bounded by git history depth"
525)]
526fn build_churn_result(state: ChurnEventState, shallow_clone: bool) -> ChurnResult {
527 let now_secs = std::time::SystemTime::now()
528 .duration_since(std::time::UNIX_EPOCH)
529 .unwrap_or_default()
530 .as_secs();
531
532 let files = state
533 .files
534 .into_iter()
535 .map(|(path, file)| {
536 let mut timestamps = Vec::with_capacity(file.events.len());
537 let mut weighted_commits = 0.0;
538 let mut lines_added = 0;
539 let mut lines_deleted = 0;
540 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
541
542 for event in file.events {
543 timestamps.push(event.timestamp);
544 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
545 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
546 weighted_commits += weight;
547 lines_added += event.lines_added;
548 lines_deleted += event.lines_deleted;
549
550 if let Some(idx) = event.author_idx {
551 authors
552 .entry(idx)
553 .and_modify(|c| {
554 c.commits += 1;
555 c.weighted_commits += weight;
556 c.first_commit_ts = c.first_commit_ts.min(event.timestamp);
557 c.last_commit_ts = c.last_commit_ts.max(event.timestamp);
558 })
559 .or_insert(AuthorContribution {
560 commits: 1,
561 weighted_commits: weight,
562 first_commit_ts: event.timestamp,
563 last_commit_ts: event.timestamp,
564 });
565 }
566 }
567
568 let commits = timestamps.len() as u32;
569 let trend = compute_trend(×tamps);
570 for c in authors.values_mut() {
572 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
573 }
574 let churn = FileChurn {
575 path: path.clone(),
576 commits,
577 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
578 lines_added,
579 lines_deleted,
580 trend,
581 authors,
582 };
583 (path, churn)
584 })
585 .collect();
586
587 ChurnResult {
588 files,
589 shallow_clone,
590 author_pool: state.author_pool,
591 }
592}
593
594#[cfg(test)]
599fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
600 let result = build_churn_result(parse_git_log_events(stdout, root), false);
601 (result.files, result.author_pool)
602}
603
604fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
606 if let Some(&idx) = index.get(email) {
607 return idx;
608 }
609 #[expect(
610 clippy::cast_possible_truncation,
611 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
612 )]
613 let idx = pool.len() as u32;
614 let owned = email.to_string();
615 index.insert(owned.clone(), idx);
616 pool.push(owned);
617 idx
618}
619
620fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
623 let mut parts = line.splitn(3, '\t');
624 let added_str = parts.next()?;
625 let deleted_str = parts.next()?;
626 let path = parts.next()?;
627
628 let added: u32 = added_str.parse().ok()?;
630 let deleted: u32 = deleted_str.parse().ok()?;
631
632 Some((added, deleted, path))
633}
634
635fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
643 if timestamps.len() < 2 {
644 return ChurnTrend::Stable;
645 }
646
647 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
648 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
649
650 if max_ts == min_ts {
651 return ChurnTrend::Stable;
652 }
653
654 let midpoint = min_ts + (max_ts - min_ts) / 2;
655 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
656 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
657
658 if older < 1.0 {
659 return ChurnTrend::Stable;
660 }
661
662 let ratio = recent / older;
663 if ratio > 1.5 {
664 ChurnTrend::Accelerating
665 } else if ratio < 0.67 {
666 ChurnTrend::Cooling
667 } else {
668 ChurnTrend::Stable
669 }
670}
671
672fn is_iso_date(input: &str) -> bool {
673 input.len() == 10
674 && input.as_bytes().get(4) == Some(&b'-')
675 && input.as_bytes().get(7) == Some(&b'-')
676 && input[..4].bytes().all(|b| b.is_ascii_digit())
677 && input[5..7].bytes().all(|b| b.is_ascii_digit())
678 && input[8..10].bytes().all(|b| b.is_ascii_digit())
679}
680
681fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
682 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
683 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
684 })?;
685 if pos == 0 {
686 return Err(format!(
687 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
688 ));
689 }
690 Ok((&input[..pos], &input[pos..]))
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
700 fn parse_since_months_short() {
701 let d = parse_since("6m").unwrap();
702 assert_eq!(d.git_after, "6 months ago");
703 assert_eq!(d.display, "6 months");
704 }
705
706 #[test]
707 fn parse_since_months_long() {
708 let d = parse_since("6months").unwrap();
709 assert_eq!(d.git_after, "6 months ago");
710 assert_eq!(d.display, "6 months");
711 }
712
713 #[test]
714 fn parse_since_days() {
715 let d = parse_since("90d").unwrap();
716 assert_eq!(d.git_after, "90 days ago");
717 assert_eq!(d.display, "90 days");
718 }
719
720 #[test]
721 fn parse_since_year_singular() {
722 let d = parse_since("1y").unwrap();
723 assert_eq!(d.git_after, "1 year ago");
724 assert_eq!(d.display, "1 year");
725 }
726
727 #[test]
728 fn parse_since_years_plural() {
729 let d = parse_since("2years").unwrap();
730 assert_eq!(d.git_after, "2 years ago");
731 assert_eq!(d.display, "2 years");
732 }
733
734 #[test]
735 fn parse_since_weeks() {
736 let d = parse_since("2w").unwrap();
737 assert_eq!(d.git_after, "2 weeks ago");
738 assert_eq!(d.display, "2 weeks");
739 }
740
741 #[test]
742 fn parse_since_iso_date() {
743 let d = parse_since("2025-06-01").unwrap();
744 assert_eq!(d.git_after, "2025-06-01");
745 assert_eq!(d.display, "2025-06-01");
746 }
747
748 #[test]
749 fn parse_since_month_singular() {
750 let d = parse_since("1month").unwrap();
751 assert_eq!(d.display, "1 month");
752 }
753
754 #[test]
755 fn parse_since_day_singular() {
756 let d = parse_since("1day").unwrap();
757 assert_eq!(d.display, "1 day");
758 }
759
760 #[test]
761 fn parse_since_zero_rejected() {
762 assert!(parse_since("0m").is_err());
763 }
764
765 #[test]
766 fn parse_since_no_unit_rejected() {
767 assert!(parse_since("90").is_err());
768 }
769
770 #[test]
771 fn parse_since_unknown_unit_rejected() {
772 assert!(parse_since("6x").is_err());
773 }
774
775 #[test]
776 fn parse_since_no_number_rejected() {
777 assert!(parse_since("months").is_err());
778 }
779
780 #[test]
783 fn numstat_normal() {
784 let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
785 assert_eq!(a, 10);
786 assert_eq!(d, 5);
787 assert_eq!(p, "src/file.ts");
788 }
789
790 #[test]
791 fn numstat_binary_skipped() {
792 assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
793 }
794
795 #[test]
796 fn numstat_zero_lines() {
797 let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
798 assert_eq!(a, 0);
799 assert_eq!(d, 0);
800 assert_eq!(p, "src/empty.ts");
801 }
802
803 #[test]
806 fn trend_empty_is_stable() {
807 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
808 }
809
810 #[test]
811 fn trend_single_commit_is_stable() {
812 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
813 }
814
815 #[test]
816 fn trend_accelerating() {
817 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
819 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
820 }
821
822 #[test]
823 fn trend_cooling() {
824 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
826 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
827 }
828
829 #[test]
830 fn trend_stable_even_distribution() {
831 let timestamps = vec![100, 200, 300, 700, 800, 900];
833 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
834 }
835
836 #[test]
837 fn trend_same_timestamp_is_stable() {
838 let timestamps = vec![500, 500, 500];
839 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
840 }
841
842 #[test]
845 fn iso_date_valid() {
846 assert!(is_iso_date("2025-06-01"));
847 assert!(is_iso_date("2025-12-31"));
848 }
849
850 #[test]
851 fn iso_date_with_time_rejected() {
852 assert!(!is_iso_date("2025-06-01T00:00:00"));
854 }
855
856 #[test]
857 fn iso_date_invalid() {
858 assert!(!is_iso_date("6months"));
859 assert!(!is_iso_date("2025"));
860 assert!(!is_iso_date("not-a-date"));
861 assert!(!is_iso_date("abcd-ef-gh"));
862 }
863
864 #[test]
867 fn trend_display() {
868 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
869 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
870 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
871 }
872
873 #[test]
876 fn parse_git_log_single_commit() {
877 let root = Path::new("/project");
878 let output = "1700000000\n10\t5\tsrc/index.ts\n";
879 let (result, _) = parse_git_log(output, root);
880 assert_eq!(result.len(), 1);
881 let churn = &result[&PathBuf::from("/project/src/index.ts")];
882 assert_eq!(churn.commits, 1);
883 assert_eq!(churn.lines_added, 10);
884 assert_eq!(churn.lines_deleted, 5);
885 }
886
887 #[test]
888 fn parse_git_log_multiple_commits_same_file() {
889 let root = Path::new("/project");
890 let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
891 let (result, _) = parse_git_log(output, root);
892 assert_eq!(result.len(), 1);
893 let churn = &result[&PathBuf::from("/project/src/index.ts")];
894 assert_eq!(churn.commits, 2);
895 assert_eq!(churn.lines_added, 13);
896 assert_eq!(churn.lines_deleted, 7);
897 }
898
899 #[test]
900 fn parse_git_log_multiple_files() {
901 let root = Path::new("/project");
902 let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
903 let (result, _) = parse_git_log(output, root);
904 assert_eq!(result.len(), 2);
905 assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
906 assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
907 }
908
909 #[test]
910 fn parse_git_log_empty_output() {
911 let root = Path::new("/project");
912 let (result, _) = parse_git_log("", root);
913 assert!(result.is_empty());
914 }
915
916 #[test]
917 fn parse_git_log_skips_binary_files() {
918 let root = Path::new("/project");
919 let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
920 let (result, _) = parse_git_log(output, root);
921 assert_eq!(result.len(), 1);
922 assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
923 }
924
925 #[test]
926 fn parse_git_log_weighted_commits_are_positive() {
927 let root = Path::new("/project");
928 let now_secs = std::time::SystemTime::now()
930 .duration_since(std::time::UNIX_EPOCH)
931 .unwrap()
932 .as_secs();
933 let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
934 let (result, _) = parse_git_log(&output, root);
935 let churn = &result[&PathBuf::from("/project/src/a.ts")];
936 assert!(
937 churn.weighted_commits > 0.0,
938 "weighted_commits should be positive for recent commits"
939 );
940 }
941
942 #[test]
945 fn trend_boundary_1_5x_ratio() {
946 let timestamps = vec![100, 200, 600, 800, 1000];
952 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
953 }
954
955 #[test]
956 fn trend_just_above_1_5x() {
957 let timestamps = vec![100, 600, 800, 1000];
962 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
963 }
964
965 #[test]
966 fn trend_boundary_0_67x_ratio() {
967 let timestamps = vec![100, 200, 300, 600, 1000];
973 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
974 }
975
976 #[test]
977 fn trend_two_timestamps_different() {
978 let timestamps = vec![100, 200];
983 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
984 }
985
986 #[test]
989 fn parse_since_week_singular() {
990 let d = parse_since("1week").unwrap();
991 assert_eq!(d.git_after, "1 week ago");
992 assert_eq!(d.display, "1 week");
993 }
994
995 #[test]
996 fn parse_since_weeks_long() {
997 let d = parse_since("3weeks").unwrap();
998 assert_eq!(d.git_after, "3 weeks ago");
999 assert_eq!(d.display, "3 weeks");
1000 }
1001
1002 #[test]
1003 fn parse_since_days_long() {
1004 let d = parse_since("30days").unwrap();
1005 assert_eq!(d.git_after, "30 days ago");
1006 assert_eq!(d.display, "30 days");
1007 }
1008
1009 #[test]
1010 fn parse_since_year_long() {
1011 let d = parse_since("1year").unwrap();
1012 assert_eq!(d.git_after, "1 year ago");
1013 assert_eq!(d.display, "1 year");
1014 }
1015
1016 #[test]
1017 fn parse_since_overflow_number_rejected() {
1018 let result = parse_since("99999999999999999999d");
1020 assert!(result.is_err());
1021 let err = result.unwrap_err();
1022 assert!(err.contains("invalid number"));
1023 }
1024
1025 #[test]
1026 fn parse_since_zero_days_rejected() {
1027 assert!(parse_since("0d").is_err());
1028 }
1029
1030 #[test]
1031 fn parse_since_zero_weeks_rejected() {
1032 assert!(parse_since("0w").is_err());
1033 }
1034
1035 #[test]
1036 fn parse_since_zero_years_rejected() {
1037 assert!(parse_since("0y").is_err());
1038 }
1039
1040 #[test]
1043 fn numstat_missing_path() {
1044 assert!(parse_numstat_line("10\t5").is_none());
1046 }
1047
1048 #[test]
1049 fn numstat_single_field() {
1050 assert!(parse_numstat_line("10").is_none());
1051 }
1052
1053 #[test]
1054 fn numstat_empty_string() {
1055 assert!(parse_numstat_line("").is_none());
1056 }
1057
1058 #[test]
1059 fn numstat_only_added_is_binary() {
1060 assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1062 }
1063
1064 #[test]
1065 fn numstat_only_deleted_is_binary() {
1066 assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1068 }
1069
1070 #[test]
1071 fn numstat_path_with_spaces() {
1072 let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1073 assert_eq!(a, 3);
1074 assert_eq!(d, 1);
1075 assert_eq!(p, "path with spaces/file.ts");
1076 }
1077
1078 #[test]
1079 fn numstat_large_numbers() {
1080 let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1081 assert_eq!(a, 9999);
1082 assert_eq!(d, 8888);
1083 assert_eq!(p, "src/big.ts");
1084 }
1085
1086 #[test]
1089 fn iso_date_wrong_separator_positions() {
1090 assert!(!is_iso_date("20-25-0601"));
1092 assert!(!is_iso_date("202506-01-"));
1093 }
1094
1095 #[test]
1096 fn iso_date_too_short() {
1097 assert!(!is_iso_date("2025-06-0"));
1098 }
1099
1100 #[test]
1101 fn iso_date_letters_in_day() {
1102 assert!(!is_iso_date("2025-06-ab"));
1103 }
1104
1105 #[test]
1106 fn iso_date_letters_in_month() {
1107 assert!(!is_iso_date("2025-ab-01"));
1108 }
1109
1110 #[test]
1113 fn split_number_unit_valid() {
1114 let (num, unit) = split_number_unit("42days").unwrap();
1115 assert_eq!(num, "42");
1116 assert_eq!(unit, "days");
1117 }
1118
1119 #[test]
1120 fn split_number_unit_single_digit() {
1121 let (num, unit) = split_number_unit("1m").unwrap();
1122 assert_eq!(num, "1");
1123 assert_eq!(unit, "m");
1124 }
1125
1126 #[test]
1127 fn split_number_unit_no_digits() {
1128 let err = split_number_unit("abc").unwrap_err();
1129 assert!(err.contains("must start with a number"));
1130 }
1131
1132 #[test]
1133 fn split_number_unit_no_unit() {
1134 let err = split_number_unit("123").unwrap_err();
1135 assert!(err.contains("requires a unit suffix"));
1136 }
1137
1138 #[test]
1141 fn parse_git_log_numstat_before_timestamp_uses_now() {
1142 let root = Path::new("/project");
1143 let output = "10\t5\tsrc/no_ts.ts\n";
1145 let (result, _) = parse_git_log(output, root);
1146 assert_eq!(result.len(), 1);
1147 let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1148 assert_eq!(churn.commits, 1);
1149 assert_eq!(churn.lines_added, 10);
1150 assert_eq!(churn.lines_deleted, 5);
1151 assert!(
1153 churn.weighted_commits > 0.9,
1154 "weight should be near 1.0 when timestamp defaults to now"
1155 );
1156 }
1157
1158 #[test]
1159 fn parse_git_log_whitespace_lines_ignored() {
1160 let root = Path::new("/project");
1161 let output = " \n1700000000\n \n10\t5\tsrc/a.ts\n \n";
1162 let (result, _) = parse_git_log(output, root);
1163 assert_eq!(result.len(), 1);
1164 }
1165
1166 #[test]
1167 fn parse_git_log_trend_is_computed_per_file() {
1168 let root = Path::new("/project");
1169 let output = "\
11711000\n5\t1\tsrc/old.ts\n\
11722000\n3\t1\tsrc/old.ts\n\
11731000\n1\t0\tsrc/hot.ts\n\
11741800\n1\t0\tsrc/hot.ts\n\
11751900\n1\t0\tsrc/hot.ts\n\
11761950\n1\t0\tsrc/hot.ts\n\
11772000\n1\t0\tsrc/hot.ts\n";
1178 let (result, _) = parse_git_log(output, root);
1179 let old = &result[&PathBuf::from("/project/src/old.ts")];
1180 let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1181 assert_eq!(old.commits, 2);
1182 assert_eq!(hot.commits, 5);
1183 assert_eq!(hot.trend, ChurnTrend::Accelerating);
1185 }
1186
1187 #[test]
1188 fn parse_git_log_weighted_decay_for_old_commits() {
1189 let root = Path::new("/project");
1190 let now = std::time::SystemTime::now()
1191 .duration_since(std::time::UNIX_EPOCH)
1192 .unwrap()
1193 .as_secs();
1194 let old_ts = now - (180 * 86_400);
1196 let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1197 let (result, _) = parse_git_log(&output, root);
1198 let churn = &result[&PathBuf::from("/project/src/old.ts")];
1199 assert!(
1200 churn.weighted_commits < 0.5,
1201 "180-day-old commit should weigh ~0.25, got {}",
1202 churn.weighted_commits
1203 );
1204 assert!(
1205 churn.weighted_commits > 0.1,
1206 "180-day-old commit should weigh ~0.25, got {}",
1207 churn.weighted_commits
1208 );
1209 }
1210
1211 #[test]
1212 fn parse_git_log_path_stored_as_absolute() {
1213 let root = Path::new("/my/project");
1214 let output = "1700000000\n1\t0\tlib/utils.ts\n";
1215 let (result, _) = parse_git_log(output, root);
1216 let key = PathBuf::from("/my/project/lib/utils.ts");
1217 assert!(result.contains_key(&key));
1218 assert_eq!(result[&key].path, key);
1219 }
1220
1221 #[test]
1222 fn parse_git_log_weighted_commits_rounded() {
1223 let root = Path::new("/project");
1224 let now = std::time::SystemTime::now()
1225 .duration_since(std::time::UNIX_EPOCH)
1226 .unwrap()
1227 .as_secs();
1228 let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1230 let (result, _) = parse_git_log(&output, root);
1231 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1232 let decimals = format!("{:.2}", churn.weighted_commits);
1234 assert_eq!(
1235 churn.weighted_commits.to_string().len(),
1236 decimals.len().min(churn.weighted_commits.to_string().len()),
1237 "weighted_commits should be rounded to at most 2 decimal places"
1238 );
1239 }
1240
1241 #[test]
1244 fn trend_serde_serialization() {
1245 assert_eq!(
1246 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1247 "\"accelerating\""
1248 );
1249 assert_eq!(
1250 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1251 "\"stable\""
1252 );
1253 assert_eq!(
1254 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1255 "\"cooling\""
1256 );
1257 }
1258
1259 #[test]
1262 fn parse_git_log_extracts_author_email() {
1263 let root = Path::new("/project");
1264 let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1265 let (result, pool) = parse_git_log(output, root);
1266 assert_eq!(pool, vec!["alice@example.com".to_string()]);
1267 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1268 assert_eq!(churn.authors.len(), 1);
1269 let alice = &churn.authors[&0];
1270 assert_eq!(alice.commits, 1);
1271 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1272 assert_eq!(alice.last_commit_ts, 1_700_000_000);
1273 }
1274
1275 #[test]
1276 fn parse_git_log_intern_dedupes_authors() {
1277 let root = Path::new("/project");
1278 let output = "\
12791700000000|alice@example.com
12801\t0\ta.ts
12811700100000|bob@example.com
12822\t1\tb.ts
12831700200000|alice@example.com
12843\t2\tc.ts
1285";
1286 let (_result, pool) = parse_git_log(output, root);
1287 assert_eq!(pool.len(), 2);
1288 assert!(pool.contains(&"alice@example.com".to_string()));
1289 assert!(pool.contains(&"bob@example.com".to_string()));
1290 }
1291
1292 #[test]
1293 fn parse_git_log_aggregates_per_author() {
1294 let root = Path::new("/project");
1295 let output = "\
12971700000000|alice@example.com
12981\t0\tsrc/index.ts
12991700100000|bob@example.com
13002\t0\tsrc/index.ts
13011700200000|alice@example.com
13021\t1\tsrc/index.ts
1303";
1304 let (result, pool) = parse_git_log(output, root);
1305 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1306 assert_eq!(churn.commits, 3);
1307 assert_eq!(churn.authors.len(), 2);
1308
1309 let alice_idx =
1310 u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1311 let alice = &churn.authors[&alice_idx];
1312 assert_eq!(alice.commits, 2);
1313 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1314 assert_eq!(alice.last_commit_ts, 1_700_200_000);
1315 }
1316
1317 #[test]
1318 fn parse_git_log_legacy_bare_timestamp_still_parses() {
1319 let root = Path::new("/project");
1321 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1322 let (result, pool) = parse_git_log(output, root);
1323 assert!(pool.is_empty());
1324 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1325 assert_eq!(churn.commits, 1);
1326 assert!(churn.authors.is_empty());
1327 }
1328
1329 #[test]
1332 fn intern_author_returns_existing_index() {
1333 let mut pool = Vec::new();
1334 let mut index = FxHashMap::default();
1335 let i1 = intern_author("alice@x", &mut pool, &mut index);
1336 let i2 = intern_author("alice@x", &mut pool, &mut index);
1337 assert_eq!(i1, i2);
1338 assert_eq!(pool.len(), 1);
1339 }
1340
1341 #[test]
1342 fn intern_author_assigns_sequential_indices() {
1343 let mut pool = Vec::new();
1344 let mut index = FxHashMap::default();
1345 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1346 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1347 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1348 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1349 }
1350
1351 fn git(root: &Path, args: &[&str]) {
1354 let status = std::process::Command::new("git")
1355 .args(args)
1356 .current_dir(root)
1357 .status()
1358 .expect("run git");
1359 assert!(status.success(), "git {args:?} failed");
1360 }
1361
1362 fn write(root: &Path, path: &str, contents: &str) {
1363 let path = root.join(path);
1364 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1365 std::fs::write(path, contents).unwrap();
1366 }
1367
1368 #[test]
1369 fn cached_churn_merges_new_commits_after_head_advances() {
1370 let repo = tempfile::tempdir().expect("create repo");
1371 let root = repo.path();
1372 git(root, &["init"]);
1373 git(root, &["config", "user.email", "churn@example.test"]);
1374 git(root, &["config", "user.name", "Churn Test"]);
1375 git(root, &["config", "commit.gpgsign", "false"]);
1376
1377 write(root, "src/a.ts", "export const a = 1;\n");
1378 git(root, &["add", "."]);
1379 git(root, &["commit", "-m", "initial"]);
1380
1381 let since = parse_since("1y").unwrap();
1382 let cache = tempfile::tempdir().expect("create cache dir");
1383 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1384 assert!(!cold_hit);
1385 let file = root.join("src/a.ts");
1386 assert_eq!(cold.files[&file].commits, 1);
1387
1388 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1389 assert!(warm_hit);
1390
1391 write(
1392 root,
1393 "src/a.ts",
1394 "export const a = 1;\nexport const b = 2;\n",
1395 );
1396 git(root, &["add", "."]);
1397 git(root, &["commit", "-m", "update a"]);
1398 let head = get_head_sha(root).unwrap();
1399
1400 let (incremental, incremental_hit) =
1401 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1402 assert!(incremental_hit);
1403 assert_eq!(incremental.files[&file].commits, 2);
1404
1405 let cache = load_churn_cache(cache.path(), &since.git_after).unwrap();
1406 assert_eq!(cache.last_indexed_sha, head);
1407 }
1408}