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 = 5;
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: Vec<u8>,
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: path_to_cache_bytes(f.0),
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 .filter_map(|entry| {
606 path_from_cache_bytes(&entry.path).map(|path| {
607 (
608 path,
609 FileEvents {
610 events: entry.events,
611 },
612 )
613 })
614 })
615 .collect();
616 ChurnEventState {
617 files,
618 author_pool: self.author_pool,
619 }
620 }
621}
622
623#[cfg(unix)]
624fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
625 use std::os::unix::ffi::OsStrExt;
626
627 path.as_os_str().as_bytes().to_vec()
628}
629
630#[cfg(unix)]
631#[allow(
632 clippy::unnecessary_wraps,
633 reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
634)]
635fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
636 use std::ffi::OsStr;
637 use std::os::unix::ffi::OsStrExt;
638
639 Some(PathBuf::from(OsStr::from_bytes(path)))
640}
641
642#[cfg(windows)]
643fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
644 use std::os::windows::ffi::OsStrExt;
645
646 path.as_os_str()
647 .encode_wide()
648 .flat_map(u16::to_le_bytes)
649 .collect()
650}
651
652#[cfg(windows)]
653fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
654 use std::ffi::OsString;
655 use std::os::windows::ffi::OsStringExt;
656
657 let chunks = path.chunks_exact(2);
658 if !chunks.remainder().is_empty() {
659 return None;
660 }
661 let wide: Vec<u16> = chunks
662 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
663 .collect();
664 Some(PathBuf::from(OsString::from_wide(&wide)))
665}
666
667fn analyze_churn_events(
669 root: &Path,
670 since: &SinceDuration,
671 revision_range: Option<&str>,
672) -> Option<ChurnEventState> {
673 let mut command = git_command();
674 command.arg("log");
675 if let Some(range) = revision_range {
676 command.arg(range);
677 }
678 command
679 .args([
680 "--numstat",
681 "--no-merges",
682 "--no-renames",
683 "--use-mailmap",
684 "-z",
685 "--format=format:%at|%ae%x00",
686 &format!("--after={}", since.git_after),
687 ])
688 .current_dir(root);
689
690 let output = match spawn_output(&mut command) {
691 Ok(o) => o,
692 Err(e) => {
693 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
694 return None;
695 }
696 };
697
698 if !output.status.success() {
699 let stderr = String::from_utf8_lossy(&output.stderr);
700 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
701 return None;
702 }
703
704 Some(parse_git_log_events_z(&output.stdout, root))
705}
706
707fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
709 let mut base_author_index: FxHashMap<String, u32> = base
710 .author_pool
711 .iter()
712 .enumerate()
713 .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
714 .collect();
715
716 let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
717 for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
718 let Ok(old_idx) = u32::try_from(old_idx) else {
719 continue;
720 };
721 let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
722 author_mapping.insert(old_idx, new_idx);
723 }
724
725 for (path, mut file) in delta.files {
726 for event in &mut file.events {
727 event.author_idx = event
728 .author_idx
729 .and_then(|idx| author_mapping.get(&idx).copied());
730 }
731 base.files
732 .entry(path)
733 .and_modify(|existing| existing.events.append(&mut file.events))
734 .or_insert(file);
735 }
736}
737
738#[cfg(test)]
740fn parse_git_log_events(stdout: &str, root: &Path) -> ChurnEventState {
741 let now_secs = std::time::SystemTime::now()
742 .duration_since(std::time::UNIX_EPOCH)
743 .unwrap_or_default()
744 .as_secs();
745
746 let mut parser = GitLogEventParser::new(root, now_secs);
747
748 for line in stdout.lines() {
749 parser.consume_line(line);
750 }
751
752 parser.finish()
753}
754
755fn parse_git_log_events_z(stdout: &[u8], root: &Path) -> ChurnEventState {
756 let now_secs = std::time::SystemTime::now()
757 .duration_since(std::time::UNIX_EPOCH)
758 .unwrap_or_default()
759 .as_secs();
760
761 let mut parser = GitLogEventParser::new(root, now_secs);
762 for record in stdout.split(|byte| *byte == 0) {
763 let record = record.strip_prefix(b"\n").unwrap_or(record);
764 if record.is_empty() {
765 continue;
766 }
767 if record.contains(&b'\t') {
768 parser.record_numstat_bytes(record);
769 } else {
770 parser.consume_line(&String::from_utf8_lossy(record));
771 }
772 }
773 parser.finish()
774}
775
776struct GitLogEventParser<'a> {
777 root: &'a Path,
778 now_secs: u64,
779 files: FxHashMap<PathBuf, FileEvents>,
780 author_pool: Vec<String>,
781 author_index: FxHashMap<String, u32>,
782 current_timestamp: Option<u64>,
783 current_author_idx: Option<u32>,
784}
785
786impl<'a> GitLogEventParser<'a> {
787 fn new(root: &'a Path, now_secs: u64) -> Self {
788 Self {
789 root,
790 now_secs,
791 files: FxHashMap::default(),
792 author_pool: Vec::new(),
793 author_index: FxHashMap::default(),
794 current_timestamp: None,
795 current_author_idx: None,
796 }
797 }
798
799 fn consume_line(&mut self, line: &str) {
800 let line = line.trim();
801 if line.is_empty() {
802 return;
803 }
804
805 if self.record_commit_header(line) {
806 return;
807 }
808 if self.record_legacy_timestamp(line) {
809 return;
810 }
811 self.record_numstat(line);
812 }
813
814 fn record_commit_header(&mut self, line: &str) -> bool {
815 let Some((ts_str, email)) = line.split_once('|') else {
816 return false;
817 };
818 let Ok(ts) = ts_str.parse::<u64>() else {
819 return false;
820 };
821
822 self.current_timestamp = Some(ts);
823 self.current_author_idx = Some(intern_author(
824 email,
825 &mut self.author_pool,
826 &mut self.author_index,
827 ));
828 true
829 }
830
831 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
832 let Ok(ts) = line.parse::<u64>() else {
833 return false;
834 };
835
836 self.current_timestamp = Some(ts);
837 self.current_author_idx = None;
838 true
839 }
840
841 fn record_numstat(&mut self, line: &str) {
842 let Some((added, deleted, path)) = parse_numstat_line(line) else {
843 return;
844 };
845
846 self.record_numstat_path(added, deleted, PathBuf::from(path));
847 }
848
849 fn record_numstat_bytes(&mut self, record: &[u8]) {
850 let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
851 return;
852 };
853 let Some(second_tab_offset) = record[first_tab + 1..]
854 .iter()
855 .position(|byte| *byte == b'\t')
856 else {
857 return;
858 };
859 let second_tab = first_tab + 1 + second_tab_offset;
860 let Some(added) = std::str::from_utf8(&record[..first_tab])
861 .ok()
862 .and_then(|value| value.parse().ok())
863 else {
864 return;
865 };
866 let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
867 .ok()
868 .and_then(|value| value.parse().ok())
869 else {
870 return;
871 };
872 let path = git_path_from_bytes(&record[second_tab + 1..]);
873 self.record_numstat_path(added, deleted, path);
874 }
875
876 fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
877 let ts = self.current_timestamp.unwrap_or(self.now_secs);
878 self.files
879 .entry(self.root.join(path))
880 .or_insert_with(|| FileEvents { events: Vec::new() })
881 .events
882 .push(CachedCommitEvent {
883 timestamp: ts,
884 lines_added: added,
885 lines_deleted: deleted,
886 author_idx: self.current_author_idx,
887 });
888 }
889
890 fn finish(self) -> ChurnEventState {
891 ChurnEventState {
892 files: self.files,
893 author_pool: self.author_pool,
894 }
895 }
896}
897
898#[cfg(unix)]
899fn git_path_from_bytes(path: &[u8]) -> PathBuf {
900 use std::ffi::OsString;
901 use std::os::unix::ffi::OsStringExt;
902
903 PathBuf::from(OsString::from_vec(path.to_vec()))
904}
905
906#[cfg(windows)]
907fn git_path_from_bytes(path: &[u8]) -> PathBuf {
908 PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
909}
910
911#[expect(
914 clippy::cast_possible_truncation,
915 reason = "commit count per file is bounded by git history depth"
916)]
917fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
918 let mut timestamps = Vec::with_capacity(file.events.len());
919 let mut weighted_commits = 0.0;
920 let mut lines_added = 0;
921 let mut lines_deleted = 0;
922 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
923
924 for event in file.events {
925 timestamps.push(event.timestamp);
926 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
927 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
928 weighted_commits += weight;
929 lines_added += event.lines_added;
930 lines_deleted += event.lines_deleted;
931 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
932 }
933
934 let commits = timestamps.len() as u32;
935 let trend = compute_trend(×tamps);
936 for c in authors.values_mut() {
937 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
938 }
939 FileChurn {
940 path,
941 commits,
942 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
943 lines_added,
944 lines_deleted,
945 trend,
946 authors,
947 }
948}
949
950fn accumulate_author(
952 authors: &mut FxHashMap<u32, AuthorContribution>,
953 author_idx: Option<u32>,
954 weight: f64,
955 timestamp: u64,
956) {
957 let Some(idx) = author_idx else {
958 return;
959 };
960 authors
961 .entry(idx)
962 .and_modify(|c| {
963 c.commits += 1;
964 c.weighted_commits += weight;
965 c.first_commit_ts = c.first_commit_ts.min(timestamp);
966 c.last_commit_ts = c.last_commit_ts.max(timestamp);
967 })
968 .or_insert(AuthorContribution {
969 commits: 1,
970 weighted_commits: weight,
971 first_commit_ts: timestamp,
972 last_commit_ts: timestamp,
973 });
974}
975
976fn build_churn_result(state: ChurnEventState, shallow_clone: bool) -> ChurnResult {
978 let now_secs = std::time::SystemTime::now()
979 .duration_since(std::time::UNIX_EPOCH)
980 .unwrap_or_default()
981 .as_secs();
982
983 let files = state
984 .files
985 .into_iter()
986 .map(|(path, file)| {
987 let churn = aggregate_file_churn(path.clone(), file, now_secs);
988 (path, churn)
989 })
990 .collect();
991
992 ChurnResult {
993 files,
994 shallow_clone,
995 author_pool: state.author_pool,
996 }
997}
998
999#[cfg(test)]
1004fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
1005 let result = build_churn_result(parse_git_log_events(stdout, root), false);
1006 (result.files, result.author_pool)
1007}
1008
1009fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1011 if let Some(&idx) = index.get(email) {
1012 return idx;
1013 }
1014 #[expect(
1015 clippy::cast_possible_truncation,
1016 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1017 )]
1018 let idx = pool.len() as u32;
1019 let owned = email.to_string();
1020 index.insert(owned.clone(), idx);
1021 pool.push(owned);
1022 idx
1023}
1024
1025fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
1028 let mut parts = line.splitn(3, '\t');
1029 let added_str = parts.next()?;
1030 let deleted_str = parts.next()?;
1031 let path = parts.next()?;
1032
1033 let added: u32 = added_str.parse().ok()?;
1034 let deleted: u32 = deleted_str.parse().ok()?;
1035
1036 Some((added, deleted, path))
1037}
1038
1039fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1047 if timestamps.len() < 2 {
1048 return ChurnTrend::Stable;
1049 }
1050
1051 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1052 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1053
1054 if max_ts == min_ts {
1055 return ChurnTrend::Stable;
1056 }
1057
1058 let midpoint = min_ts + (max_ts - min_ts) / 2;
1059 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1060 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1061
1062 if older < 1.0 {
1063 return ChurnTrend::Stable;
1064 }
1065
1066 let ratio = recent / older;
1067 if ratio > 1.5 {
1068 ChurnTrend::Accelerating
1069 } else if ratio < 0.67 {
1070 ChurnTrend::Cooling
1071 } else {
1072 ChurnTrend::Stable
1073 }
1074}
1075
1076fn is_iso_date(input: &str) -> bool {
1077 input.len() == 10
1078 && input.as_bytes().get(4) == Some(&b'-')
1079 && input.as_bytes().get(7) == Some(&b'-')
1080 && input[..4].bytes().all(|b| b.is_ascii_digit())
1081 && input[5..7].bytes().all(|b| b.is_ascii_digit())
1082 && input[8..10].bytes().all(|b| b.is_ascii_digit())
1083}
1084
1085fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1086 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1087 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1088 })?;
1089 if pos == 0 {
1090 return Err(format!(
1091 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1092 ));
1093 }
1094 Ok((&input[..pos], &input[pos..]))
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100
1101 #[test]
1102 fn parse_since_months_short() {
1103 let d = parse_since("6m").unwrap();
1104 assert_eq!(d.git_after, "6 months ago");
1105 assert_eq!(d.display, "6 months");
1106 }
1107
1108 #[test]
1109 fn parse_since_months_long() {
1110 let d = parse_since("6months").unwrap();
1111 assert_eq!(d.git_after, "6 months ago");
1112 assert_eq!(d.display, "6 months");
1113 }
1114
1115 #[test]
1116 fn parse_since_days() {
1117 let d = parse_since("90d").unwrap();
1118 assert_eq!(d.git_after, "90 days ago");
1119 assert_eq!(d.display, "90 days");
1120 }
1121
1122 #[test]
1123 fn parse_since_year_singular() {
1124 let d = parse_since("1y").unwrap();
1125 assert_eq!(d.git_after, "1 year ago");
1126 assert_eq!(d.display, "1 year");
1127 }
1128
1129 #[test]
1130 fn parse_since_years_plural() {
1131 let d = parse_since("2years").unwrap();
1132 assert_eq!(d.git_after, "2 years ago");
1133 assert_eq!(d.display, "2 years");
1134 }
1135
1136 #[test]
1137 fn parse_since_weeks() {
1138 let d = parse_since("2w").unwrap();
1139 assert_eq!(d.git_after, "2 weeks ago");
1140 assert_eq!(d.display, "2 weeks");
1141 }
1142
1143 #[test]
1144 fn parse_since_iso_date() {
1145 let d = parse_since("2025-06-01").unwrap();
1146 assert_eq!(d.git_after, "2025-06-01");
1147 assert_eq!(d.display, "2025-06-01");
1148 }
1149
1150 #[test]
1151 fn parse_since_month_singular() {
1152 let d = parse_since("1month").unwrap();
1153 assert_eq!(d.display, "1 month");
1154 }
1155
1156 #[test]
1157 fn parse_since_day_singular() {
1158 let d = parse_since("1day").unwrap();
1159 assert_eq!(d.display, "1 day");
1160 }
1161
1162 #[test]
1163 fn parse_since_zero_rejected() {
1164 assert!(parse_since("0m").is_err());
1165 }
1166
1167 #[test]
1168 fn parse_since_no_unit_rejected() {
1169 assert!(parse_since("90").is_err());
1170 }
1171
1172 #[test]
1173 fn parse_since_unknown_unit_rejected() {
1174 assert!(parse_since("6x").is_err());
1175 }
1176
1177 #[test]
1178 fn parse_since_no_number_rejected() {
1179 assert!(parse_since("months").is_err());
1180 }
1181
1182 #[test]
1183 fn numstat_normal() {
1184 let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
1185 assert_eq!(a, 10);
1186 assert_eq!(d, 5);
1187 assert_eq!(p, "src/file.ts");
1188 }
1189
1190 #[test]
1191 fn numstat_binary_skipped() {
1192 assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
1193 }
1194
1195 #[test]
1196 fn numstat_zero_lines() {
1197 let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
1198 assert_eq!(a, 0);
1199 assert_eq!(d, 0);
1200 assert_eq!(p, "src/empty.ts");
1201 }
1202
1203 #[test]
1204 fn trend_empty_is_stable() {
1205 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1206 }
1207
1208 #[test]
1209 fn trend_single_commit_is_stable() {
1210 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1211 }
1212
1213 #[test]
1214 fn trend_accelerating() {
1215 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1216 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1217 }
1218
1219 #[test]
1220 fn trend_cooling() {
1221 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1222 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1223 }
1224
1225 #[test]
1226 fn trend_stable_even_distribution() {
1227 let timestamps = vec![100, 200, 300, 700, 800, 900];
1228 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1229 }
1230
1231 #[test]
1232 fn trend_same_timestamp_is_stable() {
1233 let timestamps = vec![500, 500, 500];
1234 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1235 }
1236
1237 #[test]
1238 fn iso_date_valid() {
1239 assert!(is_iso_date("2025-06-01"));
1240 assert!(is_iso_date("2025-12-31"));
1241 }
1242
1243 #[test]
1244 fn iso_date_with_time_rejected() {
1245 assert!(!is_iso_date("2025-06-01T00:00:00"));
1246 }
1247
1248 #[test]
1249 fn iso_date_invalid() {
1250 assert!(!is_iso_date("6months"));
1251 assert!(!is_iso_date("2025"));
1252 assert!(!is_iso_date("not-a-date"));
1253 assert!(!is_iso_date("abcd-ef-gh"));
1254 }
1255
1256 #[test]
1257 fn trend_display() {
1258 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1259 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1260 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1261 }
1262
1263 #[test]
1264 fn parse_git_log_single_commit() {
1265 let root = Path::new("/project");
1266 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1267 let (result, _) = parse_git_log(output, root);
1268 assert_eq!(result.len(), 1);
1269 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1270 assert_eq!(churn.commits, 1);
1271 assert_eq!(churn.lines_added, 10);
1272 assert_eq!(churn.lines_deleted, 5);
1273 }
1274
1275 #[test]
1276 fn parse_git_log_multiple_commits_same_file() {
1277 let root = Path::new("/project");
1278 let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
1279 let (result, _) = parse_git_log(output, root);
1280 assert_eq!(result.len(), 1);
1281 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1282 assert_eq!(churn.commits, 2);
1283 assert_eq!(churn.lines_added, 13);
1284 assert_eq!(churn.lines_deleted, 7);
1285 }
1286
1287 #[test]
1288 fn parse_git_log_multiple_files() {
1289 let root = Path::new("/project");
1290 let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
1291 let (result, _) = parse_git_log(output, root);
1292 assert_eq!(result.len(), 2);
1293 assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
1294 assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
1295 }
1296
1297 #[test]
1298 fn parse_git_log_empty_output() {
1299 let root = Path::new("/project");
1300 let (result, _) = parse_git_log("", root);
1301 assert!(result.is_empty());
1302 }
1303
1304 #[test]
1305 fn parse_git_log_skips_binary_files() {
1306 let root = Path::new("/project");
1307 let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
1308 let (result, _) = parse_git_log(output, root);
1309 assert_eq!(result.len(), 1);
1310 assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
1311 }
1312
1313 #[test]
1314 fn parse_git_log_weighted_commits_are_positive() {
1315 let root = Path::new("/project");
1316 let now_secs = std::time::SystemTime::now()
1317 .duration_since(std::time::UNIX_EPOCH)
1318 .unwrap()
1319 .as_secs();
1320 let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
1321 let (result, _) = parse_git_log(&output, root);
1322 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1323 assert!(
1324 churn.weighted_commits > 0.0,
1325 "weighted_commits should be positive for recent commits"
1326 );
1327 }
1328
1329 #[test]
1330 fn trend_boundary_1_5x_ratio() {
1331 let timestamps = vec![100, 200, 600, 800, 1000];
1332 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1333 }
1334
1335 #[test]
1336 fn trend_just_above_1_5x() {
1337 let timestamps = vec![100, 600, 800, 1000];
1338 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1339 }
1340
1341 #[test]
1342 fn trend_boundary_0_67x_ratio() {
1343 let timestamps = vec![100, 200, 300, 600, 1000];
1344 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1345 }
1346
1347 #[test]
1348 fn trend_two_timestamps_different() {
1349 let timestamps = vec![100, 200];
1350 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1351 }
1352
1353 #[test]
1354 fn parse_since_week_singular() {
1355 let d = parse_since("1week").unwrap();
1356 assert_eq!(d.git_after, "1 week ago");
1357 assert_eq!(d.display, "1 week");
1358 }
1359
1360 #[test]
1361 fn parse_since_weeks_long() {
1362 let d = parse_since("3weeks").unwrap();
1363 assert_eq!(d.git_after, "3 weeks ago");
1364 assert_eq!(d.display, "3 weeks");
1365 }
1366
1367 #[test]
1368 fn parse_since_days_long() {
1369 let d = parse_since("30days").unwrap();
1370 assert_eq!(d.git_after, "30 days ago");
1371 assert_eq!(d.display, "30 days");
1372 }
1373
1374 #[test]
1375 fn parse_since_year_long() {
1376 let d = parse_since("1year").unwrap();
1377 assert_eq!(d.git_after, "1 year ago");
1378 assert_eq!(d.display, "1 year");
1379 }
1380
1381 #[test]
1382 fn parse_since_overflow_number_rejected() {
1383 let result = parse_since("99999999999999999999d");
1384 assert!(result.is_err());
1385 let err = result.unwrap_err();
1386 assert!(err.contains("invalid number"));
1387 }
1388
1389 #[test]
1390 fn parse_since_zero_days_rejected() {
1391 assert!(parse_since("0d").is_err());
1392 }
1393
1394 #[test]
1395 fn parse_since_zero_weeks_rejected() {
1396 assert!(parse_since("0w").is_err());
1397 }
1398
1399 #[test]
1400 fn parse_since_zero_years_rejected() {
1401 assert!(parse_since("0y").is_err());
1402 }
1403
1404 #[test]
1405 fn numstat_missing_path() {
1406 assert!(parse_numstat_line("10\t5").is_none());
1407 }
1408
1409 #[test]
1410 fn numstat_single_field() {
1411 assert!(parse_numstat_line("10").is_none());
1412 }
1413
1414 #[test]
1415 fn numstat_empty_string() {
1416 assert!(parse_numstat_line("").is_none());
1417 }
1418
1419 #[test]
1420 fn numstat_only_added_is_binary() {
1421 assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1422 }
1423
1424 #[test]
1425 fn numstat_only_deleted_is_binary() {
1426 assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1427 }
1428
1429 #[test]
1430 fn numstat_path_with_spaces() {
1431 let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1432 assert_eq!(a, 3);
1433 assert_eq!(d, 1);
1434 assert_eq!(p, "path with spaces/file.ts");
1435 }
1436
1437 #[test]
1438 fn numstat_large_numbers() {
1439 let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1440 assert_eq!(a, 9999);
1441 assert_eq!(d, 8888);
1442 assert_eq!(p, "src/big.ts");
1443 }
1444
1445 #[test]
1446 fn iso_date_wrong_separator_positions() {
1447 assert!(!is_iso_date("20-25-0601"));
1448 assert!(!is_iso_date("202506-01-"));
1449 }
1450
1451 #[test]
1452 fn iso_date_too_short() {
1453 assert!(!is_iso_date("2025-06-0"));
1454 }
1455
1456 #[test]
1457 fn iso_date_letters_in_day() {
1458 assert!(!is_iso_date("2025-06-ab"));
1459 }
1460
1461 #[test]
1462 fn iso_date_letters_in_month() {
1463 assert!(!is_iso_date("2025-ab-01"));
1464 }
1465
1466 #[test]
1467 fn split_number_unit_valid() {
1468 let (num, unit) = split_number_unit("42days").unwrap();
1469 assert_eq!(num, "42");
1470 assert_eq!(unit, "days");
1471 }
1472
1473 #[test]
1474 fn split_number_unit_single_digit() {
1475 let (num, unit) = split_number_unit("1m").unwrap();
1476 assert_eq!(num, "1");
1477 assert_eq!(unit, "m");
1478 }
1479
1480 #[test]
1481 fn split_number_unit_no_digits() {
1482 let err = split_number_unit("abc").unwrap_err();
1483 assert!(err.contains("must start with a number"));
1484 }
1485
1486 #[test]
1487 fn split_number_unit_no_unit() {
1488 let err = split_number_unit("123").unwrap_err();
1489 assert!(err.contains("requires a unit suffix"));
1490 }
1491
1492 #[test]
1493 fn parse_git_log_numstat_before_timestamp_uses_now() {
1494 let root = Path::new("/project");
1495 let output = "10\t5\tsrc/no_ts.ts\n";
1496 let (result, _) = parse_git_log(output, root);
1497 assert_eq!(result.len(), 1);
1498 let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1499 assert_eq!(churn.commits, 1);
1500 assert_eq!(churn.lines_added, 10);
1501 assert_eq!(churn.lines_deleted, 5);
1502 assert!(
1503 churn.weighted_commits > 0.9,
1504 "weight should be near 1.0 when timestamp defaults to now"
1505 );
1506 }
1507
1508 #[test]
1509 fn parse_git_log_whitespace_lines_ignored() {
1510 let root = Path::new("/project");
1511 let output = " \n1700000000\n \n10\t5\tsrc/a.ts\n \n";
1512 let (result, _) = parse_git_log(output, root);
1513 assert_eq!(result.len(), 1);
1514 }
1515
1516 #[test]
1517 fn parse_git_log_trend_is_computed_per_file() {
1518 let root = Path::new("/project");
1519 let output = "\
15201000\n5\t1\tsrc/old.ts\n\
15212000\n3\t1\tsrc/old.ts\n\
15221000\n1\t0\tsrc/hot.ts\n\
15231800\n1\t0\tsrc/hot.ts\n\
15241900\n1\t0\tsrc/hot.ts\n\
15251950\n1\t0\tsrc/hot.ts\n\
15262000\n1\t0\tsrc/hot.ts\n";
1527 let (result, _) = parse_git_log(output, root);
1528 let old = &result[&PathBuf::from("/project/src/old.ts")];
1529 let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1530 assert_eq!(old.commits, 2);
1531 assert_eq!(hot.commits, 5);
1532 assert_eq!(hot.trend, ChurnTrend::Accelerating);
1533 }
1534
1535 #[test]
1536 fn parse_git_log_weighted_decay_for_old_commits() {
1537 let root = Path::new("/project");
1538 let now = std::time::SystemTime::now()
1539 .duration_since(std::time::UNIX_EPOCH)
1540 .unwrap()
1541 .as_secs();
1542 let old_ts = now - (180 * 86_400);
1543 let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1544 let (result, _) = parse_git_log(&output, root);
1545 let churn = &result[&PathBuf::from("/project/src/old.ts")];
1546 assert!(
1547 churn.weighted_commits < 0.5,
1548 "180-day-old commit should weigh ~0.25, got {}",
1549 churn.weighted_commits
1550 );
1551 assert!(
1552 churn.weighted_commits > 0.1,
1553 "180-day-old commit should weigh ~0.25, got {}",
1554 churn.weighted_commits
1555 );
1556 }
1557
1558 #[test]
1559 fn parse_git_log_path_stored_as_absolute() {
1560 let root = Path::new("/my/project");
1561 let output = "1700000000\n1\t0\tlib/utils.ts\n";
1562 let (result, _) = parse_git_log(output, root);
1563 let key = PathBuf::from("/my/project/lib/utils.ts");
1564 assert!(result.contains_key(&key));
1565 assert_eq!(result[&key].path, key);
1566 }
1567
1568 #[test]
1569 fn parse_git_log_weighted_commits_rounded() {
1570 let root = Path::new("/project");
1571 let now = std::time::SystemTime::now()
1572 .duration_since(std::time::UNIX_EPOCH)
1573 .unwrap()
1574 .as_secs();
1575 let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1576 let (result, _) = parse_git_log(&output, root);
1577 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1578 let decimals = format!("{:.2}", churn.weighted_commits);
1579 assert_eq!(
1580 churn.weighted_commits.to_string().len(),
1581 decimals.len().min(churn.weighted_commits.to_string().len()),
1582 "weighted_commits should be rounded to at most 2 decimal places"
1583 );
1584 }
1585
1586 #[test]
1587 fn trend_serde_serialization() {
1588 assert_eq!(
1589 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1590 "\"accelerating\""
1591 );
1592 assert_eq!(
1593 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1594 "\"stable\""
1595 );
1596 assert_eq!(
1597 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1598 "\"cooling\""
1599 );
1600 }
1601
1602 #[test]
1603 fn parse_git_log_extracts_author_email() {
1604 let root = Path::new("/project");
1605 let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1606 let (result, pool) = parse_git_log(output, root);
1607 assert_eq!(pool, vec!["alice@example.com".to_string()]);
1608 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1609 assert_eq!(churn.authors.len(), 1);
1610 let alice = &churn.authors[&0];
1611 assert_eq!(alice.commits, 1);
1612 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1613 assert_eq!(alice.last_commit_ts, 1_700_000_000);
1614 }
1615
1616 #[test]
1617 fn parse_git_log_intern_dedupes_authors() {
1618 let root = Path::new("/project");
1619 let output = "\
16201700000000|alice@example.com
16211\t0\ta.ts
16221700100000|bob@example.com
16232\t1\tb.ts
16241700200000|alice@example.com
16253\t2\tc.ts
1626";
1627 let (_result, pool) = parse_git_log(output, root);
1628 assert_eq!(pool.len(), 2);
1629 assert!(pool.contains(&"alice@example.com".to_string()));
1630 assert!(pool.contains(&"bob@example.com".to_string()));
1631 }
1632
1633 #[test]
1634 fn parse_git_log_aggregates_per_author() {
1635 let root = Path::new("/project");
1636 let output = "\
16371700000000|alice@example.com
16381\t0\tsrc/index.ts
16391700100000|bob@example.com
16402\t0\tsrc/index.ts
16411700200000|alice@example.com
16421\t1\tsrc/index.ts
1643";
1644 let (result, pool) = parse_git_log(output, root);
1645 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1646 assert_eq!(churn.commits, 3);
1647 assert_eq!(churn.authors.len(), 2);
1648
1649 let alice_idx =
1650 u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1651 let alice = &churn.authors[&alice_idx];
1652 assert_eq!(alice.commits, 2);
1653 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1654 assert_eq!(alice.last_commit_ts, 1_700_200_000);
1655 }
1656
1657 #[test]
1658 fn parse_git_log_legacy_bare_timestamp_still_parses() {
1659 let root = Path::new("/project");
1660 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1661 let (result, pool) = parse_git_log(output, root);
1662 assert!(pool.is_empty());
1663 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1664 assert_eq!(churn.commits, 1);
1665 assert!(churn.authors.is_empty());
1666 }
1667
1668 #[test]
1669 fn intern_author_returns_existing_index() {
1670 let mut pool = Vec::new();
1671 let mut index = FxHashMap::default();
1672 let i1 = intern_author("alice@x", &mut pool, &mut index);
1673 let i2 = intern_author("alice@x", &mut pool, &mut index);
1674 assert_eq!(i1, i2);
1675 assert_eq!(pool.len(), 1);
1676 }
1677
1678 #[test]
1679 fn intern_author_assigns_sequential_indices() {
1680 let mut pool = Vec::new();
1681 let mut index = FxHashMap::default();
1682 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1683 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1684 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1685 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1686 }
1687
1688 fn git(root: &Path, args: &[&str]) {
1689 let status = std::process::Command::new("git")
1690 .args(args)
1691 .current_dir(root)
1692 .status()
1693 .expect("run git");
1694 assert!(status.success(), "git {args:?} failed");
1695 }
1696
1697 fn write(root: &Path, path: &str, contents: &str) {
1698 let path = root.join(path);
1699 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1700 std::fs::write(path, contents).unwrap();
1701 }
1702
1703 #[cfg(unix)]
1704 #[test]
1705 fn churn_preserves_special_filenames() {
1706 let repo = tempfile::tempdir().expect("create repo");
1707 let root = repo.path();
1708 git(root, &["init", "--quiet"]);
1709 git(root, &["config", "user.email", "churn@example.test"]);
1710 git(root, &["config", "user.name", "Churn Test"]);
1711 git(root, &["config", "commit.gpgsign", "false"]);
1712
1713 let special_files = [
1714 "src/line\nbreak.ts",
1715 "src/space name.ts",
1716 "src/quote\"name.ts",
1717 "src/back\\slash.ts",
1718 "src/unicode-λ.ts",
1719 ]
1720 .map(|path| root.join(path));
1721 std::fs::create_dir_all(root.join("src")).expect("source dir");
1722 for special in &special_files {
1723 std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1724 }
1725 git(root, &["add", "."]);
1726 git(root, &["commit", "--quiet", "-m", "initial"]);
1727
1728 let since = parse_since("1y").expect("valid duration");
1729 let churn = analyze_churn(root, &since).expect("churn result");
1730 for special in special_files {
1731 assert!(
1732 churn.files.contains_key(&special),
1733 "missing {special:?}: {:?}",
1734 churn.files.keys()
1735 );
1736 }
1737 }
1738
1739 #[cfg(unix)]
1740 #[test]
1741 fn churn_cache_preserves_non_utf8_filenames() {
1742 use std::ffi::OsString;
1743 use std::os::unix::ffi::OsStringExt;
1744
1745 let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1746 let mut files = FxHashMap::default();
1747 files.insert(
1748 invalid_path.clone(),
1749 FileEvents {
1750 events: vec![CachedCommitEvent {
1751 timestamp: 1,
1752 lines_added: 2,
1753 lines_deleted: 1,
1754 author_idx: None,
1755 }],
1756 },
1757 );
1758 let state = ChurnEventState {
1759 files,
1760 author_pool: Vec::new(),
1761 };
1762 let cache_dir = tempfile::tempdir().expect("cache directory");
1763 save_churn_cache(cache_dir.path(), "abc123", "1 year ago", &state, false);
1764 let warm = load_churn_cache(cache_dir.path(), "1 year ago")
1765 .expect("warm churn cache")
1766 .into_event_state();
1767
1768 assert!(warm.files.contains_key(&invalid_path));
1769 }
1770
1771 #[cfg(windows)]
1772 #[test]
1773 fn git_path_bytes_use_windows_separators() {
1774 assert_eq!(
1775 git_path_from_bytes(b"src/nested/file.ts"),
1776 PathBuf::from(r"src\nested\file.ts")
1777 );
1778 }
1779
1780 #[test]
1781 fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1782 let cache_dir = tempfile::tempdir().expect("cache directory");
1783 let cache = ChurnCache {
1784 version: 4,
1785 last_indexed_sha: "abc123".to_string(),
1786 git_after: "1 year ago".to_string(),
1787 files: vec![CachedFileChurn {
1788 path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1789 events: Vec::new(),
1790 }],
1791 shallow_clone: false,
1792 author_pool: Vec::new(),
1793 };
1794 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1795 .expect("cache fixture");
1796
1797 assert!(load_churn_cache(cache_dir.path(), "1 year ago").is_none());
1798 }
1799
1800 #[test]
1801 fn cached_churn_merges_new_commits_after_head_advances() {
1802 let repo = tempfile::tempdir().expect("create repo");
1803 let root = repo.path();
1804 git(root, &["init"]);
1805 git(root, &["config", "user.email", "churn@example.test"]);
1806 git(root, &["config", "user.name", "Churn Test"]);
1807 git(root, &["config", "commit.gpgsign", "false"]);
1808
1809 write(root, "src/a.ts", "export const a = 1;\n");
1810 git(root, &["add", "."]);
1811 git(root, &["commit", "-m", "initial"]);
1812
1813 let since = parse_since("1y").unwrap();
1814 let cache = tempfile::tempdir().expect("create cache dir");
1815 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1816 assert!(!cold_hit);
1817 let file = root.join("src/a.ts");
1818 assert_eq!(cold.files[&file].commits, 1);
1819
1820 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1821 assert!(warm_hit);
1822
1823 write(
1824 root,
1825 "src/a.ts",
1826 "export const a = 1;\nexport const b = 2;\n",
1827 );
1828 git(root, &["add", "."]);
1829 git(root, &["commit", "-m", "update a"]);
1830 let head = get_head_sha(root).unwrap();
1831
1832 let (incremental, incremental_hit) =
1833 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1834 assert!(incremental_hit);
1835 assert_eq!(incremental.files[&file].commits, 2);
1836
1837 let cache = load_churn_cache(cache.path(), &since.git_after).unwrap();
1838 assert_eq!(cache.last_indexed_sha, head);
1839 }
1840
1841 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1842 let path = dir.join("churn.json");
1843 std::fs::write(&path, contents).unwrap();
1844 path
1845 }
1846
1847 #[test]
1848 fn churn_file_happy_path() {
1849 let dir = tempfile::tempdir().unwrap();
1850 let root = Path::new("/project");
1851 let path = write_churn_file(
1852 dir.path(),
1853 r#"{
1854 "schema": "fallow-churn/v1",
1855 "events": [
1856 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1857 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1858 ]
1859 }"#,
1860 );
1861 let result = analyze_churn_from_file(&path, root).unwrap();
1862 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1863 assert_eq!(churn.commits, 2);
1864 assert_eq!(churn.lines_added, 13);
1865 assert_eq!(churn.lines_deleted, 7);
1866 assert_eq!(churn.authors.len(), 2);
1867 assert!(result.author_pool.contains(&"alice@corp".to_string()));
1868 assert!(result.author_pool.contains(&"bob@corp".to_string()));
1869 assert!(!result.shallow_clone);
1870 }
1871
1872 #[test]
1873 fn churn_file_matches_git_parse() {
1874 let dir = tempfile::tempdir().unwrap();
1878 let root = Path::new("/project");
1879 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";
1880 let (git_files, git_pool) = parse_git_log(git_output, root);
1881
1882 let path = write_churn_file(
1883 dir.path(),
1884 r#"{
1885 "schema": "fallow-churn/v1",
1886 "events": [
1887 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1888 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
1889 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1890 ]
1891 }"#,
1892 );
1893 let imported = analyze_churn_from_file(&path, root).unwrap();
1894
1895 assert_eq!(git_pool, imported.author_pool, "author pools diverge");
1896 assert_eq!(git_files.len(), imported.files.len());
1897 for (file, git_churn) in &git_files {
1898 let imp = &imported.files[file];
1899 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
1900 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
1901 assert_eq!(
1902 git_churn.lines_deleted, imp.lines_deleted,
1903 "deleted for {file:?}"
1904 );
1905 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
1906 assert_eq!(
1907 git_churn.authors.len(),
1908 imp.authors.len(),
1909 "authors for {file:?}"
1910 );
1911 assert!(
1912 (git_churn.weighted_commits - imp.weighted_commits).abs() < 0.02,
1913 "weighted_commits for {file:?}: {} vs {}",
1914 git_churn.weighted_commits,
1915 imp.weighted_commits
1916 );
1917 }
1918 }
1919
1920 #[test]
1921 fn churn_file_empty_events_is_valid() {
1922 let dir = tempfile::tempdir().unwrap();
1923 let path = write_churn_file(
1924 dir.path(),
1925 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1926 );
1927 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1928 assert!(result.files.is_empty());
1929 assert!(result.author_pool.is_empty());
1930 }
1931
1932 #[test]
1933 fn churn_file_missing_events_key_is_valid() {
1934 let dir = tempfile::tempdir().unwrap();
1935 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1936 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1937 assert!(result.files.is_empty());
1938 }
1939
1940 #[test]
1941 fn churn_file_bad_schema_rejected() {
1942 let dir = tempfile::tempdir().unwrap();
1943 let path = write_churn_file(
1944 dir.path(),
1945 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1946 );
1947 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1948 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
1949 }
1950
1951 #[test]
1952 fn churn_file_malformed_json_rejected() {
1953 let dir = tempfile::tempdir().unwrap();
1954 let path = write_churn_file(dir.path(), "{ not json");
1955 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
1956 }
1957
1958 #[test]
1959 fn churn_file_missing_file_rejected() {
1960 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
1961 .unwrap_err();
1962 assert!(err.contains("failed to read churn file"), "{err}");
1963 }
1964
1965 #[test]
1966 fn churn_file_empty_path_rejected() {
1967 let dir = tempfile::tempdir().unwrap();
1968 let path = write_churn_file(
1969 dir.path(),
1970 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1971 );
1972 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1973 assert!(err.contains("empty path"), "{err}");
1974 }
1975
1976 #[test]
1977 fn churn_file_millisecond_timestamp_rejected() {
1978 let dir = tempfile::tempdir().unwrap();
1979 let path = write_churn_file(
1981 dir.path(),
1982 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
1983 );
1984 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1985 assert!(err.contains("milliseconds"), "{err}");
1986 }
1987
1988 #[test]
1989 fn churn_file_missing_author_contributes_no_signal() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let path = write_churn_file(
1992 dir.path(),
1993 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1994 );
1995 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1996 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1997 assert_eq!(churn.commits, 1);
1998 assert!(churn.authors.is_empty());
1999 assert!(result.author_pool.is_empty());
2000 }
2001
2002 #[test]
2003 fn churn_file_empty_author_string_treated_as_absent() {
2004 let dir = tempfile::tempdir().unwrap();
2005 let path = write_churn_file(
2006 dir.path(),
2007 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
2008 );
2009 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2010 assert!(result.author_pool.is_empty());
2011 }
2012
2013 #[test]
2014 fn churn_file_unknown_fields_ignored() {
2015 let dir = tempfile::tempdir().unwrap();
2018 let path = write_churn_file(
2019 dir.path(),
2020 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" } ] }"#,
2021 );
2022 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2023 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2024 }
2025
2026 #[test]
2027 fn churn_file_backslash_paths_normalized() {
2028 let dir = tempfile::tempdir().unwrap();
2029 let path = write_churn_file(
2030 dir.path(),
2031 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2032 );
2033 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2034 assert!(
2035 result
2036 .files
2037 .contains_key(&PathBuf::from("/project/src/a.ts"))
2038 );
2039 }
2040}