1use rustc_hash::FxHashMap;
7use std::io::Read as _;
8use std::path::{Component, Path, PathBuf};
9use std::process::{Command, Output};
10use std::sync::OnceLock;
11
12use serde::Deserialize;
13
14use crate::changed_files::git_path_from_bytes;
15
16pub use fallow_types::churn::ChurnTrend;
17
18pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
24
25static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
26
27fn git_command() -> Command {
28 crate::git_env::git_command()
29}
30
31pub fn set_spawn_hook(hook: ChurnSpawnHook) {
37 let _ = SPAWN_HOOK.set(hook);
38}
39
40fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
41 if let Some(hook) = SPAWN_HOOK.get() {
42 hook(command)
43 } else {
44 command.output()
45 }
46}
47
48const SECS_PER_DAY: f64 = 86_400.0;
50
51const HALF_LIFE_DAYS: f64 = 90.0;
54
55const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
57
58const MAX_CHURN_EVENTS: usize = 5_000_000;
64
65const MAX_CHURN_FILE_BYTES: usize = 256 * 1024 * 1024;
67
68const MAX_FUTURE_TIMESTAMP_SECS: u64 = 365 * 24 * 60 * 60;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ChurnWindowUnit {
80 Days,
82 Weeks,
84 Months,
86 Years,
88}
89
90impl ChurnWindowUnit {
91 const fn token(self) -> char {
93 match self {
94 Self::Days => 'd',
95 Self::Weeks => 'w',
96 Self::Months => 'm',
97 Self::Years => 'y',
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ChurnWindow {
109 Relative {
111 count: u64,
113 unit: ChurnWindowUnit,
115 },
116 Date(String),
118 Imported,
120}
121
122impl ChurnWindow {
123 #[must_use]
126 pub fn cache_token(&self) -> String {
127 match self {
128 Self::Relative { count, unit } => format!("{count}{}", unit.token()),
129 Self::Date(date) => date.clone(),
130 Self::Imported => String::new(),
131 }
132 }
133
134 #[must_use]
137 pub fn cutoff_secs(&self, clock: &crate::clock::AnalysisClock) -> Option<u64> {
138 match self {
139 Self::Relative { count, unit } => Some(match unit {
140 ChurnWindowUnit::Days => clock.minus_days(*count),
141 ChurnWindowUnit::Weeks => clock.minus_days(count.saturating_mul(7)),
142 ChurnWindowUnit::Months => clock.minus_months(*count),
143 ChurnWindowUnit::Years => clock.minus_years(*count),
144 }),
145 Self::Date(date) => crate::clock::utc_midnight_epoch(date),
146 Self::Imported => None,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SinceDuration {
154 pub window: ChurnWindow,
156 pub display: String,
158}
159
160impl SinceDuration {
161 #[must_use]
163 pub fn relative(count: u64, unit: ChurnWindowUnit, display: impl Into<String>) -> Self {
164 Self {
165 window: ChurnWindow::Relative { count, unit },
166 display: display.into(),
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct AuthorContribution {
177 pub commits: u32,
179 pub weighted_commits: f64,
181 pub first_commit_ts: u64,
183 pub last_commit_ts: u64,
185}
186
187#[derive(Debug, Clone)]
189pub struct FileChurn {
190 pub path: PathBuf,
192 pub commits: u32,
194 pub weighted_commits: f64,
196 pub lines_added: u32,
198 pub lines_deleted: u32,
200 pub trend: ChurnTrend,
202 pub authors: FxHashMap<u32, AuthorContribution>,
205}
206
207#[derive(Debug, Clone)]
209pub struct ChurnResult {
210 pub files: FxHashMap<PathBuf, FileChurn>,
212 pub shallow_clone: bool,
214 pub author_pool: Vec<String>,
217 pub clock: crate::clock::AnalysisClock,
221}
222
223pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
234 if is_iso_date(input) {
235 return Ok(SinceDuration {
236 window: ChurnWindow::Date(input.to_string()),
237 display: input.to_string(),
238 });
239 }
240
241 let (num_str, unit) = split_number_unit(input)?;
242 let num: u64 = num_str
243 .parse()
244 .map_err(|_| format!("invalid number in --since: {input}"))?;
245
246 if num == 0 {
247 return Err("--since duration must be greater than 0".to_string());
248 }
249
250 let (unit, label) = match unit {
251 "d" | "day" | "days" => (ChurnWindowUnit::Days, "day"),
252 "w" | "week" | "weeks" => (ChurnWindowUnit::Weeks, "week"),
253 "m" | "month" | "months" => (ChurnWindowUnit::Months, "month"),
254 "y" | "year" | "years" => (ChurnWindowUnit::Years, "year"),
255 _ => {
256 return Err(format!(
257 "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
258 ));
259 }
260 };
261 let plural = if num == 1 { "" } else { "s" };
262 Ok(SinceDuration::relative(
263 num,
264 unit,
265 format!("{num} {label}{plural}"),
266 ))
267}
268
269pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
273 let clock = crate::clock::AnalysisClock::for_repo(root);
274 let shallow = is_shallow_clone(root);
275 let state = analyze_churn_events(root, since, None, &clock)?;
276 Some(build_churn_result(state, shallow, clock))
277}
278
279#[derive(Debug, Deserialize)]
285struct ChurnFileDoc {
286 schema: String,
287 #[serde(default)]
288 events: Vec<ChurnFileEvent>,
289}
290
291#[derive(Debug, Deserialize)]
296struct ChurnFileEvent {
297 path: String,
299 timestamp: u64,
301 #[serde(default)]
304 author: Option<String>,
305 added: u32,
307 deleted: u32,
309}
310
311pub(crate) fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
325 let raw = read_churn_file_with_limit(path, MAX_CHURN_FILE_BYTES)?;
326 let doc: ChurnFileDoc = serde_json::from_str(&raw)
327 .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
328 if doc.schema != CHURN_FILE_SCHEMA {
329 return Err(format!(
330 "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
331 path.display(),
332 doc.schema
333 ));
334 }
335 if doc.events.len() > MAX_CHURN_EVENTS {
336 return Err(format!(
337 "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
338 path.display(),
339 doc.events.len()
340 ));
341 }
342
343 let state = churn_event_state_from_doc(&doc, path, root)?;
344 Ok(build_churn_result(
345 state,
346 false,
347 crate::clock::AnalysisClock::for_repo(root),
348 ))
349}
350
351fn read_churn_file_with_limit(path: &Path, limit: usize) -> Result<String, String> {
352 let file = std::fs::File::open(path)
353 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
354 let mut bytes = Vec::new();
355 file.take(limit as u64 + 1)
356 .read_to_end(&mut bytes)
357 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
358 if bytes.len() > limit {
359 return Err(format!(
360 "churn file {} is at least {} bytes, exceeding the {limit} byte limit",
361 path.display(),
362 bytes.len()
363 ));
364 }
365 String::from_utf8(bytes)
366 .map_err(|e| format!("failed to read churn file {} as UTF-8: {e}", path.display()))
367}
368
369fn churn_event_state_from_doc(
375 doc: &ChurnFileDoc,
376 path: &Path,
377 root: &Path,
378) -> Result<ChurnEventState, String> {
379 let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
380
381 for event in &doc.events {
382 builder.push_event(event)?;
383 }
384
385 Ok(builder.finish())
386}
387
388fn churn_file_future_limit() -> u64 {
401 let now_secs = std::time::SystemTime::now()
402 .duration_since(std::time::UNIX_EPOCH)
403 .unwrap_or_default()
404 .as_secs();
405 now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
406}
407
408struct ChurnFileImportBuilder<'a> {
409 path: &'a Path,
410 root: &'a Path,
411 future_limit: u64,
412 files: FxHashMap<PathBuf, FileEvents>,
413 totals: FxHashMap<PathBuf, (u64, u64)>,
414 author_pool: Vec<String>,
415 author_index: FxHashMap<String, u32>,
416}
417
418impl<'a> ChurnFileImportBuilder<'a> {
419 fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
420 Self {
421 path,
422 root,
423 future_limit,
424 files: FxHashMap::default(),
425 totals: FxHashMap::default(),
426 author_pool: Vec::new(),
427 author_index: FxHashMap::default(),
428 }
429 }
430
431 fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
432 let rel = normalize_churn_event_path(self.path, &event.path)?;
433 validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
434
435 let abs_path = self.root.join(&rel);
436 let totals = self.totals.entry(abs_path.clone()).or_default();
437 totals.0 += u64::from(event.added);
438 totals.1 += u64::from(event.deleted);
439 if totals.0 > u64::from(u32::MAX) || totals.1 > u64::from(u32::MAX) {
440 return Err(format!(
441 "churn file {} has line totals for \"{rel}\" exceeding the u32 limit \
442 (added {}, deleted {})",
443 self.path.display(),
444 totals.0,
445 totals.1
446 ));
447 }
448 let author_idx = self.intern_author(event.author.as_deref());
449 self.files
450 .entry(abs_path)
451 .or_insert_with(|| FileEvents { events: Vec::new() })
452 .events
453 .push(CachedCommitEvent {
454 timestamp: event.timestamp,
455 committed_at: event.timestamp,
456 lines_added: event.added,
457 lines_deleted: event.deleted,
458 author_idx,
459 });
460 Ok(())
461 }
462
463 fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
464 author
465 .map(str::trim)
466 .filter(|email| !email.is_empty())
467 .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
468 }
469
470 fn finish(self) -> ChurnEventState {
471 ChurnEventState {
472 files: self.files,
473 author_pool: self.author_pool,
474 }
475 }
476}
477
478fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
479 let normalized = event_path.replace('\\', "/");
480 let rel = normalized.trim();
481 if rel.is_empty() {
482 return Err(format!(
483 "churn file {} has an event with an empty path",
484 path.display()
485 ));
486 }
487 let bytes = rel.as_bytes();
488 let has_drive_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
489 let has_empty_component = rel.split('/').any(str::is_empty);
490 let components_are_normal = Path::new(rel)
491 .components()
492 .all(|component| matches!(component, Component::Normal(_)));
493 if rel.starts_with('/') || has_drive_prefix || has_empty_component || !components_are_normal {
494 return Err(format!(
495 "churn file {} has an invalid repo-relative event path \"{event_path}\"",
496 path.display()
497 ));
498 }
499 Ok(rel.to_string())
500}
501
502fn validate_churn_event_timestamp(
503 path: &Path,
504 timestamp: u64,
505 future_limit: u64,
506 rel: &str,
507) -> Result<(), String> {
508 if timestamp <= future_limit {
509 return Ok(());
510 }
511
512 Err(format!(
513 "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
514 future; timestamps must be unix SECONDS (not milliseconds), UTC",
515 path.display(),
516 timestamp
517 ))
518}
519
520#[must_use]
522fn is_shallow_clone(root: &Path) -> bool {
523 let mut command = git_command();
524 command
525 .args(["rev-parse", "--is-shallow-repository"])
526 .current_dir(root);
527 command.output().is_ok_and(|o| {
528 String::from_utf8_lossy(&o.stdout)
529 .trim()
530 .eq_ignore_ascii_case("true")
531 })
532}
533
534#[must_use]
536pub fn is_git_repo(root: &Path) -> bool {
537 let mut command = git_command();
538 command
539 .args(["rev-parse", "--git-dir"])
540 .current_dir(root)
541 .stdout(std::process::Stdio::null())
542 .stderr(std::process::Stdio::null());
543 command.status().is_ok_and(|s| s.success())
544}
545
546const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
549
550const CHURN_CACHE_VERSION: u8 = 6;
557
558#[derive(Clone, bitcode::Encode, bitcode::Decode)]
560struct CachedCommitEvent {
561 timestamp: u64,
562 committed_at: u64,
566 lines_added: u32,
567 lines_deleted: u32,
568 author_idx: Option<u32>,
569}
570
571#[derive(Clone, bitcode::Encode, bitcode::Decode)]
573struct CachedFileChurn {
574 path: Vec<u8>,
575 events: Vec<CachedCommitEvent>,
576}
577
578#[derive(Clone, bitcode::Encode, bitcode::Decode)]
580struct ChurnCache {
581 version: u8,
583 last_indexed_sha: String,
584 window_token: String,
586 files: Vec<CachedFileChurn>,
587 shallow_clone: bool,
588 author_pool: Vec<String>,
590}
591
592struct FileEvents {
594 events: Vec<CachedCommitEvent>,
595}
596
597struct ChurnEventState {
600 files: FxHashMap<PathBuf, FileEvents>,
601 author_pool: Vec<String>,
602}
603
604fn get_head_sha(root: &Path) -> Option<String> {
606 let mut command = git_command();
607 command.args(["rev-parse", "HEAD"]).current_dir(root);
608 command
609 .output()
610 .ok()
611 .filter(|o| o.status.success())
612 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
613}
614
615fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
617 let mut command = git_command();
618 command
619 .args(["merge-base", "--is-ancestor", ancestor, descendant])
620 .current_dir(root);
621 command.status().is_ok_and(|s| s.success())
622}
623
624fn load_churn_cache(cache_dir: &Path, window_token: &str) -> Option<ChurnCache> {
627 let cache_file = cache_dir.join("churn.bin");
628 let data = std::fs::read(&cache_file).ok()?;
629 if data.len() > MAX_CHURN_CACHE_SIZE {
630 return None;
631 }
632 let cache: ChurnCache = bitcode::decode(&data).ok()?;
633 if cache.version != CHURN_CACHE_VERSION || cache.window_token != window_token {
634 return None;
635 }
636 Some(cache)
637}
638
639fn save_churn_cache(
641 cache_dir: &Path,
642 last_indexed_sha: &str,
643 window_token: &str,
644 state: &ChurnEventState,
645 shallow_clone: bool,
646) {
647 let files: Vec<CachedFileChurn> = state
648 .files
649 .iter()
650 .map(|f| CachedFileChurn {
651 path: path_to_cache_bytes(f.0),
652 events: f.1.events.clone(),
653 })
654 .collect();
655 let cache = ChurnCache {
656 version: CHURN_CACHE_VERSION,
657 last_indexed_sha: last_indexed_sha.to_string(),
658 window_token: window_token.to_string(),
659 files,
660 shallow_clone,
661 author_pool: state.author_pool.clone(),
662 };
663 let _ = std::fs::create_dir_all(cache_dir);
664 let data = bitcode::encode(&cache);
665 let tmp = cache_dir.join("churn.bin.tmp");
666 if std::fs::write(&tmp, data).is_ok() {
667 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
668 }
669}
670
671pub fn analyze_churn_cached(
679 root: &Path,
680 since: &SinceDuration,
681 cache_dir: &Path,
682 no_cache: bool,
683) -> Option<(ChurnResult, bool)> {
684 let head_sha = get_head_sha(root)?;
685 let clock = crate::clock::AnalysisClock::for_repo(root);
686
687 if !no_cache
688 && let Some(result) = try_reuse_churn_cache(root, since, cache_dir, &head_sha, &clock)
689 {
690 return Some((result, true));
691 }
692
693 analyze_fresh_churn(root, since, cache_dir, no_cache, &head_sha, &clock)
694 .map(|result| (result, false))
695}
696
697fn try_reuse_churn_cache(
698 root: &Path,
699 since: &SinceDuration,
700 cache_dir: &Path,
701 head_sha: &str,
702 clock: &crate::clock::AnalysisClock,
703) -> Option<ChurnResult> {
704 let cache = load_churn_cache(cache_dir, &since.window.cache_token())?;
705 let cutoff = since.window.cutoff_secs(clock);
706 if cache.last_indexed_sha == head_sha {
707 let shallow_clone = cache.shallow_clone;
708 return Some(build_churn_result(
709 cache.into_event_state(cutoff),
710 shallow_clone,
711 *clock,
712 ));
713 }
714
715 if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
716 return None;
717 }
718
719 extend_churn_cache(root, since, cache_dir, head_sha, cache, clock)
720}
721
722fn extend_churn_cache(
723 root: &Path,
724 since: &SinceDuration,
725 cache_dir: &Path,
726 head_sha: &str,
727 cache: ChurnCache,
728 clock: &crate::clock::AnalysisClock,
729) -> Option<ChurnResult> {
730 let shallow_clone = is_shallow_clone(root);
731 let range = format!("{}..HEAD", cache.last_indexed_sha);
732 let delta = analyze_churn_events(root, since, Some(&range), clock)?;
733 let mut state = cache.into_event_state(since.window.cutoff_secs(clock));
734 merge_churn_states(&mut state, delta);
735 save_churn_cache(
736 cache_dir,
737 head_sha,
738 &since.window.cache_token(),
739 &state,
740 shallow_clone,
741 );
742 Some(build_churn_result(state, shallow_clone, *clock))
743}
744
745fn analyze_fresh_churn(
746 root: &Path,
747 since: &SinceDuration,
748 cache_dir: &Path,
749 no_cache: bool,
750 head_sha: &str,
751 clock: &crate::clock::AnalysisClock,
752) -> Option<ChurnResult> {
753 let shallow_clone = is_shallow_clone(root);
754 let state = analyze_churn_events(root, since, None, clock)?;
755 if !no_cache {
756 save_churn_cache(
757 cache_dir,
758 head_sha,
759 &since.window.cache_token(),
760 &state,
761 shallow_clone,
762 );
763 }
764
765 Some(build_churn_result(state, shallow_clone, *clock))
766}
767
768impl ChurnCache {
769 fn into_event_state(self, cutoff_secs: Option<u64>) -> ChurnEventState {
778 let files = self
779 .files
780 .into_iter()
781 .filter_map(|entry| {
782 let path = path_from_cache_bytes(&entry.path)?;
783 let mut events = entry.events;
784 if let Some(cutoff) = cutoff_secs {
785 events.retain(|event| event.committed_at >= cutoff);
786 }
787 (!events.is_empty()).then_some((path, FileEvents { events }))
788 })
789 .collect();
790 ChurnEventState {
791 files,
792 author_pool: self.author_pool,
793 }
794 }
795}
796
797#[cfg(unix)]
798fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
799 use std::os::unix::ffi::OsStrExt;
800
801 path.as_os_str().as_bytes().to_vec()
802}
803
804#[cfg(unix)]
805#[allow(
806 clippy::unnecessary_wraps,
807 reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
808)]
809fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
810 use std::ffi::OsStr;
811 use std::os::unix::ffi::OsStrExt;
812
813 Some(PathBuf::from(OsStr::from_bytes(path)))
814}
815
816#[cfg(windows)]
817fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
818 use std::os::windows::ffi::OsStrExt;
819
820 path.as_os_str()
821 .encode_wide()
822 .flat_map(u16::to_le_bytes)
823 .collect()
824}
825
826#[cfg(windows)]
827fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
828 use std::ffi::OsString;
829 use std::os::windows::ffi::OsStringExt;
830
831 let chunks = path.chunks_exact(2);
832 if !chunks.remainder().is_empty() {
833 return None;
834 }
835 let wide: Vec<u16> = chunks
836 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
837 .collect();
838 Some(PathBuf::from(OsString::from_wide(&wide)))
839}
840
841fn analyze_churn_events(
847 root: &Path,
848 since: &SinceDuration,
849 revision_range: Option<&str>,
850 clock: &crate::clock::AnalysisClock,
851) -> Option<ChurnEventState> {
852 let mut command = git_command();
853 command.arg("log");
854 if let Some(range) = revision_range {
855 command.arg(range);
856 }
857 command
858 .args([
859 "--numstat",
860 "--no-merges",
861 "--no-renames",
862 "--use-mailmap",
863 "-z",
864 "--format=format:%at|%ct|%ae%x00",
865 ])
866 .current_dir(root);
867 if let Some(cutoff) = since.window.cutoff_secs(clock) {
868 command.arg(format!("--after=@{cutoff}"));
869 }
870
871 let output = match spawn_output(&mut command) {
872 Ok(o) => o,
873 Err(e) => {
874 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
875 return None;
876 }
877 };
878
879 if !output.status.success() {
880 let stderr = String::from_utf8_lossy(&output.stderr);
881 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
882 return None;
883 }
884
885 Some(parse_git_log_events_z(
886 &output.stdout,
887 root,
888 clock.epoch_secs(),
889 ))
890}
891
892fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
894 let mut base_author_index: FxHashMap<String, u32> = base
895 .author_pool
896 .iter()
897 .enumerate()
898 .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
899 .collect();
900
901 let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
902 for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
903 let Ok(old_idx) = u32::try_from(old_idx) else {
904 continue;
905 };
906 let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
907 author_mapping.insert(old_idx, new_idx);
908 }
909
910 for (path, mut file) in delta.files {
911 for event in &mut file.events {
912 event.author_idx = event
913 .author_idx
914 .and_then(|idx| author_mapping.get(&idx).copied());
915 }
916 base.files
917 .entry(path)
918 .and_modify(|existing| existing.events.append(&mut file.events))
919 .or_insert(file);
920 }
921}
922
923#[cfg(test)]
925fn parse_git_log_events(stdout: &str, root: &Path, now_secs: u64) -> ChurnEventState {
926 let mut parser = GitLogEventParser::new(root, now_secs);
927
928 for line in stdout.lines() {
929 parser.consume_line(line);
930 }
931
932 parser.finish()
933}
934
935fn parse_git_log_events_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnEventState {
939 let mut parser = GitLogEventParser::new(root, now_secs);
940 for record in stdout.split(|byte| *byte == 0) {
941 let record = record.strip_prefix(b"\n").unwrap_or(record);
942 if record.is_empty() {
943 continue;
944 }
945 if record.contains(&b'\t') {
946 parser.record_numstat_bytes(record);
947 } else {
948 parser.consume_line(&String::from_utf8_lossy(record));
949 }
950 }
951 parser.finish()
952}
953
954struct GitLogEventParser<'a> {
955 root: &'a Path,
956 now_secs: u64,
957 files: FxHashMap<PathBuf, FileEvents>,
958 author_pool: Vec<String>,
959 author_index: FxHashMap<String, u32>,
960 current_timestamp: Option<u64>,
961 current_committed_at: Option<u64>,
962 current_author_idx: Option<u32>,
963}
964
965impl<'a> GitLogEventParser<'a> {
966 fn new(root: &'a Path, now_secs: u64) -> Self {
967 Self {
968 root,
969 now_secs,
970 files: FxHashMap::default(),
971 author_pool: Vec::new(),
972 author_index: FxHashMap::default(),
973 current_timestamp: None,
974 current_committed_at: None,
975 current_author_idx: None,
976 }
977 }
978
979 fn consume_line(&mut self, line: &str) {
980 let line = line.trim();
981 if line.is_empty() {
982 return;
983 }
984
985 if self.record_commit_header(line) {
986 return;
987 }
988 if self.record_legacy_timestamp(line) {
989 return;
990 }
991 self.record_numstat(line);
992 }
993
994 fn record_commit_header(&mut self, line: &str) -> bool {
998 let Some((ts_str, rest)) = line.split_once('|') else {
999 return false;
1000 };
1001 let Ok(ts) = ts_str.parse::<u64>() else {
1002 return false;
1003 };
1004 let (committed_at, email) = match rest.split_once('|') {
1005 Some((committer_str, email)) => (committer_str.parse::<u64>().unwrap_or(ts), email),
1006 None => (ts, rest),
1007 };
1008
1009 self.current_timestamp = Some(ts);
1010 self.current_committed_at = Some(committed_at);
1011 self.current_author_idx = Some(intern_author(
1012 email,
1013 &mut self.author_pool,
1014 &mut self.author_index,
1015 ));
1016 true
1017 }
1018
1019 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
1020 let Ok(ts) = line.parse::<u64>() else {
1021 return false;
1022 };
1023
1024 self.current_timestamp = Some(ts);
1025 self.current_committed_at = Some(ts);
1026 self.current_author_idx = None;
1027 true
1028 }
1029
1030 fn record_numstat(&mut self, line: &str) {
1031 let Some((added, deleted, path)) = parse_numstat_line(line) else {
1032 return;
1033 };
1034
1035 self.record_numstat_path(added, deleted, PathBuf::from(path));
1036 }
1037
1038 fn record_numstat_bytes(&mut self, record: &[u8]) {
1039 let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
1040 return;
1041 };
1042 let Some(second_tab_offset) = record[first_tab + 1..]
1043 .iter()
1044 .position(|byte| *byte == b'\t')
1045 else {
1046 return;
1047 };
1048 let second_tab = first_tab + 1 + second_tab_offset;
1049 let Some(added) = std::str::from_utf8(&record[..first_tab])
1050 .ok()
1051 .and_then(|value| value.parse().ok())
1052 else {
1053 return;
1054 };
1055 let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
1056 .ok()
1057 .and_then(|value| value.parse().ok())
1058 else {
1059 return;
1060 };
1061 let path = git_path_from_bytes(&record[second_tab + 1..]);
1062 self.record_numstat_path(added, deleted, path);
1063 }
1064
1065 fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
1066 let ts = self.current_timestamp.unwrap_or(self.now_secs);
1067 self.files
1068 .entry(self.root.join(path))
1069 .or_insert_with(|| FileEvents { events: Vec::new() })
1070 .events
1071 .push(CachedCommitEvent {
1072 timestamp: ts,
1073 committed_at: self.current_committed_at.unwrap_or(ts),
1074 lines_added: added,
1075 lines_deleted: deleted,
1076 author_idx: self.current_author_idx,
1077 });
1078 }
1079
1080 fn finish(self) -> ChurnEventState {
1081 ChurnEventState {
1082 files: self.files,
1083 author_pool: self.author_pool,
1084 }
1085 }
1086}
1087
1088#[expect(
1091 clippy::cast_possible_truncation,
1092 reason = "commit count per file is bounded by git history depth"
1093)]
1094fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
1095 let mut timestamps = Vec::with_capacity(file.events.len());
1096 let mut weighted_commits = 0.0;
1097 let mut lines_added = 0_u32;
1098 let mut lines_deleted = 0_u32;
1099 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
1100
1101 for event in file.events {
1102 timestamps.push(event.timestamp);
1103 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
1104 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
1105 weighted_commits += weight;
1106 lines_added = lines_added.saturating_add(event.lines_added);
1107 lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
1108 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
1109 }
1110
1111 let commits = timestamps.len() as u32;
1112 let trend = compute_trend(×tamps);
1113 for c in authors.values_mut() {
1114 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
1115 }
1116 FileChurn {
1117 path,
1118 commits,
1119 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
1120 lines_added,
1121 lines_deleted,
1122 trend,
1123 authors,
1124 }
1125}
1126
1127fn accumulate_author(
1129 authors: &mut FxHashMap<u32, AuthorContribution>,
1130 author_idx: Option<u32>,
1131 weight: f64,
1132 timestamp: u64,
1133) {
1134 let Some(idx) = author_idx else {
1135 return;
1136 };
1137 authors
1138 .entry(idx)
1139 .and_modify(|c| {
1140 c.commits += 1;
1141 c.weighted_commits += weight;
1142 c.first_commit_ts = c.first_commit_ts.min(timestamp);
1143 c.last_commit_ts = c.last_commit_ts.max(timestamp);
1144 })
1145 .or_insert(AuthorContribution {
1146 commits: 1,
1147 weighted_commits: weight,
1148 first_commit_ts: timestamp,
1149 last_commit_ts: timestamp,
1150 });
1151}
1152
1153fn build_churn_result(
1158 state: ChurnEventState,
1159 shallow_clone: bool,
1160 clock: crate::clock::AnalysisClock,
1161) -> ChurnResult {
1162 let now_secs = clock.epoch_secs();
1163
1164 let files = state
1165 .files
1166 .into_iter()
1167 .map(|(path, file)| {
1168 let churn = aggregate_file_churn(path.clone(), file, now_secs);
1169 (path, churn)
1170 })
1171 .collect();
1172
1173 ChurnResult {
1174 files,
1175 shallow_clone,
1176 author_pool: state.author_pool,
1177 clock,
1178 }
1179}
1180
1181#[cfg(test)]
1186fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
1187 let now_secs = std::time::SystemTime::now()
1188 .duration_since(std::time::UNIX_EPOCH)
1189 .unwrap_or_default()
1190 .as_secs();
1191 let result = build_churn_result(
1192 parse_git_log_events(stdout, root, now_secs),
1193 false,
1194 crate::clock::AnalysisClock::pinned(now_secs),
1195 );
1196 (result.files, result.author_pool)
1197}
1198
1199fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1201 if let Some(&idx) = index.get(email) {
1202 return idx;
1203 }
1204 #[expect(
1205 clippy::cast_possible_truncation,
1206 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1207 )]
1208 let idx = pool.len() as u32;
1209 let owned = email.to_string();
1210 index.insert(owned.clone(), idx);
1211 pool.push(owned);
1212 idx
1213}
1214
1215fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
1218 let mut parts = line.splitn(3, '\t');
1219 let added_str = parts.next()?;
1220 let deleted_str = parts.next()?;
1221 let path = parts.next()?;
1222
1223 let added: u32 = added_str.parse().ok()?;
1224 let deleted: u32 = deleted_str.parse().ok()?;
1225
1226 Some((added, deleted, path))
1227}
1228
1229fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1237 if timestamps.len() < 2 {
1238 return ChurnTrend::Stable;
1239 }
1240
1241 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1242 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1243
1244 if max_ts == min_ts {
1245 return ChurnTrend::Stable;
1246 }
1247
1248 let midpoint = min_ts + (max_ts - min_ts) / 2;
1249 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1250 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1251
1252 if older < 1.0 {
1253 return ChurnTrend::Stable;
1254 }
1255
1256 let ratio = recent / older;
1257 if ratio > 1.5 {
1258 ChurnTrend::Accelerating
1259 } else if ratio < 0.67 {
1260 ChurnTrend::Cooling
1261 } else {
1262 ChurnTrend::Stable
1263 }
1264}
1265
1266fn is_iso_date(input: &str) -> bool {
1267 input.len() == 10
1268 && input.as_bytes().get(4) == Some(&b'-')
1269 && input.as_bytes().get(7) == Some(&b'-')
1270 && input[..4].bytes().all(|b| b.is_ascii_digit())
1271 && input[5..7].bytes().all(|b| b.is_ascii_digit())
1272 && input[8..10].bytes().all(|b| b.is_ascii_digit())
1273}
1274
1275fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1276 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1277 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1278 })?;
1279 if pos == 0 {
1280 return Err(format!(
1281 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1282 ));
1283 }
1284 Ok((&input[..pos], &input[pos..]))
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290
1291 #[test]
1292 fn parse_since_months_short() {
1293 let d = parse_since("6m").unwrap();
1294 assert_eq!(
1295 d.window,
1296 ChurnWindow::Relative {
1297 count: 6,
1298 unit: ChurnWindowUnit::Months
1299 }
1300 );
1301 assert_eq!(d.display, "6 months");
1302 }
1303
1304 #[test]
1305 fn parse_since_months_long() {
1306 let d = parse_since("6months").unwrap();
1307 assert_eq!(
1308 d.window,
1309 ChurnWindow::Relative {
1310 count: 6,
1311 unit: ChurnWindowUnit::Months
1312 }
1313 );
1314 assert_eq!(d.display, "6 months");
1315 }
1316
1317 #[test]
1318 fn parse_since_days() {
1319 let d = parse_since("90d").unwrap();
1320 assert_eq!(
1321 d.window,
1322 ChurnWindow::Relative {
1323 count: 90,
1324 unit: ChurnWindowUnit::Days
1325 }
1326 );
1327 assert_eq!(d.display, "90 days");
1328 }
1329
1330 #[test]
1331 fn parse_since_year_singular() {
1332 let d = parse_since("1y").unwrap();
1333 assert_eq!(
1334 d.window,
1335 ChurnWindow::Relative {
1336 count: 1,
1337 unit: ChurnWindowUnit::Years
1338 }
1339 );
1340 assert_eq!(d.display, "1 year");
1341 }
1342
1343 #[test]
1344 fn parse_since_years_plural() {
1345 let d = parse_since("2years").unwrap();
1346 assert_eq!(
1347 d.window,
1348 ChurnWindow::Relative {
1349 count: 2,
1350 unit: ChurnWindowUnit::Years
1351 }
1352 );
1353 assert_eq!(d.display, "2 years");
1354 }
1355
1356 #[test]
1357 fn parse_since_weeks() {
1358 let d = parse_since("2w").unwrap();
1359 assert_eq!(
1360 d.window,
1361 ChurnWindow::Relative {
1362 count: 2,
1363 unit: ChurnWindowUnit::Weeks
1364 }
1365 );
1366 assert_eq!(d.display, "2 weeks");
1367 }
1368
1369 #[test]
1370 fn parse_since_iso_date() {
1371 let d = parse_since("2025-06-01").unwrap();
1372 assert_eq!(d.window, ChurnWindow::Date("2025-06-01".to_string()));
1373 assert_eq!(d.display, "2025-06-01");
1374 }
1375
1376 #[test]
1377 fn parse_since_month_singular() {
1378 let d = parse_since("1month").unwrap();
1379 assert_eq!(d.display, "1 month");
1380 }
1381
1382 #[test]
1383 fn parse_since_day_singular() {
1384 let d = parse_since("1day").unwrap();
1385 assert_eq!(d.display, "1 day");
1386 }
1387
1388 #[test]
1389 fn parse_since_zero_rejected() {
1390 assert!(parse_since("0m").is_err());
1391 }
1392
1393 #[test]
1394 fn parse_since_no_unit_rejected() {
1395 assert!(parse_since("90").is_err());
1396 }
1397
1398 #[test]
1399 fn parse_since_unknown_unit_rejected() {
1400 assert!(parse_since("6x").is_err());
1401 }
1402
1403 #[test]
1404 fn parse_since_no_number_rejected() {
1405 assert!(parse_since("months").is_err());
1406 }
1407
1408 #[test]
1409 fn numstat_normal() {
1410 let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
1411 assert_eq!(a, 10);
1412 assert_eq!(d, 5);
1413 assert_eq!(p, "src/file.ts");
1414 }
1415
1416 #[test]
1417 fn numstat_binary_skipped() {
1418 assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
1419 }
1420
1421 #[test]
1422 fn numstat_zero_lines() {
1423 let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
1424 assert_eq!(a, 0);
1425 assert_eq!(d, 0);
1426 assert_eq!(p, "src/empty.ts");
1427 }
1428
1429 #[test]
1430 fn trend_empty_is_stable() {
1431 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1432 }
1433
1434 #[test]
1435 fn trend_single_commit_is_stable() {
1436 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1437 }
1438
1439 #[test]
1440 fn trend_accelerating() {
1441 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1442 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1443 }
1444
1445 #[test]
1446 fn trend_cooling() {
1447 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1448 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1449 }
1450
1451 #[test]
1452 fn trend_stable_even_distribution() {
1453 let timestamps = vec![100, 200, 300, 700, 800, 900];
1454 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1455 }
1456
1457 #[test]
1458 fn trend_same_timestamp_is_stable() {
1459 let timestamps = vec![500, 500, 500];
1460 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1461 }
1462
1463 #[test]
1464 fn iso_date_valid() {
1465 assert!(is_iso_date("2025-06-01"));
1466 assert!(is_iso_date("2025-12-31"));
1467 }
1468
1469 #[test]
1470 fn iso_date_with_time_rejected() {
1471 assert!(!is_iso_date("2025-06-01T00:00:00"));
1472 }
1473
1474 #[test]
1475 fn iso_date_invalid() {
1476 assert!(!is_iso_date("6months"));
1477 assert!(!is_iso_date("2025"));
1478 assert!(!is_iso_date("not-a-date"));
1479 assert!(!is_iso_date("abcd-ef-gh"));
1480 }
1481
1482 #[test]
1483 fn trend_display() {
1484 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1485 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1486 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1487 }
1488
1489 #[test]
1490 fn parse_git_log_single_commit() {
1491 let root = Path::new("/project");
1492 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1493 let (result, _) = parse_git_log(output, root);
1494 assert_eq!(result.len(), 1);
1495 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1496 assert_eq!(churn.commits, 1);
1497 assert_eq!(churn.lines_added, 10);
1498 assert_eq!(churn.lines_deleted, 5);
1499 }
1500
1501 #[test]
1502 fn parse_git_log_multiple_commits_same_file() {
1503 let root = Path::new("/project");
1504 let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
1505 let (result, _) = parse_git_log(output, root);
1506 assert_eq!(result.len(), 1);
1507 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1508 assert_eq!(churn.commits, 2);
1509 assert_eq!(churn.lines_added, 13);
1510 assert_eq!(churn.lines_deleted, 7);
1511 }
1512
1513 #[test]
1514 fn parse_git_log_multiple_files() {
1515 let root = Path::new("/project");
1516 let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
1517 let (result, _) = parse_git_log(output, root);
1518 assert_eq!(result.len(), 2);
1519 assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
1520 assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
1521 }
1522
1523 #[test]
1524 fn parse_git_log_empty_output() {
1525 let root = Path::new("/project");
1526 let (result, _) = parse_git_log("", root);
1527 assert!(result.is_empty());
1528 }
1529
1530 #[test]
1531 fn parse_git_log_skips_binary_files() {
1532 let root = Path::new("/project");
1533 let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
1534 let (result, _) = parse_git_log(output, root);
1535 assert_eq!(result.len(), 1);
1536 assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
1537 }
1538
1539 #[test]
1540 fn parse_git_log_weighted_commits_are_positive() {
1541 let root = Path::new("/project");
1542 let now_secs = std::time::SystemTime::now()
1543 .duration_since(std::time::UNIX_EPOCH)
1544 .unwrap()
1545 .as_secs();
1546 let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
1547 let (result, _) = parse_git_log(&output, root);
1548 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1549 assert!(
1550 churn.weighted_commits > 0.0,
1551 "weighted_commits should be positive for recent commits"
1552 );
1553 }
1554
1555 #[test]
1556 fn trend_boundary_1_5x_ratio() {
1557 let timestamps = vec![100, 200, 600, 800, 1000];
1558 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1559 }
1560
1561 #[test]
1562 fn trend_just_above_1_5x() {
1563 let timestamps = vec![100, 600, 800, 1000];
1564 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1565 }
1566
1567 #[test]
1568 fn trend_boundary_0_67x_ratio() {
1569 let timestamps = vec![100, 200, 300, 600, 1000];
1570 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1571 }
1572
1573 #[test]
1574 fn trend_two_timestamps_different() {
1575 let timestamps = vec![100, 200];
1576 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1577 }
1578
1579 #[test]
1580 fn parse_since_week_singular() {
1581 let d = parse_since("1week").unwrap();
1582 assert_eq!(
1583 d.window,
1584 ChurnWindow::Relative {
1585 count: 1,
1586 unit: ChurnWindowUnit::Weeks
1587 }
1588 );
1589 assert_eq!(d.display, "1 week");
1590 }
1591
1592 #[test]
1593 fn parse_since_weeks_long() {
1594 let d = parse_since("3weeks").unwrap();
1595 assert_eq!(
1596 d.window,
1597 ChurnWindow::Relative {
1598 count: 3,
1599 unit: ChurnWindowUnit::Weeks
1600 }
1601 );
1602 assert_eq!(d.display, "3 weeks");
1603 }
1604
1605 #[test]
1606 fn parse_since_days_long() {
1607 let d = parse_since("30days").unwrap();
1608 assert_eq!(
1609 d.window,
1610 ChurnWindow::Relative {
1611 count: 30,
1612 unit: ChurnWindowUnit::Days
1613 }
1614 );
1615 assert_eq!(d.display, "30 days");
1616 }
1617
1618 #[test]
1619 fn parse_since_year_long() {
1620 let d = parse_since("1year").unwrap();
1621 assert_eq!(
1622 d.window,
1623 ChurnWindow::Relative {
1624 count: 1,
1625 unit: ChurnWindowUnit::Years
1626 }
1627 );
1628 assert_eq!(d.display, "1 year");
1629 }
1630
1631 #[test]
1632 fn parse_since_overflow_number_rejected() {
1633 let result = parse_since("99999999999999999999d");
1634 assert!(result.is_err());
1635 let err = result.unwrap_err();
1636 assert!(err.contains("invalid number"));
1637 }
1638
1639 #[test]
1640 fn parse_since_zero_days_rejected() {
1641 assert!(parse_since("0d").is_err());
1642 }
1643
1644 #[test]
1645 fn parse_since_zero_weeks_rejected() {
1646 assert!(parse_since("0w").is_err());
1647 }
1648
1649 #[test]
1650 fn parse_since_zero_years_rejected() {
1651 assert!(parse_since("0y").is_err());
1652 }
1653
1654 #[test]
1655 fn numstat_missing_path() {
1656 assert!(parse_numstat_line("10\t5").is_none());
1657 }
1658
1659 #[test]
1660 fn numstat_single_field() {
1661 assert!(parse_numstat_line("10").is_none());
1662 }
1663
1664 #[test]
1665 fn numstat_empty_string() {
1666 assert!(parse_numstat_line("").is_none());
1667 }
1668
1669 #[test]
1670 fn numstat_only_added_is_binary() {
1671 assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1672 }
1673
1674 #[test]
1675 fn numstat_only_deleted_is_binary() {
1676 assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1677 }
1678
1679 #[test]
1680 fn numstat_path_with_spaces() {
1681 let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1682 assert_eq!(a, 3);
1683 assert_eq!(d, 1);
1684 assert_eq!(p, "path with spaces/file.ts");
1685 }
1686
1687 #[test]
1688 fn numstat_large_numbers() {
1689 let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1690 assert_eq!(a, 9999);
1691 assert_eq!(d, 8888);
1692 assert_eq!(p, "src/big.ts");
1693 }
1694
1695 #[test]
1696 fn iso_date_wrong_separator_positions() {
1697 assert!(!is_iso_date("20-25-0601"));
1698 assert!(!is_iso_date("202506-01-"));
1699 }
1700
1701 #[test]
1702 fn iso_date_too_short() {
1703 assert!(!is_iso_date("2025-06-0"));
1704 }
1705
1706 #[test]
1707 fn iso_date_letters_in_day() {
1708 assert!(!is_iso_date("2025-06-ab"));
1709 }
1710
1711 #[test]
1712 fn iso_date_letters_in_month() {
1713 assert!(!is_iso_date("2025-ab-01"));
1714 }
1715
1716 #[test]
1717 fn split_number_unit_valid() {
1718 let (num, unit) = split_number_unit("42days").unwrap();
1719 assert_eq!(num, "42");
1720 assert_eq!(unit, "days");
1721 }
1722
1723 #[test]
1724 fn split_number_unit_single_digit() {
1725 let (num, unit) = split_number_unit("1m").unwrap();
1726 assert_eq!(num, "1");
1727 assert_eq!(unit, "m");
1728 }
1729
1730 #[test]
1731 fn split_number_unit_no_digits() {
1732 let err = split_number_unit("abc").unwrap_err();
1733 assert!(err.contains("must start with a number"));
1734 }
1735
1736 #[test]
1737 fn split_number_unit_no_unit() {
1738 let err = split_number_unit("123").unwrap_err();
1739 assert!(err.contains("requires a unit suffix"));
1740 }
1741
1742 #[test]
1743 fn parse_git_log_numstat_before_timestamp_uses_now() {
1744 let root = Path::new("/project");
1745 let output = "10\t5\tsrc/no_ts.ts\n";
1746 let (result, _) = parse_git_log(output, root);
1747 assert_eq!(result.len(), 1);
1748 let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1749 assert_eq!(churn.commits, 1);
1750 assert_eq!(churn.lines_added, 10);
1751 assert_eq!(churn.lines_deleted, 5);
1752 assert!(
1753 churn.weighted_commits > 0.9,
1754 "weight should be near 1.0 when timestamp defaults to now"
1755 );
1756 }
1757
1758 #[test]
1759 fn parse_git_log_whitespace_lines_ignored() {
1760 let root = Path::new("/project");
1761 let output = " \n1700000000\n \n10\t5\tsrc/a.ts\n \n";
1762 let (result, _) = parse_git_log(output, root);
1763 assert_eq!(result.len(), 1);
1764 }
1765
1766 #[test]
1767 fn parse_git_log_trend_is_computed_per_file() {
1768 let root = Path::new("/project");
1769 let output = "\
17701000\n5\t1\tsrc/old.ts\n\
17712000\n3\t1\tsrc/old.ts\n\
17721000\n1\t0\tsrc/hot.ts\n\
17731800\n1\t0\tsrc/hot.ts\n\
17741900\n1\t0\tsrc/hot.ts\n\
17751950\n1\t0\tsrc/hot.ts\n\
17762000\n1\t0\tsrc/hot.ts\n";
1777 let (result, _) = parse_git_log(output, root);
1778 let old = &result[&PathBuf::from("/project/src/old.ts")];
1779 let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1780 assert_eq!(old.commits, 2);
1781 assert_eq!(hot.commits, 5);
1782 assert_eq!(hot.trend, ChurnTrend::Accelerating);
1783 }
1784
1785 #[test]
1786 fn parse_git_log_weighted_decay_for_old_commits() {
1787 let root = Path::new("/project");
1788 let now = std::time::SystemTime::now()
1789 .duration_since(std::time::UNIX_EPOCH)
1790 .unwrap()
1791 .as_secs();
1792 let old_ts = now - (180 * 86_400);
1793 let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1794 let (result, _) = parse_git_log(&output, root);
1795 let churn = &result[&PathBuf::from("/project/src/old.ts")];
1796 assert!(
1797 churn.weighted_commits < 0.5,
1798 "180-day-old commit should weigh ~0.25, got {}",
1799 churn.weighted_commits
1800 );
1801 assert!(
1802 churn.weighted_commits > 0.1,
1803 "180-day-old commit should weigh ~0.25, got {}",
1804 churn.weighted_commits
1805 );
1806 }
1807
1808 #[test]
1809 fn parse_git_log_path_stored_as_absolute() {
1810 let root = Path::new("/my/project");
1811 let output = "1700000000\n1\t0\tlib/utils.ts\n";
1812 let (result, _) = parse_git_log(output, root);
1813 let key = PathBuf::from("/my/project/lib/utils.ts");
1814 assert!(result.contains_key(&key));
1815 assert_eq!(result[&key].path, key);
1816 }
1817
1818 #[test]
1819 fn parse_git_log_weighted_commits_rounded() {
1820 let root = Path::new("/project");
1821 let now = std::time::SystemTime::now()
1822 .duration_since(std::time::UNIX_EPOCH)
1823 .unwrap()
1824 .as_secs();
1825 let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1826 let (result, _) = parse_git_log(&output, root);
1827 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1828 let decimals = format!("{:.2}", churn.weighted_commits);
1829 assert_eq!(
1830 churn.weighted_commits.to_string().len(),
1831 decimals.len().min(churn.weighted_commits.to_string().len()),
1832 "weighted_commits should be rounded to at most 2 decimal places"
1833 );
1834 }
1835
1836 #[test]
1837 fn trend_serde_serialization() {
1838 assert_eq!(
1839 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1840 "\"accelerating\""
1841 );
1842 assert_eq!(
1843 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1844 "\"stable\""
1845 );
1846 assert_eq!(
1847 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1848 "\"cooling\""
1849 );
1850 }
1851
1852 #[test]
1853 fn parse_git_log_extracts_author_email() {
1854 let root = Path::new("/project");
1855 let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1856 let (result, pool) = parse_git_log(output, root);
1857 assert_eq!(pool, vec!["alice@example.com".to_string()]);
1858 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1859 assert_eq!(churn.authors.len(), 1);
1860 let alice = &churn.authors[&0];
1861 assert_eq!(alice.commits, 1);
1862 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1863 assert_eq!(alice.last_commit_ts, 1_700_000_000);
1864 }
1865
1866 #[test]
1867 fn parse_git_log_intern_dedupes_authors() {
1868 let root = Path::new("/project");
1869 let output = "\
18701700000000|alice@example.com
18711\t0\ta.ts
18721700100000|bob@example.com
18732\t1\tb.ts
18741700200000|alice@example.com
18753\t2\tc.ts
1876";
1877 let (_result, pool) = parse_git_log(output, root);
1878 assert_eq!(pool.len(), 2);
1879 assert!(pool.contains(&"alice@example.com".to_string()));
1880 assert!(pool.contains(&"bob@example.com".to_string()));
1881 }
1882
1883 #[test]
1884 fn parse_git_log_aggregates_per_author() {
1885 let root = Path::new("/project");
1886 let output = "\
18871700000000|alice@example.com
18881\t0\tsrc/index.ts
18891700100000|bob@example.com
18902\t0\tsrc/index.ts
18911700200000|alice@example.com
18921\t1\tsrc/index.ts
1893";
1894 let (result, pool) = parse_git_log(output, root);
1895 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1896 assert_eq!(churn.commits, 3);
1897 assert_eq!(churn.authors.len(), 2);
1898
1899 let alice_idx =
1900 u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1901 let alice = &churn.authors[&alice_idx];
1902 assert_eq!(alice.commits, 2);
1903 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1904 assert_eq!(alice.last_commit_ts, 1_700_200_000);
1905 }
1906
1907 #[test]
1908 fn parse_git_log_legacy_bare_timestamp_still_parses() {
1909 let root = Path::new("/project");
1910 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1911 let (result, pool) = parse_git_log(output, root);
1912 assert!(pool.is_empty());
1913 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1914 assert_eq!(churn.commits, 1);
1915 assert!(churn.authors.is_empty());
1916 }
1917
1918 #[test]
1919 fn intern_author_returns_existing_index() {
1920 let mut pool = Vec::new();
1921 let mut index = FxHashMap::default();
1922 let i1 = intern_author("alice@x", &mut pool, &mut index);
1923 let i2 = intern_author("alice@x", &mut pool, &mut index);
1924 assert_eq!(i1, i2);
1925 assert_eq!(pool.len(), 1);
1926 }
1927
1928 #[test]
1929 fn intern_author_assigns_sequential_indices() {
1930 let mut pool = Vec::new();
1931 let mut index = FxHashMap::default();
1932 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1933 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1934 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1935 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1936 }
1937
1938 fn git(root: &Path, args: &[&str]) {
1939 let status = std::process::Command::new("git")
1940 .args(args)
1941 .current_dir(root)
1942 .status()
1943 .expect("run git");
1944 assert!(status.success(), "git {args:?} failed");
1945 }
1946
1947 fn write(root: &Path, path: &str, contents: &str) {
1948 let path = root.join(path);
1949 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1950 std::fs::write(path, contents).unwrap();
1951 }
1952
1953 #[cfg(unix)]
1954 #[test]
1955 fn churn_preserves_special_filenames() {
1956 let repo = tempfile::tempdir().expect("create repo");
1957 let root = repo.path();
1958 git(root, &["init", "--quiet"]);
1959 git(root, &["config", "user.email", "churn@example.test"]);
1960 git(root, &["config", "user.name", "Churn Test"]);
1961 git(root, &["config", "commit.gpgsign", "false"]);
1962
1963 let special_files = [
1964 "src/line\nbreak.ts",
1965 "src/space name.ts",
1966 "src/quote\"name.ts",
1967 "src/back\\slash.ts",
1968 "src/unicode-λ.ts",
1969 ]
1970 .map(|path| root.join(path));
1971 std::fs::create_dir_all(root.join("src")).expect("source dir");
1972 for special in &special_files {
1973 std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1974 }
1975 git(root, &["add", "."]);
1976 git(root, &["commit", "--quiet", "-m", "initial"]);
1977
1978 let since = parse_since("1y").expect("valid duration");
1979 let churn = analyze_churn(root, &since).expect("churn result");
1980 for special in special_files {
1981 assert!(
1982 churn.files.contains_key(&special),
1983 "missing {special:?}: {:?}",
1984 churn.files.keys()
1985 );
1986 }
1987 }
1988
1989 #[cfg(unix)]
1990 #[test]
1991 fn churn_cache_preserves_non_utf8_filenames() {
1992 use std::ffi::OsString;
1993 use std::os::unix::ffi::OsStringExt;
1994
1995 let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1996 let mut files = FxHashMap::default();
1997 files.insert(
1998 invalid_path.clone(),
1999 FileEvents {
2000 events: vec![CachedCommitEvent {
2001 timestamp: 1,
2002 committed_at: 1,
2003 lines_added: 2,
2004 lines_deleted: 1,
2005 author_idx: None,
2006 }],
2007 },
2008 );
2009 let state = ChurnEventState {
2010 files,
2011 author_pool: Vec::new(),
2012 };
2013 let cache_dir = tempfile::tempdir().expect("cache directory");
2014 save_churn_cache(cache_dir.path(), "abc123", "1y", &state, false);
2015 let warm = load_churn_cache(cache_dir.path(), "1y")
2016 .expect("warm churn cache")
2017 .into_event_state(None);
2018
2019 assert!(warm.files.contains_key(&invalid_path));
2020 }
2021
2022 #[test]
2023 fn churn_cache_rejects_pre_lossless_path_encoding_version() {
2024 let cache_dir = tempfile::tempdir().expect("cache directory");
2025 let cache = ChurnCache {
2026 version: 4,
2027 last_indexed_sha: "abc123".to_string(),
2028 window_token: "1y".to_string(),
2029 files: vec![CachedFileChurn {
2030 path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
2031 events: Vec::new(),
2032 }],
2033 shallow_clone: false,
2034 author_pool: Vec::new(),
2035 };
2036 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
2037 .expect("cache fixture");
2038
2039 assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
2040 }
2041
2042 #[test]
2043 fn cached_churn_merges_new_commits_after_head_advances() {
2044 let repo = tempfile::tempdir().expect("create repo");
2045 let root = repo.path();
2046 git(root, &["init"]);
2047 git(root, &["config", "user.email", "churn@example.test"]);
2048 git(root, &["config", "user.name", "Churn Test"]);
2049 git(root, &["config", "commit.gpgsign", "false"]);
2050
2051 write(root, "src/a.ts", "export const a = 1;\n");
2052 git(root, &["add", "."]);
2053 git(root, &["commit", "-m", "initial"]);
2054
2055 let since = parse_since("1y").unwrap();
2056 let cache = tempfile::tempdir().expect("create cache dir");
2057 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2058 assert!(!cold_hit);
2059 let file = root.join("src/a.ts");
2060 assert_eq!(cold.files[&file].commits, 1);
2061
2062 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2063 assert!(warm_hit);
2064
2065 write(
2066 root,
2067 "src/a.ts",
2068 "export const a = 1;\nexport const b = 2;\n",
2069 );
2070 git(root, &["add", "."]);
2071 git(root, &["commit", "-m", "update a"]);
2072 let head = get_head_sha(root).unwrap();
2073
2074 let (incremental, incremental_hit) =
2075 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2076 assert!(incremental_hit);
2077 assert_eq!(incremental.files[&file].commits, 2);
2078
2079 let cache = load_churn_cache(cache.path(), &since.window.cache_token()).unwrap();
2080 assert_eq!(cache.last_indexed_sha, head);
2081 }
2082
2083 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
2084 let path = dir.join("churn.json");
2085 std::fs::write(&path, contents).unwrap();
2086 path
2087 }
2088
2089 #[test]
2090 fn churn_file_happy_path() {
2091 let dir = tempfile::tempdir().unwrap();
2092 let root = Path::new("/project");
2093 let path = write_churn_file(
2094 dir.path(),
2095 r#"{
2096 "schema": "fallow-churn/v1",
2097 "events": [
2098 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2099 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2100 ]
2101 }"#,
2102 );
2103 let result = analyze_churn_from_file(&path, root).unwrap();
2104 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2105 assert_eq!(churn.commits, 2);
2106 assert_eq!(churn.lines_added, 13);
2107 assert_eq!(churn.lines_deleted, 7);
2108 assert_eq!(churn.authors.len(), 2);
2109 assert!(result.author_pool.contains(&"alice@corp".to_string()));
2110 assert!(result.author_pool.contains(&"bob@corp".to_string()));
2111 assert!(!result.shallow_clone);
2112 }
2113
2114 #[test]
2115 fn churn_file_matches_git_parse() {
2116 let dir = tempfile::tempdir().unwrap();
2120 let root = Path::new("/project");
2121 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";
2122 let (git_files, git_pool) = parse_git_log(git_output, root);
2123
2124 let path = write_churn_file(
2125 dir.path(),
2126 r#"{
2127 "schema": "fallow-churn/v1",
2128 "events": [
2129 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2130 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
2131 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2132 ]
2133 }"#,
2134 );
2135 let imported = analyze_churn_from_file(&path, root).unwrap();
2136
2137 assert_eq!(git_pool, imported.author_pool, "author pools diverge");
2138 assert_eq!(git_files.len(), imported.files.len());
2139 for (file, git_churn) in &git_files {
2140 let imp = &imported.files[file];
2141 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
2142 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
2143 assert_eq!(
2144 git_churn.lines_deleted, imp.lines_deleted,
2145 "deleted for {file:?}"
2146 );
2147 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
2148 assert_eq!(
2149 git_churn.authors.len(),
2150 imp.authors.len(),
2151 "authors for {file:?}"
2152 );
2153 assert!(
2154 (git_churn.weighted_commits - imp.weighted_commits).abs() < 0.02,
2155 "weighted_commits for {file:?}: {} vs {}",
2156 git_churn.weighted_commits,
2157 imp.weighted_commits
2158 );
2159 }
2160 }
2161
2162 #[test]
2163 fn churn_file_empty_events_is_valid() {
2164 let dir = tempfile::tempdir().unwrap();
2165 let path = write_churn_file(
2166 dir.path(),
2167 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
2168 );
2169 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2170 assert!(result.files.is_empty());
2171 assert!(result.author_pool.is_empty());
2172 }
2173
2174 #[test]
2175 fn churn_file_missing_events_key_is_valid() {
2176 let dir = tempfile::tempdir().unwrap();
2177 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
2178 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2179 assert!(result.files.is_empty());
2180 }
2181
2182 #[test]
2183 fn churn_file_bad_schema_rejected() {
2184 let dir = tempfile::tempdir().unwrap();
2185 let path = write_churn_file(
2186 dir.path(),
2187 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
2188 );
2189 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2190 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
2191 }
2192
2193 #[test]
2194 fn churn_file_malformed_json_rejected() {
2195 let dir = tempfile::tempdir().unwrap();
2196 let path = write_churn_file(dir.path(), "{ not json");
2197 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
2198 }
2199
2200 #[test]
2201 fn churn_file_missing_file_rejected() {
2202 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
2203 .unwrap_err();
2204 assert!(err.contains("failed to read churn file"), "{err}");
2205 }
2206
2207 #[test]
2208 fn churn_file_reader_accepts_exact_limit() {
2209 let dir = tempfile::tempdir().unwrap();
2210 let path = write_churn_file(dir.path(), "12345678");
2211 assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
2212 }
2213
2214 #[test]
2215 fn churn_file_reader_rejects_limit_plus_one() {
2216 let dir = tempfile::tempdir().unwrap();
2217 let path = write_churn_file(dir.path(), "123456789");
2218 let err = read_churn_file_with_limit(&path, 8).unwrap_err();
2219 assert!(err.contains("at least 9 bytes"), "{err}");
2220 assert!(err.contains("8 byte limit"), "{err}");
2221 }
2222
2223 #[test]
2224 fn churn_file_empty_path_rejected() {
2225 let dir = tempfile::tempdir().unwrap();
2226 let path = write_churn_file(
2227 dir.path(),
2228 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2229 );
2230 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2231 assert!(err.contains("empty path"), "{err}");
2232 }
2233
2234 #[test]
2235 fn churn_file_rejects_non_relative_paths() {
2236 let invalid = [
2237 "/tmp/a.ts",
2238 r"C:\tmp\a.ts",
2239 "../a.ts",
2240 "src/../../a.ts",
2241 "./src/a.ts",
2242 "//server/share/a.ts",
2243 ];
2244 for event_path in invalid {
2245 let dir = tempfile::tempdir().unwrap();
2246 let body = format!(
2247 r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
2248 );
2249 let path = write_churn_file(dir.path(), &body);
2250 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2251 assert!(err.contains(event_path), "{event_path}: {err}");
2252 assert!(err.contains("repo-relative"), "{event_path}: {err}");
2253 }
2254 }
2255
2256 #[test]
2257 fn churn_file_accepts_unicode_and_spaces_in_path_components() {
2258 let dir = tempfile::tempdir().unwrap();
2259 let path = write_churn_file(
2260 dir.path(),
2261 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2262 );
2263 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2264 assert!(
2265 result
2266 .files
2267 .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
2268 );
2269 }
2270
2271 #[test]
2272 fn churn_file_millisecond_timestamp_rejected() {
2273 let dir = tempfile::tempdir().unwrap();
2274 let path = write_churn_file(
2276 dir.path(),
2277 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
2278 );
2279 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2280 assert!(err.contains("milliseconds"), "{err}");
2281 }
2282
2283 #[test]
2284 fn churn_file_missing_author_contributes_no_signal() {
2285 let dir = tempfile::tempdir().unwrap();
2286 let path = write_churn_file(
2287 dir.path(),
2288 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2289 );
2290 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2291 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2292 assert_eq!(churn.commits, 1);
2293 assert!(churn.authors.is_empty());
2294 assert!(result.author_pool.is_empty());
2295 }
2296
2297 #[test]
2298 fn churn_file_empty_author_string_treated_as_absent() {
2299 let dir = tempfile::tempdir().unwrap();
2300 let path = write_churn_file(
2301 dir.path(),
2302 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
2303 );
2304 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2305 assert!(result.author_pool.is_empty());
2306 }
2307
2308 #[test]
2309 fn churn_file_unknown_fields_ignored() {
2310 let dir = tempfile::tempdir().unwrap();
2313 let path = write_churn_file(
2314 dir.path(),
2315 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" } ] }"#,
2316 );
2317 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2318 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2319 }
2320
2321 #[test]
2322 fn churn_file_backslash_paths_normalized() {
2323 let dir = tempfile::tempdir().unwrap();
2324 let path = write_churn_file(
2325 dir.path(),
2326 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2327 );
2328 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2329 assert!(
2330 result
2331 .files
2332 .contains_key(&PathBuf::from("/project/src/a.ts"))
2333 );
2334 }
2335
2336 #[test]
2337 fn churn_file_rejects_line_totals_above_u32() {
2338 let dir = tempfile::tempdir().unwrap();
2339 let path = write_churn_file(
2340 dir.path(),
2341 r#"{ "schema": "fallow-churn/v1", "events": [
2342 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
2343 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2344 ] }"#,
2345 );
2346 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2347 assert!(err.contains("exceeding the u32 limit"), "{err}");
2348 assert!(err.contains("src/a.ts"), "{err}");
2349 }
2350
2351 #[test]
2352 fn churn_file_accepts_line_totals_at_u32_max() {
2353 let dir = tempfile::tempdir().unwrap();
2354 let path = write_churn_file(
2355 dir.path(),
2356 r#"{ "schema": "fallow-churn/v1", "events": [
2357 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
2358 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2359 ] }"#,
2360 );
2361 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2362 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2363 assert_eq!(churn.lines_added, u32::MAX);
2364 assert_eq!(churn.lines_deleted, u32::MAX);
2365 }
2366
2367 #[test]
2372 fn headerless_numstat_falls_back_to_the_run_clock() {
2373 let root = Path::new("/project");
2374 let pinned = 1_700_000_000;
2375
2376 let state = parse_git_log_events_z(b"10\t5\tsrc/a.ts", root, pinned);
2377
2378 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2379 assert_eq!(events.len(), 1);
2380 assert_eq!(events[0].timestamp, pinned);
2381 assert_eq!(events[0].committed_at, pinned);
2382 }
2383
2384 #[test]
2388 fn headerless_numstat_tracks_only_the_supplied_clock() {
2389 let root = Path::new("/project");
2390 let record: &[u8] = b"1\t0\tsrc/a.ts";
2391
2392 let early = parse_git_log_events_z(record, root, 1_600_000_000);
2393 let late = parse_git_log_events_z(record, root, 1_700_000_000);
2394
2395 let key = PathBuf::from("/project/src/a.ts");
2396 assert_eq!(early.files[&key].events[0].timestamp, 1_600_000_000);
2397 assert_eq!(late.files[&key].events[0].timestamp, 1_700_000_000);
2398 }
2399
2400 #[test]
2402 fn commit_header_timestamp_beats_the_run_clock_fallback() {
2403 let root = Path::new("/project");
2404 let record = b"1700000000|1700000500|dev@example.com\x002\t1\tsrc/a.ts";
2405
2406 let state = parse_git_log_events_z(record, root, 1_234_567_890);
2407
2408 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2409 assert_eq!(events[0].timestamp, 1_700_000_000);
2410 assert_eq!(events[0].committed_at, 1_700_000_500);
2411 }
2412}