1use rustc_hash::FxHashMap;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Output};
9use std::sync::OnceLock;
10
11use serde::Deserialize;
12
13pub use fallow_types::churn::ChurnTrend;
14
15pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
21
22static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
23
24#[expect(
25 clippy::disallowed_methods,
26 reason = "engine-owned git spawn wrapper clears ambient git env before churn subprocesses"
27)]
28fn git_command() -> Command {
29 let mut command = Command::new("git");
30 crate::git_env::clear_ambient_git_env(&mut command);
31 command
32}
33
34pub fn set_spawn_hook(hook: ChurnSpawnHook) {
40 let _ = SPAWN_HOOK.set(hook);
41}
42
43fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
44 if let Some(hook) = SPAWN_HOOK.get() {
45 hook(command)
46 } else {
47 command.output()
48 }
49}
50
51const SECS_PER_DAY: f64 = 86_400.0;
53
54const HALF_LIFE_DAYS: f64 = 90.0;
57
58const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
60
61const MAX_CHURN_EVENTS: usize = 5_000_000;
67
68const MAX_FUTURE_TIMESTAMP_SECS: u64 = 365 * 24 * 60 * 60;
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SinceDuration {
80 pub git_after: String,
82 pub display: String,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct AuthorContribution {
92 pub commits: u32,
94 pub weighted_commits: f64,
96 pub first_commit_ts: u64,
98 pub last_commit_ts: u64,
100}
101
102#[derive(Debug, Clone)]
104pub struct FileChurn {
105 pub path: PathBuf,
107 pub commits: u32,
109 pub weighted_commits: f64,
111 pub lines_added: u32,
113 pub lines_deleted: u32,
115 pub trend: ChurnTrend,
117 pub authors: FxHashMap<u32, AuthorContribution>,
120}
121
122#[derive(Debug, Clone)]
124pub struct ChurnResult {
125 pub files: FxHashMap<PathBuf, FileChurn>,
127 pub shallow_clone: bool,
129 pub author_pool: Vec<String>,
132}
133
134pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
145 if is_iso_date(input) {
146 return Ok(SinceDuration {
147 git_after: input.to_string(),
148 display: input.to_string(),
149 });
150 }
151
152 let (num_str, unit) = split_number_unit(input)?;
153 let num: u64 = num_str
154 .parse()
155 .map_err(|_| format!("invalid number in --since: {input}"))?;
156
157 if num == 0 {
158 return Err("--since duration must be greater than 0".to_string());
159 }
160
161 match unit {
162 "d" | "day" | "days" => {
163 let s = if num == 1 { "" } else { "s" };
164 Ok(SinceDuration {
165 git_after: format!("{num} day{s} ago"),
166 display: format!("{num} day{s}"),
167 })
168 }
169 "w" | "week" | "weeks" => {
170 let s = if num == 1 { "" } else { "s" };
171 Ok(SinceDuration {
172 git_after: format!("{num} week{s} ago"),
173 display: format!("{num} week{s}"),
174 })
175 }
176 "m" | "month" | "months" => {
177 let s = if num == 1 { "" } else { "s" };
178 Ok(SinceDuration {
179 git_after: format!("{num} month{s} ago"),
180 display: format!("{num} month{s}"),
181 })
182 }
183 "y" | "year" | "years" => {
184 let s = if num == 1 { "" } else { "s" };
185 Ok(SinceDuration {
186 git_after: format!("{num} year{s} ago"),
187 display: format!("{num} year{s}"),
188 })
189 }
190 _ => Err(format!(
191 "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
192 )),
193 }
194}
195
196pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
200 let shallow = is_shallow_clone(root);
201 let state = analyze_churn_events(root, since, None)?;
202 Some(build_churn_result(state, shallow))
203}
204
205#[derive(Debug, Deserialize)]
211struct ChurnFileDoc {
212 schema: String,
213 #[serde(default)]
214 events: Vec<ChurnFileEvent>,
215}
216
217#[derive(Debug, Deserialize)]
222struct ChurnFileEvent {
223 path: String,
225 timestamp: u64,
227 #[serde(default)]
230 author: Option<String>,
231 added: u32,
233 deleted: u32,
235}
236
237pub fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
250 let raw = std::fs::read_to_string(path)
251 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
252 let doc: ChurnFileDoc = serde_json::from_str(&raw)
253 .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
254 if doc.schema != CHURN_FILE_SCHEMA {
255 return Err(format!(
256 "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
257 path.display(),
258 doc.schema
259 ));
260 }
261 if doc.events.len() > MAX_CHURN_EVENTS {
262 return Err(format!(
263 "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
264 path.display(),
265 doc.events.len()
266 ));
267 }
268
269 let state = churn_event_state_from_doc(&doc, path, root)?;
270 Ok(build_churn_result(state, false))
271}
272
273fn churn_event_state_from_doc(
278 doc: &ChurnFileDoc,
279 path: &Path,
280 root: &Path,
281) -> Result<ChurnEventState, String> {
282 let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
283
284 for event in &doc.events {
285 builder.push_event(event)?;
286 }
287
288 Ok(builder.finish())
289}
290
291fn churn_file_future_limit() -> u64 {
292 let now_secs = std::time::SystemTime::now()
293 .duration_since(std::time::UNIX_EPOCH)
294 .unwrap_or_default()
295 .as_secs();
296 now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
297}
298
299struct ChurnFileImportBuilder<'a> {
300 path: &'a Path,
301 root: &'a Path,
302 future_limit: u64,
303 files: FxHashMap<PathBuf, FileEvents>,
304 author_pool: Vec<String>,
305 author_index: FxHashMap<String, u32>,
306}
307
308impl<'a> ChurnFileImportBuilder<'a> {
309 fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
310 Self {
311 path,
312 root,
313 future_limit,
314 files: FxHashMap::default(),
315 author_pool: Vec::new(),
316 author_index: FxHashMap::default(),
317 }
318 }
319
320 fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
321 let rel = normalize_churn_event_path(self.path, &event.path)?;
322 validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
323
324 let abs_path = self.root.join(&rel);
325 let author_idx = self.intern_author(event.author.as_deref());
326 self.files
327 .entry(abs_path)
328 .or_insert_with(|| FileEvents { events: Vec::new() })
329 .events
330 .push(CachedCommitEvent {
331 timestamp: event.timestamp,
332 lines_added: event.added,
333 lines_deleted: event.deleted,
334 author_idx,
335 });
336 Ok(())
337 }
338
339 fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
340 author
341 .map(str::trim)
342 .filter(|email| !email.is_empty())
343 .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
344 }
345
346 fn finish(self) -> ChurnEventState {
347 ChurnEventState {
348 files: self.files,
349 author_pool: self.author_pool,
350 }
351 }
352}
353
354fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
355 let normalized = event_path.replace('\\', "/");
356 let rel = normalized.trim();
357 if rel.is_empty() {
358 return Err(format!(
359 "churn file {} has an event with an empty path",
360 path.display()
361 ));
362 }
363 Ok(rel.to_string())
364}
365
366fn validate_churn_event_timestamp(
367 path: &Path,
368 timestamp: u64,
369 future_limit: u64,
370 rel: &str,
371) -> Result<(), String> {
372 if timestamp <= future_limit {
373 return Ok(());
374 }
375
376 Err(format!(
377 "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
378 future; timestamps must be unix SECONDS (not milliseconds), UTC",
379 path.display(),
380 timestamp
381 ))
382}
383
384#[must_use]
386pub fn is_shallow_clone(root: &Path) -> bool {
387 let mut command = git_command();
388 command
389 .args(["rev-parse", "--is-shallow-repository"])
390 .current_dir(root);
391 command.output().is_ok_and(|o| {
392 String::from_utf8_lossy(&o.stdout)
393 .trim()
394 .eq_ignore_ascii_case("true")
395 })
396}
397
398#[must_use]
400pub fn is_git_repo(root: &Path) -> bool {
401 let mut command = git_command();
402 command
403 .args(["rev-parse", "--git-dir"])
404 .current_dir(root)
405 .stdout(std::process::Stdio::null())
406 .stderr(std::process::Stdio::null());
407 command.status().is_ok_and(|s| s.success())
408}
409
410const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
413
414const CHURN_CACHE_VERSION: u8 = 3;
418
419#[derive(Clone, bitcode::Encode, bitcode::Decode)]
421struct CachedCommitEvent {
422 timestamp: u64,
423 lines_added: u32,
424 lines_deleted: u32,
425 author_idx: Option<u32>,
426}
427
428#[derive(Clone, bitcode::Encode, bitcode::Decode)]
430struct CachedFileChurn {
431 path: String,
432 events: Vec<CachedCommitEvent>,
433}
434
435#[derive(Clone, bitcode::Encode, bitcode::Decode)]
437struct ChurnCache {
438 version: u8,
440 last_indexed_sha: String,
441 git_after: String,
442 files: Vec<CachedFileChurn>,
443 shallow_clone: bool,
444 author_pool: Vec<String>,
446}
447
448struct FileEvents {
450 events: Vec<CachedCommitEvent>,
451}
452
453struct ChurnEventState {
456 files: FxHashMap<PathBuf, FileEvents>,
457 author_pool: Vec<String>,
458}
459
460fn get_head_sha(root: &Path) -> Option<String> {
462 let mut command = git_command();
463 command.args(["rev-parse", "HEAD"]).current_dir(root);
464 command
465 .output()
466 .ok()
467 .filter(|o| o.status.success())
468 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
469}
470
471fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
473 let mut command = git_command();
474 command
475 .args(["merge-base", "--is-ancestor", ancestor, descendant])
476 .current_dir(root);
477 command.status().is_ok_and(|s| s.success())
478}
479
480fn load_churn_cache(cache_dir: &Path, git_after: &str) -> Option<ChurnCache> {
483 let cache_file = cache_dir.join("churn.bin");
484 let data = std::fs::read(&cache_file).ok()?;
485 if data.len() > MAX_CHURN_CACHE_SIZE {
486 return None;
487 }
488 let cache: ChurnCache = bitcode::decode(&data).ok()?;
489 if cache.version != CHURN_CACHE_VERSION || cache.git_after != git_after {
490 return None;
491 }
492 Some(cache)
493}
494
495fn save_churn_cache(
497 cache_dir: &Path,
498 last_indexed_sha: &str,
499 git_after: &str,
500 state: &ChurnEventState,
501 shallow_clone: bool,
502) {
503 let files: Vec<CachedFileChurn> = state
504 .files
505 .iter()
506 .map(|f| CachedFileChurn {
507 path: f.0.to_string_lossy().to_string(),
508 events: f.1.events.clone(),
509 })
510 .collect();
511 let cache = ChurnCache {
512 version: CHURN_CACHE_VERSION,
513 last_indexed_sha: last_indexed_sha.to_string(),
514 git_after: git_after.to_string(),
515 files,
516 shallow_clone,
517 author_pool: state.author_pool.clone(),
518 };
519 let _ = std::fs::create_dir_all(cache_dir);
520 let data = bitcode::encode(&cache);
521 let tmp = cache_dir.join("churn.bin.tmp");
522 if std::fs::write(&tmp, data).is_ok() {
523 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
524 }
525}
526
527pub fn analyze_churn_cached(
535 root: &Path,
536 since: &SinceDuration,
537 cache_dir: &Path,
538 no_cache: bool,
539) -> Option<(ChurnResult, bool)> {
540 let head_sha = get_head_sha(root)?;
541
542 if !no_cache && let Some(result) = try_reuse_churn_cache(root, since, cache_dir, &head_sha) {
543 return Some((result, true));
544 }
545
546 analyze_fresh_churn(root, since, cache_dir, no_cache, &head_sha).map(|result| (result, false))
547}
548
549fn try_reuse_churn_cache(
550 root: &Path,
551 since: &SinceDuration,
552 cache_dir: &Path,
553 head_sha: &str,
554) -> Option<ChurnResult> {
555 let cache = load_churn_cache(cache_dir, &since.git_after)?;
556 if cache.last_indexed_sha == head_sha {
557 let shallow_clone = cache.shallow_clone;
558 return Some(build_churn_result(cache.into_event_state(), shallow_clone));
559 }
560
561 if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
562 return None;
563 }
564
565 extend_churn_cache(root, since, cache_dir, head_sha, cache)
566}
567
568fn extend_churn_cache(
569 root: &Path,
570 since: &SinceDuration,
571 cache_dir: &Path,
572 head_sha: &str,
573 cache: ChurnCache,
574) -> Option<ChurnResult> {
575 let shallow_clone = is_shallow_clone(root);
576 let range = format!("{}..HEAD", cache.last_indexed_sha);
577 let delta = analyze_churn_events(root, since, Some(&range))?;
578 let mut state = cache.into_event_state();
579 merge_churn_states(&mut state, delta);
580 save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
581 Some(build_churn_result(state, shallow_clone))
582}
583
584fn analyze_fresh_churn(
585 root: &Path,
586 since: &SinceDuration,
587 cache_dir: &Path,
588 no_cache: bool,
589 head_sha: &str,
590) -> Option<ChurnResult> {
591 let shallow_clone = is_shallow_clone(root);
592 let state = analyze_churn_events(root, since, None)?;
593 if !no_cache {
594 save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
595 }
596
597 Some(build_churn_result(state, shallow_clone))
598}
599
600impl ChurnCache {
601 fn into_event_state(self) -> ChurnEventState {
602 let files = self
603 .files
604 .into_iter()
605 .map(|entry| {
606 (
607 PathBuf::from(entry.path),
608 FileEvents {
609 events: entry.events,
610 },
611 )
612 })
613 .collect();
614 ChurnEventState {
615 files,
616 author_pool: self.author_pool,
617 }
618 }
619}
620
621fn analyze_churn_events(
623 root: &Path,
624 since: &SinceDuration,
625 revision_range: Option<&str>,
626) -> Option<ChurnEventState> {
627 let mut command = git_command();
628 command.arg("log");
629 if let Some(range) = revision_range {
630 command.arg(range);
631 }
632 command
633 .args([
634 "--numstat",
635 "--no-merges",
636 "--no-renames",
637 "--use-mailmap",
638 "--format=format:%at|%ae",
639 &format!("--after={}", since.git_after),
640 ])
641 .current_dir(root);
642
643 let output = match spawn_output(&mut command) {
644 Ok(o) => o,
645 Err(e) => {
646 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
647 return None;
648 }
649 };
650
651 if !output.status.success() {
652 let stderr = String::from_utf8_lossy(&output.stderr);
653 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
654 return None;
655 }
656
657 let stdout = String::from_utf8_lossy(&output.stdout);
658 Some(parse_git_log_events(&stdout, root))
659}
660
661fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
663 let mut base_author_index: FxHashMap<String, u32> = base
664 .author_pool
665 .iter()
666 .enumerate()
667 .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
668 .collect();
669
670 let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
671 for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
672 let Ok(old_idx) = u32::try_from(old_idx) else {
673 continue;
674 };
675 let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
676 author_mapping.insert(old_idx, new_idx);
677 }
678
679 for (path, mut file) in delta.files {
680 for event in &mut file.events {
681 event.author_idx = event
682 .author_idx
683 .and_then(|idx| author_mapping.get(&idx).copied());
684 }
685 base.files
686 .entry(path)
687 .and_modify(|existing| existing.events.append(&mut file.events))
688 .or_insert(file);
689 }
690}
691
692fn parse_git_log_events(stdout: &str, root: &Path) -> ChurnEventState {
694 let now_secs = std::time::SystemTime::now()
695 .duration_since(std::time::UNIX_EPOCH)
696 .unwrap_or_default()
697 .as_secs();
698
699 let mut parser = GitLogEventParser::new(root, now_secs);
700
701 for line in stdout.lines() {
702 parser.consume_line(line);
703 }
704
705 parser.finish()
706}
707
708struct GitLogEventParser<'a> {
709 root: &'a Path,
710 now_secs: u64,
711 files: FxHashMap<PathBuf, FileEvents>,
712 author_pool: Vec<String>,
713 author_index: FxHashMap<String, u32>,
714 current_timestamp: Option<u64>,
715 current_author_idx: Option<u32>,
716}
717
718impl<'a> GitLogEventParser<'a> {
719 fn new(root: &'a Path, now_secs: u64) -> Self {
720 Self {
721 root,
722 now_secs,
723 files: FxHashMap::default(),
724 author_pool: Vec::new(),
725 author_index: FxHashMap::default(),
726 current_timestamp: None,
727 current_author_idx: None,
728 }
729 }
730
731 fn consume_line(&mut self, line: &str) {
732 let line = line.trim();
733 if line.is_empty() {
734 return;
735 }
736
737 if self.record_commit_header(line) {
738 return;
739 }
740 if self.record_legacy_timestamp(line) {
741 return;
742 }
743 self.record_numstat(line);
744 }
745
746 fn record_commit_header(&mut self, line: &str) -> bool {
747 let Some((ts_str, email)) = line.split_once('|') else {
748 return false;
749 };
750 let Ok(ts) = ts_str.parse::<u64>() else {
751 return false;
752 };
753
754 self.current_timestamp = Some(ts);
755 self.current_author_idx = Some(intern_author(
756 email,
757 &mut self.author_pool,
758 &mut self.author_index,
759 ));
760 true
761 }
762
763 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
764 let Ok(ts) = line.parse::<u64>() else {
765 return false;
766 };
767
768 self.current_timestamp = Some(ts);
769 self.current_author_idx = None;
770 true
771 }
772
773 fn record_numstat(&mut self, line: &str) {
774 let Some((added, deleted, path)) = parse_numstat_line(line) else {
775 return;
776 };
777
778 let ts = self.current_timestamp.unwrap_or(self.now_secs);
779 self.files
780 .entry(self.root.join(path))
781 .or_insert_with(|| FileEvents { events: Vec::new() })
782 .events
783 .push(CachedCommitEvent {
784 timestamp: ts,
785 lines_added: added,
786 lines_deleted: deleted,
787 author_idx: self.current_author_idx,
788 });
789 }
790
791 fn finish(self) -> ChurnEventState {
792 ChurnEventState {
793 files: self.files,
794 author_pool: self.author_pool,
795 }
796 }
797}
798
799#[expect(
802 clippy::cast_possible_truncation,
803 reason = "commit count per file is bounded by git history depth"
804)]
805fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
806 let mut timestamps = Vec::with_capacity(file.events.len());
807 let mut weighted_commits = 0.0;
808 let mut lines_added = 0;
809 let mut lines_deleted = 0;
810 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
811
812 for event in file.events {
813 timestamps.push(event.timestamp);
814 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
815 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
816 weighted_commits += weight;
817 lines_added += event.lines_added;
818 lines_deleted += event.lines_deleted;
819 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
820 }
821
822 let commits = timestamps.len() as u32;
823 let trend = compute_trend(×tamps);
824 for c in authors.values_mut() {
825 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
826 }
827 FileChurn {
828 path,
829 commits,
830 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
831 lines_added,
832 lines_deleted,
833 trend,
834 authors,
835 }
836}
837
838fn accumulate_author(
840 authors: &mut FxHashMap<u32, AuthorContribution>,
841 author_idx: Option<u32>,
842 weight: f64,
843 timestamp: u64,
844) {
845 let Some(idx) = author_idx else {
846 return;
847 };
848 authors
849 .entry(idx)
850 .and_modify(|c| {
851 c.commits += 1;
852 c.weighted_commits += weight;
853 c.first_commit_ts = c.first_commit_ts.min(timestamp);
854 c.last_commit_ts = c.last_commit_ts.max(timestamp);
855 })
856 .or_insert(AuthorContribution {
857 commits: 1,
858 weighted_commits: weight,
859 first_commit_ts: timestamp,
860 last_commit_ts: timestamp,
861 });
862}
863
864fn build_churn_result(state: ChurnEventState, shallow_clone: bool) -> ChurnResult {
866 let now_secs = std::time::SystemTime::now()
867 .duration_since(std::time::UNIX_EPOCH)
868 .unwrap_or_default()
869 .as_secs();
870
871 let files = state
872 .files
873 .into_iter()
874 .map(|(path, file)| {
875 let churn = aggregate_file_churn(path.clone(), file, now_secs);
876 (path, churn)
877 })
878 .collect();
879
880 ChurnResult {
881 files,
882 shallow_clone,
883 author_pool: state.author_pool,
884 }
885}
886
887#[cfg(test)]
892fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
893 let result = build_churn_result(parse_git_log_events(stdout, root), false);
894 (result.files, result.author_pool)
895}
896
897fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
899 if let Some(&idx) = index.get(email) {
900 return idx;
901 }
902 #[expect(
903 clippy::cast_possible_truncation,
904 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
905 )]
906 let idx = pool.len() as u32;
907 let owned = email.to_string();
908 index.insert(owned.clone(), idx);
909 pool.push(owned);
910 idx
911}
912
913fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
916 let mut parts = line.splitn(3, '\t');
917 let added_str = parts.next()?;
918 let deleted_str = parts.next()?;
919 let path = parts.next()?;
920
921 let added: u32 = added_str.parse().ok()?;
922 let deleted: u32 = deleted_str.parse().ok()?;
923
924 Some((added, deleted, path))
925}
926
927fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
935 if timestamps.len() < 2 {
936 return ChurnTrend::Stable;
937 }
938
939 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
940 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
941
942 if max_ts == min_ts {
943 return ChurnTrend::Stable;
944 }
945
946 let midpoint = min_ts + (max_ts - min_ts) / 2;
947 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
948 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
949
950 if older < 1.0 {
951 return ChurnTrend::Stable;
952 }
953
954 let ratio = recent / older;
955 if ratio > 1.5 {
956 ChurnTrend::Accelerating
957 } else if ratio < 0.67 {
958 ChurnTrend::Cooling
959 } else {
960 ChurnTrend::Stable
961 }
962}
963
964fn is_iso_date(input: &str) -> bool {
965 input.len() == 10
966 && input.as_bytes().get(4) == Some(&b'-')
967 && input.as_bytes().get(7) == Some(&b'-')
968 && input[..4].bytes().all(|b| b.is_ascii_digit())
969 && input[5..7].bytes().all(|b| b.is_ascii_digit())
970 && input[8..10].bytes().all(|b| b.is_ascii_digit())
971}
972
973fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
974 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
975 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
976 })?;
977 if pos == 0 {
978 return Err(format!(
979 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
980 ));
981 }
982 Ok((&input[..pos], &input[pos..]))
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 #[test]
990 fn parse_since_months_short() {
991 let d = parse_since("6m").unwrap();
992 assert_eq!(d.git_after, "6 months ago");
993 assert_eq!(d.display, "6 months");
994 }
995
996 #[test]
997 fn parse_since_months_long() {
998 let d = parse_since("6months").unwrap();
999 assert_eq!(d.git_after, "6 months ago");
1000 assert_eq!(d.display, "6 months");
1001 }
1002
1003 #[test]
1004 fn parse_since_days() {
1005 let d = parse_since("90d").unwrap();
1006 assert_eq!(d.git_after, "90 days ago");
1007 assert_eq!(d.display, "90 days");
1008 }
1009
1010 #[test]
1011 fn parse_since_year_singular() {
1012 let d = parse_since("1y").unwrap();
1013 assert_eq!(d.git_after, "1 year ago");
1014 assert_eq!(d.display, "1 year");
1015 }
1016
1017 #[test]
1018 fn parse_since_years_plural() {
1019 let d = parse_since("2years").unwrap();
1020 assert_eq!(d.git_after, "2 years ago");
1021 assert_eq!(d.display, "2 years");
1022 }
1023
1024 #[test]
1025 fn parse_since_weeks() {
1026 let d = parse_since("2w").unwrap();
1027 assert_eq!(d.git_after, "2 weeks ago");
1028 assert_eq!(d.display, "2 weeks");
1029 }
1030
1031 #[test]
1032 fn parse_since_iso_date() {
1033 let d = parse_since("2025-06-01").unwrap();
1034 assert_eq!(d.git_after, "2025-06-01");
1035 assert_eq!(d.display, "2025-06-01");
1036 }
1037
1038 #[test]
1039 fn parse_since_month_singular() {
1040 let d = parse_since("1month").unwrap();
1041 assert_eq!(d.display, "1 month");
1042 }
1043
1044 #[test]
1045 fn parse_since_day_singular() {
1046 let d = parse_since("1day").unwrap();
1047 assert_eq!(d.display, "1 day");
1048 }
1049
1050 #[test]
1051 fn parse_since_zero_rejected() {
1052 assert!(parse_since("0m").is_err());
1053 }
1054
1055 #[test]
1056 fn parse_since_no_unit_rejected() {
1057 assert!(parse_since("90").is_err());
1058 }
1059
1060 #[test]
1061 fn parse_since_unknown_unit_rejected() {
1062 assert!(parse_since("6x").is_err());
1063 }
1064
1065 #[test]
1066 fn parse_since_no_number_rejected() {
1067 assert!(parse_since("months").is_err());
1068 }
1069
1070 #[test]
1071 fn numstat_normal() {
1072 let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
1073 assert_eq!(a, 10);
1074 assert_eq!(d, 5);
1075 assert_eq!(p, "src/file.ts");
1076 }
1077
1078 #[test]
1079 fn numstat_binary_skipped() {
1080 assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
1081 }
1082
1083 #[test]
1084 fn numstat_zero_lines() {
1085 let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
1086 assert_eq!(a, 0);
1087 assert_eq!(d, 0);
1088 assert_eq!(p, "src/empty.ts");
1089 }
1090
1091 #[test]
1092 fn trend_empty_is_stable() {
1093 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1094 }
1095
1096 #[test]
1097 fn trend_single_commit_is_stable() {
1098 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1099 }
1100
1101 #[test]
1102 fn trend_accelerating() {
1103 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1104 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1105 }
1106
1107 #[test]
1108 fn trend_cooling() {
1109 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1110 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1111 }
1112
1113 #[test]
1114 fn trend_stable_even_distribution() {
1115 let timestamps = vec![100, 200, 300, 700, 800, 900];
1116 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1117 }
1118
1119 #[test]
1120 fn trend_same_timestamp_is_stable() {
1121 let timestamps = vec![500, 500, 500];
1122 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1123 }
1124
1125 #[test]
1126 fn iso_date_valid() {
1127 assert!(is_iso_date("2025-06-01"));
1128 assert!(is_iso_date("2025-12-31"));
1129 }
1130
1131 #[test]
1132 fn iso_date_with_time_rejected() {
1133 assert!(!is_iso_date("2025-06-01T00:00:00"));
1134 }
1135
1136 #[test]
1137 fn iso_date_invalid() {
1138 assert!(!is_iso_date("6months"));
1139 assert!(!is_iso_date("2025"));
1140 assert!(!is_iso_date("not-a-date"));
1141 assert!(!is_iso_date("abcd-ef-gh"));
1142 }
1143
1144 #[test]
1145 fn trend_display() {
1146 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1147 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1148 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1149 }
1150
1151 #[test]
1152 fn parse_git_log_single_commit() {
1153 let root = Path::new("/project");
1154 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1155 let (result, _) = parse_git_log(output, root);
1156 assert_eq!(result.len(), 1);
1157 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1158 assert_eq!(churn.commits, 1);
1159 assert_eq!(churn.lines_added, 10);
1160 assert_eq!(churn.lines_deleted, 5);
1161 }
1162
1163 #[test]
1164 fn parse_git_log_multiple_commits_same_file() {
1165 let root = Path::new("/project");
1166 let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
1167 let (result, _) = parse_git_log(output, root);
1168 assert_eq!(result.len(), 1);
1169 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1170 assert_eq!(churn.commits, 2);
1171 assert_eq!(churn.lines_added, 13);
1172 assert_eq!(churn.lines_deleted, 7);
1173 }
1174
1175 #[test]
1176 fn parse_git_log_multiple_files() {
1177 let root = Path::new("/project");
1178 let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
1179 let (result, _) = parse_git_log(output, root);
1180 assert_eq!(result.len(), 2);
1181 assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
1182 assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
1183 }
1184
1185 #[test]
1186 fn parse_git_log_empty_output() {
1187 let root = Path::new("/project");
1188 let (result, _) = parse_git_log("", root);
1189 assert!(result.is_empty());
1190 }
1191
1192 #[test]
1193 fn parse_git_log_skips_binary_files() {
1194 let root = Path::new("/project");
1195 let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
1196 let (result, _) = parse_git_log(output, root);
1197 assert_eq!(result.len(), 1);
1198 assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
1199 }
1200
1201 #[test]
1202 fn parse_git_log_weighted_commits_are_positive() {
1203 let root = Path::new("/project");
1204 let now_secs = std::time::SystemTime::now()
1205 .duration_since(std::time::UNIX_EPOCH)
1206 .unwrap()
1207 .as_secs();
1208 let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
1209 let (result, _) = parse_git_log(&output, root);
1210 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1211 assert!(
1212 churn.weighted_commits > 0.0,
1213 "weighted_commits should be positive for recent commits"
1214 );
1215 }
1216
1217 #[test]
1218 fn trend_boundary_1_5x_ratio() {
1219 let timestamps = vec![100, 200, 600, 800, 1000];
1220 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1221 }
1222
1223 #[test]
1224 fn trend_just_above_1_5x() {
1225 let timestamps = vec![100, 600, 800, 1000];
1226 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1227 }
1228
1229 #[test]
1230 fn trend_boundary_0_67x_ratio() {
1231 let timestamps = vec![100, 200, 300, 600, 1000];
1232 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1233 }
1234
1235 #[test]
1236 fn trend_two_timestamps_different() {
1237 let timestamps = vec![100, 200];
1238 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1239 }
1240
1241 #[test]
1242 fn parse_since_week_singular() {
1243 let d = parse_since("1week").unwrap();
1244 assert_eq!(d.git_after, "1 week ago");
1245 assert_eq!(d.display, "1 week");
1246 }
1247
1248 #[test]
1249 fn parse_since_weeks_long() {
1250 let d = parse_since("3weeks").unwrap();
1251 assert_eq!(d.git_after, "3 weeks ago");
1252 assert_eq!(d.display, "3 weeks");
1253 }
1254
1255 #[test]
1256 fn parse_since_days_long() {
1257 let d = parse_since("30days").unwrap();
1258 assert_eq!(d.git_after, "30 days ago");
1259 assert_eq!(d.display, "30 days");
1260 }
1261
1262 #[test]
1263 fn parse_since_year_long() {
1264 let d = parse_since("1year").unwrap();
1265 assert_eq!(d.git_after, "1 year ago");
1266 assert_eq!(d.display, "1 year");
1267 }
1268
1269 #[test]
1270 fn parse_since_overflow_number_rejected() {
1271 let result = parse_since("99999999999999999999d");
1272 assert!(result.is_err());
1273 let err = result.unwrap_err();
1274 assert!(err.contains("invalid number"));
1275 }
1276
1277 #[test]
1278 fn parse_since_zero_days_rejected() {
1279 assert!(parse_since("0d").is_err());
1280 }
1281
1282 #[test]
1283 fn parse_since_zero_weeks_rejected() {
1284 assert!(parse_since("0w").is_err());
1285 }
1286
1287 #[test]
1288 fn parse_since_zero_years_rejected() {
1289 assert!(parse_since("0y").is_err());
1290 }
1291
1292 #[test]
1293 fn numstat_missing_path() {
1294 assert!(parse_numstat_line("10\t5").is_none());
1295 }
1296
1297 #[test]
1298 fn numstat_single_field() {
1299 assert!(parse_numstat_line("10").is_none());
1300 }
1301
1302 #[test]
1303 fn numstat_empty_string() {
1304 assert!(parse_numstat_line("").is_none());
1305 }
1306
1307 #[test]
1308 fn numstat_only_added_is_binary() {
1309 assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1310 }
1311
1312 #[test]
1313 fn numstat_only_deleted_is_binary() {
1314 assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1315 }
1316
1317 #[test]
1318 fn numstat_path_with_spaces() {
1319 let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1320 assert_eq!(a, 3);
1321 assert_eq!(d, 1);
1322 assert_eq!(p, "path with spaces/file.ts");
1323 }
1324
1325 #[test]
1326 fn numstat_large_numbers() {
1327 let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1328 assert_eq!(a, 9999);
1329 assert_eq!(d, 8888);
1330 assert_eq!(p, "src/big.ts");
1331 }
1332
1333 #[test]
1334 fn iso_date_wrong_separator_positions() {
1335 assert!(!is_iso_date("20-25-0601"));
1336 assert!(!is_iso_date("202506-01-"));
1337 }
1338
1339 #[test]
1340 fn iso_date_too_short() {
1341 assert!(!is_iso_date("2025-06-0"));
1342 }
1343
1344 #[test]
1345 fn iso_date_letters_in_day() {
1346 assert!(!is_iso_date("2025-06-ab"));
1347 }
1348
1349 #[test]
1350 fn iso_date_letters_in_month() {
1351 assert!(!is_iso_date("2025-ab-01"));
1352 }
1353
1354 #[test]
1355 fn split_number_unit_valid() {
1356 let (num, unit) = split_number_unit("42days").unwrap();
1357 assert_eq!(num, "42");
1358 assert_eq!(unit, "days");
1359 }
1360
1361 #[test]
1362 fn split_number_unit_single_digit() {
1363 let (num, unit) = split_number_unit("1m").unwrap();
1364 assert_eq!(num, "1");
1365 assert_eq!(unit, "m");
1366 }
1367
1368 #[test]
1369 fn split_number_unit_no_digits() {
1370 let err = split_number_unit("abc").unwrap_err();
1371 assert!(err.contains("must start with a number"));
1372 }
1373
1374 #[test]
1375 fn split_number_unit_no_unit() {
1376 let err = split_number_unit("123").unwrap_err();
1377 assert!(err.contains("requires a unit suffix"));
1378 }
1379
1380 #[test]
1381 fn parse_git_log_numstat_before_timestamp_uses_now() {
1382 let root = Path::new("/project");
1383 let output = "10\t5\tsrc/no_ts.ts\n";
1384 let (result, _) = parse_git_log(output, root);
1385 assert_eq!(result.len(), 1);
1386 let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1387 assert_eq!(churn.commits, 1);
1388 assert_eq!(churn.lines_added, 10);
1389 assert_eq!(churn.lines_deleted, 5);
1390 assert!(
1391 churn.weighted_commits > 0.9,
1392 "weight should be near 1.0 when timestamp defaults to now"
1393 );
1394 }
1395
1396 #[test]
1397 fn parse_git_log_whitespace_lines_ignored() {
1398 let root = Path::new("/project");
1399 let output = " \n1700000000\n \n10\t5\tsrc/a.ts\n \n";
1400 let (result, _) = parse_git_log(output, root);
1401 assert_eq!(result.len(), 1);
1402 }
1403
1404 #[test]
1405 fn parse_git_log_trend_is_computed_per_file() {
1406 let root = Path::new("/project");
1407 let output = "\
14081000\n5\t1\tsrc/old.ts\n\
14092000\n3\t1\tsrc/old.ts\n\
14101000\n1\t0\tsrc/hot.ts\n\
14111800\n1\t0\tsrc/hot.ts\n\
14121900\n1\t0\tsrc/hot.ts\n\
14131950\n1\t0\tsrc/hot.ts\n\
14142000\n1\t0\tsrc/hot.ts\n";
1415 let (result, _) = parse_git_log(output, root);
1416 let old = &result[&PathBuf::from("/project/src/old.ts")];
1417 let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1418 assert_eq!(old.commits, 2);
1419 assert_eq!(hot.commits, 5);
1420 assert_eq!(hot.trend, ChurnTrend::Accelerating);
1421 }
1422
1423 #[test]
1424 fn parse_git_log_weighted_decay_for_old_commits() {
1425 let root = Path::new("/project");
1426 let now = std::time::SystemTime::now()
1427 .duration_since(std::time::UNIX_EPOCH)
1428 .unwrap()
1429 .as_secs();
1430 let old_ts = now - (180 * 86_400);
1431 let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1432 let (result, _) = parse_git_log(&output, root);
1433 let churn = &result[&PathBuf::from("/project/src/old.ts")];
1434 assert!(
1435 churn.weighted_commits < 0.5,
1436 "180-day-old commit should weigh ~0.25, got {}",
1437 churn.weighted_commits
1438 );
1439 assert!(
1440 churn.weighted_commits > 0.1,
1441 "180-day-old commit should weigh ~0.25, got {}",
1442 churn.weighted_commits
1443 );
1444 }
1445
1446 #[test]
1447 fn parse_git_log_path_stored_as_absolute() {
1448 let root = Path::new("/my/project");
1449 let output = "1700000000\n1\t0\tlib/utils.ts\n";
1450 let (result, _) = parse_git_log(output, root);
1451 let key = PathBuf::from("/my/project/lib/utils.ts");
1452 assert!(result.contains_key(&key));
1453 assert_eq!(result[&key].path, key);
1454 }
1455
1456 #[test]
1457 fn parse_git_log_weighted_commits_rounded() {
1458 let root = Path::new("/project");
1459 let now = std::time::SystemTime::now()
1460 .duration_since(std::time::UNIX_EPOCH)
1461 .unwrap()
1462 .as_secs();
1463 let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1464 let (result, _) = parse_git_log(&output, root);
1465 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1466 let decimals = format!("{:.2}", churn.weighted_commits);
1467 assert_eq!(
1468 churn.weighted_commits.to_string().len(),
1469 decimals.len().min(churn.weighted_commits.to_string().len()),
1470 "weighted_commits should be rounded to at most 2 decimal places"
1471 );
1472 }
1473
1474 #[test]
1475 fn trend_serde_serialization() {
1476 assert_eq!(
1477 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1478 "\"accelerating\""
1479 );
1480 assert_eq!(
1481 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1482 "\"stable\""
1483 );
1484 assert_eq!(
1485 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1486 "\"cooling\""
1487 );
1488 }
1489
1490 #[test]
1491 fn parse_git_log_extracts_author_email() {
1492 let root = Path::new("/project");
1493 let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1494 let (result, pool) = parse_git_log(output, root);
1495 assert_eq!(pool, vec!["alice@example.com".to_string()]);
1496 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1497 assert_eq!(churn.authors.len(), 1);
1498 let alice = &churn.authors[&0];
1499 assert_eq!(alice.commits, 1);
1500 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1501 assert_eq!(alice.last_commit_ts, 1_700_000_000);
1502 }
1503
1504 #[test]
1505 fn parse_git_log_intern_dedupes_authors() {
1506 let root = Path::new("/project");
1507 let output = "\
15081700000000|alice@example.com
15091\t0\ta.ts
15101700100000|bob@example.com
15112\t1\tb.ts
15121700200000|alice@example.com
15133\t2\tc.ts
1514";
1515 let (_result, pool) = parse_git_log(output, root);
1516 assert_eq!(pool.len(), 2);
1517 assert!(pool.contains(&"alice@example.com".to_string()));
1518 assert!(pool.contains(&"bob@example.com".to_string()));
1519 }
1520
1521 #[test]
1522 fn parse_git_log_aggregates_per_author() {
1523 let root = Path::new("/project");
1524 let output = "\
15251700000000|alice@example.com
15261\t0\tsrc/index.ts
15271700100000|bob@example.com
15282\t0\tsrc/index.ts
15291700200000|alice@example.com
15301\t1\tsrc/index.ts
1531";
1532 let (result, pool) = parse_git_log(output, root);
1533 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1534 assert_eq!(churn.commits, 3);
1535 assert_eq!(churn.authors.len(), 2);
1536
1537 let alice_idx =
1538 u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1539 let alice = &churn.authors[&alice_idx];
1540 assert_eq!(alice.commits, 2);
1541 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1542 assert_eq!(alice.last_commit_ts, 1_700_200_000);
1543 }
1544
1545 #[test]
1546 fn parse_git_log_legacy_bare_timestamp_still_parses() {
1547 let root = Path::new("/project");
1548 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1549 let (result, pool) = parse_git_log(output, root);
1550 assert!(pool.is_empty());
1551 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1552 assert_eq!(churn.commits, 1);
1553 assert!(churn.authors.is_empty());
1554 }
1555
1556 #[test]
1557 fn intern_author_returns_existing_index() {
1558 let mut pool = Vec::new();
1559 let mut index = FxHashMap::default();
1560 let i1 = intern_author("alice@x", &mut pool, &mut index);
1561 let i2 = intern_author("alice@x", &mut pool, &mut index);
1562 assert_eq!(i1, i2);
1563 assert_eq!(pool.len(), 1);
1564 }
1565
1566 #[test]
1567 fn intern_author_assigns_sequential_indices() {
1568 let mut pool = Vec::new();
1569 let mut index = FxHashMap::default();
1570 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1571 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1572 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1573 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1574 }
1575
1576 fn git(root: &Path, args: &[&str]) {
1577 let status = std::process::Command::new("git")
1578 .args(args)
1579 .current_dir(root)
1580 .status()
1581 .expect("run git");
1582 assert!(status.success(), "git {args:?} failed");
1583 }
1584
1585 fn write(root: &Path, path: &str, contents: &str) {
1586 let path = root.join(path);
1587 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1588 std::fs::write(path, contents).unwrap();
1589 }
1590
1591 #[test]
1592 fn cached_churn_merges_new_commits_after_head_advances() {
1593 let repo = tempfile::tempdir().expect("create repo");
1594 let root = repo.path();
1595 git(root, &["init"]);
1596 git(root, &["config", "user.email", "churn@example.test"]);
1597 git(root, &["config", "user.name", "Churn Test"]);
1598 git(root, &["config", "commit.gpgsign", "false"]);
1599
1600 write(root, "src/a.ts", "export const a = 1;\n");
1601 git(root, &["add", "."]);
1602 git(root, &["commit", "-m", "initial"]);
1603
1604 let since = parse_since("1y").unwrap();
1605 let cache = tempfile::tempdir().expect("create cache dir");
1606 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1607 assert!(!cold_hit);
1608 let file = root.join("src/a.ts");
1609 assert_eq!(cold.files[&file].commits, 1);
1610
1611 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1612 assert!(warm_hit);
1613
1614 write(
1615 root,
1616 "src/a.ts",
1617 "export const a = 1;\nexport const b = 2;\n",
1618 );
1619 git(root, &["add", "."]);
1620 git(root, &["commit", "-m", "update a"]);
1621 let head = get_head_sha(root).unwrap();
1622
1623 let (incremental, incremental_hit) =
1624 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1625 assert!(incremental_hit);
1626 assert_eq!(incremental.files[&file].commits, 2);
1627
1628 let cache = load_churn_cache(cache.path(), &since.git_after).unwrap();
1629 assert_eq!(cache.last_indexed_sha, head);
1630 }
1631
1632 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1633 let path = dir.join("churn.json");
1634 std::fs::write(&path, contents).unwrap();
1635 path
1636 }
1637
1638 #[test]
1639 fn churn_file_happy_path() {
1640 let dir = tempfile::tempdir().unwrap();
1641 let root = Path::new("/project");
1642 let path = write_churn_file(
1643 dir.path(),
1644 r#"{
1645 "schema": "fallow-churn/v1",
1646 "events": [
1647 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1648 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1649 ]
1650 }"#,
1651 );
1652 let result = analyze_churn_from_file(&path, root).unwrap();
1653 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1654 assert_eq!(churn.commits, 2);
1655 assert_eq!(churn.lines_added, 13);
1656 assert_eq!(churn.lines_deleted, 7);
1657 assert_eq!(churn.authors.len(), 2);
1658 assert!(result.author_pool.contains(&"alice@corp".to_string()));
1659 assert!(result.author_pool.contains(&"bob@corp".to_string()));
1660 assert!(!result.shallow_clone);
1661 }
1662
1663 #[test]
1664 fn churn_file_matches_git_parse() {
1665 let dir = tempfile::tempdir().unwrap();
1669 let root = Path::new("/project");
1670 let git_output = "1700000000|alice@corp\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n\n1700100000|bob@corp\n3\t2\tsrc/a.ts\n";
1671 let (git_files, git_pool) = parse_git_log(git_output, root);
1672
1673 let path = write_churn_file(
1674 dir.path(),
1675 r#"{
1676 "schema": "fallow-churn/v1",
1677 "events": [
1678 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1679 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
1680 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1681 ]
1682 }"#,
1683 );
1684 let imported = analyze_churn_from_file(&path, root).unwrap();
1685
1686 assert_eq!(git_pool, imported.author_pool, "author pools diverge");
1687 assert_eq!(git_files.len(), imported.files.len());
1688 for (file, git_churn) in &git_files {
1689 let imp = &imported.files[file];
1690 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
1691 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
1692 assert_eq!(
1693 git_churn.lines_deleted, imp.lines_deleted,
1694 "deleted for {file:?}"
1695 );
1696 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
1697 assert_eq!(
1698 git_churn.authors.len(),
1699 imp.authors.len(),
1700 "authors for {file:?}"
1701 );
1702 assert!(
1703 (git_churn.weighted_commits - imp.weighted_commits).abs() < 0.02,
1704 "weighted_commits for {file:?}: {} vs {}",
1705 git_churn.weighted_commits,
1706 imp.weighted_commits
1707 );
1708 }
1709 }
1710
1711 #[test]
1712 fn churn_file_empty_events_is_valid() {
1713 let dir = tempfile::tempdir().unwrap();
1714 let path = write_churn_file(
1715 dir.path(),
1716 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1717 );
1718 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1719 assert!(result.files.is_empty());
1720 assert!(result.author_pool.is_empty());
1721 }
1722
1723 #[test]
1724 fn churn_file_missing_events_key_is_valid() {
1725 let dir = tempfile::tempdir().unwrap();
1726 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1727 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1728 assert!(result.files.is_empty());
1729 }
1730
1731 #[test]
1732 fn churn_file_bad_schema_rejected() {
1733 let dir = tempfile::tempdir().unwrap();
1734 let path = write_churn_file(
1735 dir.path(),
1736 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1737 );
1738 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1739 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
1740 }
1741
1742 #[test]
1743 fn churn_file_malformed_json_rejected() {
1744 let dir = tempfile::tempdir().unwrap();
1745 let path = write_churn_file(dir.path(), "{ not json");
1746 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
1747 }
1748
1749 #[test]
1750 fn churn_file_missing_file_rejected() {
1751 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
1752 .unwrap_err();
1753 assert!(err.contains("failed to read churn file"), "{err}");
1754 }
1755
1756 #[test]
1757 fn churn_file_empty_path_rejected() {
1758 let dir = tempfile::tempdir().unwrap();
1759 let path = write_churn_file(
1760 dir.path(),
1761 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1762 );
1763 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1764 assert!(err.contains("empty path"), "{err}");
1765 }
1766
1767 #[test]
1768 fn churn_file_millisecond_timestamp_rejected() {
1769 let dir = tempfile::tempdir().unwrap();
1770 let path = write_churn_file(
1772 dir.path(),
1773 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
1774 );
1775 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1776 assert!(err.contains("milliseconds"), "{err}");
1777 }
1778
1779 #[test]
1780 fn churn_file_missing_author_contributes_no_signal() {
1781 let dir = tempfile::tempdir().unwrap();
1782 let path = write_churn_file(
1783 dir.path(),
1784 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1785 );
1786 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1787 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1788 assert_eq!(churn.commits, 1);
1789 assert!(churn.authors.is_empty());
1790 assert!(result.author_pool.is_empty());
1791 }
1792
1793 #[test]
1794 fn churn_file_empty_author_string_treated_as_absent() {
1795 let dir = tempfile::tempdir().unwrap();
1796 let path = write_churn_file(
1797 dir.path(),
1798 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
1799 );
1800 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1801 assert!(result.author_pool.is_empty());
1802 }
1803
1804 #[test]
1805 fn churn_file_unknown_fields_ignored() {
1806 let dir = tempfile::tempdir().unwrap();
1809 let path = write_churn_file(
1810 dir.path(),
1811 r#"{ "schema": "fallow-churn/v1", "extra": true, "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 1, "deleted": 0, "commit": "abc123", "tz": "+0200" } ] }"#,
1812 );
1813 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1814 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
1815 }
1816
1817 #[test]
1818 fn churn_file_backslash_paths_normalized() {
1819 let dir = tempfile::tempdir().unwrap();
1820 let path = write_churn_file(
1821 dir.path(),
1822 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1823 );
1824 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1825 assert!(
1826 result
1827 .files
1828 .contains_key(&PathBuf::from("/project/src/a.ts"))
1829 );
1830 }
1831}