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