Skip to main content

fallow_engine/
churn.rs

1//! Git churn analysis for hotspot detection.
2//!
3//! Shells out to `git log` to collect per-file change history, then computes
4//! recency-weighted churn scores and trend indicators.
5
6use rustc_hash::FxHashMap;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Output};
9use std::sync::OnceLock;
10
11use serde::Deserialize;
12
13pub use fallow_types::churn::ChurnTrend;
14
15/// Function pointer signature used by `set_spawn_hook` to intercept the
16/// `git log --numstat` subprocess. Lets the CLI route long-running git
17/// log calls through its `ScopedChild` registry so SIGINT / SIGTERM
18/// reap the subprocess instead of leaving it running after the parent
19/// exits. See `crates/cli/src/signal/` and issue #477.
20pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
21
22static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
23
24#[expect(
25    clippy::disallowed_methods,
26    reason = "engine-owned git spawn wrapper clears ambient git env before churn subprocesses"
27)]
28fn git_command() -> Command {
29    let mut command = Command::new("git");
30    crate::git_env::clear_ambient_git_env(&mut command);
31    command
32}
33
34/// Install a spawn-hook that wraps the `git log` subprocess. Idempotent;
35/// subsequent calls are no-ops. Called once from the CLI's `main()` to
36/// route through the signal registry; defaults to `Command::output`
37/// when not set so the function-pointer indirection stays free for tests
38/// and embedders that don't care.
39pub fn set_spawn_hook(hook: ChurnSpawnHook) {
40    let _ = SPAWN_HOOK.set(hook);
41}
42
43fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
44    if let Some(hook) = SPAWN_HOOK.get() {
45        hook(command)
46    } else {
47        command.output()
48    }
49}
50
51/// Number of seconds in one day.
52const SECS_PER_DAY: f64 = 86_400.0;
53
54/// Recency weight half-life in days. A commit from 90 days ago counts half
55/// as much as today's commit; 180 days ago counts 25%.
56const HALF_LIFE_DAYS: f64 = 90.0;
57
58/// Schema discriminator a `--churn-file` document must declare.
59const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
60
61/// Upper bound on imported churn events. A file past this size is a sign of a
62/// pathological export (whole-history dump of a giant monorepo) rather than a
63/// useful hotspot window; parsing is rejected so we never allocate unbounded
64/// state from a single untrusted file. Mirrors the diff parser's
65/// `MAX_ADDED_LINES` guard in the CLI.
66const MAX_CHURN_EVENTS: usize = 5_000_000;
67
68/// Reject an imported `timestamp` more than this many seconds in the future
69/// (one year). A unix-seconds commit time is never legitimately this far ahead
70/// even with clock skew, so a value past it is almost always a millisecond
71/// timestamp (~52000 years out) or corruption. Caught loudly because the
72/// recency decay uses `saturating_sub`, so a future timestamp would otherwise
73/// clamp to age 0, give every commit full weight, and silently collapse the
74/// recency signal that distinguishes recent from old churn.
75const MAX_FUTURE_TIMESTAMP_SECS: u64 = 365 * 24 * 60 * 60;
76
77/// Parsed duration for the `--since` flag.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SinceDuration {
80    /// Value to pass to `git log --after` (e.g., `"6 months ago"` or `"2025-06-01"`).
81    pub git_after: String,
82    /// Human-readable display string (e.g., `"6 months"`).
83    pub display: String,
84}
85
86/// Per-author commit aggregation for a single file.
87///
88/// Authors are interned via [`ChurnResult::author_pool`] indices to keep
89/// per-file maps small and the bitcode cache compact.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct AuthorContribution {
92    /// Total commits by this author touching this file in the analysis window.
93    pub commits: u32,
94    /// Recency-weighted commit sum (exponential decay, half-life 90 days).
95    pub weighted_commits: f64,
96    /// Earliest commit timestamp by this author (epoch seconds).
97    pub first_commit_ts: u64,
98    /// Latest commit timestamp by this author (epoch seconds).
99    pub last_commit_ts: u64,
100}
101
102/// Per-file churn data collected from git history.
103#[derive(Debug, Clone)]
104pub struct FileChurn {
105    /// Absolute file path.
106    pub path: PathBuf,
107    /// Total number of commits touching this file in the analysis window.
108    pub commits: u32,
109    /// Recency-weighted commit count (exponential decay, half-life 90 days).
110    pub weighted_commits: f64,
111    /// Total lines added across all commits.
112    pub lines_added: u32,
113    /// Total lines deleted across all commits.
114    pub lines_deleted: u32,
115    /// Churn trend: accelerating, stable, or cooling.
116    pub trend: ChurnTrend,
117    /// Per-author contributions keyed by interned author index.
118    /// Indices reference [`ChurnResult::author_pool`].
119    pub authors: FxHashMap<u32, AuthorContribution>,
120}
121
122/// Result of churn analysis.
123#[derive(Debug, Clone)]
124pub struct ChurnResult {
125    /// Per-file churn data, keyed by absolute path.
126    pub files: FxHashMap<PathBuf, FileChurn>,
127    /// Whether the repository is a shallow clone.
128    pub shallow_clone: bool,
129    /// Author email pool. Per-file [`AuthorContribution`] entries reference
130    /// authors by their index into this vector.
131    pub author_pool: Vec<String>,
132}
133
134/// Parse a `--since` value into a git-compatible duration.
135///
136/// Accepts:
137/// - Durations: `6m`, `6months`, `90d`, `90days`, `1y`, `1year`, `2w`, `2weeks`
138/// - ISO dates: `2025-06-01`
139///
140/// # Errors
141///
142/// Returns an error if the input is not a recognized duration format or ISO date,
143/// the numeric part is invalid, or the duration is zero.
144pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
145    if is_iso_date(input) {
146        return Ok(SinceDuration {
147            git_after: input.to_string(),
148            display: input.to_string(),
149        });
150    }
151
152    let (num_str, unit) = split_number_unit(input)?;
153    let num: u64 = num_str
154        .parse()
155        .map_err(|_| format!("invalid number in --since: {input}"))?;
156
157    if num == 0 {
158        return Err("--since duration must be greater than 0".to_string());
159    }
160
161    match unit {
162        "d" | "day" | "days" => {
163            let s = if num == 1 { "" } else { "s" };
164            Ok(SinceDuration {
165                git_after: format!("{num} day{s} ago"),
166                display: format!("{num} day{s}"),
167            })
168        }
169        "w" | "week" | "weeks" => {
170            let s = if num == 1 { "" } else { "s" };
171            Ok(SinceDuration {
172                git_after: format!("{num} week{s} ago"),
173                display: format!("{num} week{s}"),
174            })
175        }
176        "m" | "month" | "months" => {
177            let s = if num == 1 { "" } else { "s" };
178            Ok(SinceDuration {
179                git_after: format!("{num} month{s} ago"),
180                display: format!("{num} month{s}"),
181            })
182        }
183        "y" | "year" | "years" => {
184            let s = if num == 1 { "" } else { "s" };
185            Ok(SinceDuration {
186                git_after: format!("{num} year{s} ago"),
187                display: format!("{num} year{s}"),
188            })
189        }
190        _ => Err(format!(
191            "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
192        )),
193    }
194}
195
196/// Analyze git churn for files in the given root directory.
197///
198/// Returns `None` if git is not available or the directory is not a git repository.
199pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
200    let shallow = is_shallow_clone(root);
201    let state = analyze_churn_events(root, since, None)?;
202    Some(build_churn_result(state, shallow))
203}
204
205/// A `fallow-churn/v1` import document: a normalized, VCS-agnostic stand-in for
206/// `git log --numstat` output. Unknown fields are ignored (no
207/// `deny_unknown_fields`) so wrappers may carry extra metadata and so the
208/// reserved `commit` field can be added in a future revision without breaking
209/// v1 consumers.
210#[derive(Debug, Deserialize)]
211struct ChurnFileDoc {
212    schema: String,
213    #[serde(default)]
214    events: Vec<ChurnFileEvent>,
215}
216
217/// One per-(commit, file) change event, the natural shape of a `<vcs> log
218/// --numstat` row. `commit` is intentionally NOT a field: extra keys are
219/// already ignored, so a wrapper emitting `commit` is forward-compatible and a
220/// future revision can promote it to a real field without a breaking change.
221#[derive(Debug, Deserialize)]
222struct ChurnFileEvent {
223    /// Repo-root-relative, forward-slash path. Joined to `root`.
224    path: String,
225    /// Commit time, unix SECONDS UTC (not milliseconds).
226    timestamp: u64,
227    /// Opaque author identity (email recommended); absent contributes no
228    /// ownership signal. fallow does NOT apply mailmap to imported authors.
229    #[serde(default)]
230    author: Option<String>,
231    /// Lines added in this file in this commit.
232    added: u32,
233    /// Lines deleted in this file in this commit.
234    deleted: u32,
235}
236
237/// Build churn data from a normalized `fallow-churn/v1` JSON import instead of
238/// `git log`. Lets projects on a non-git VCS (Yandex Arc, Mercurial, Perforce)
239/// feed change history into hotspot / ownership / bus-factor analysis: a small
240/// wrapper translates the VCS log into the contract and fallow runs all the
241/// usual recency-weighting, trend, and ownership logic on the imported events.
242///
243/// `root` is the project root that relative event paths are joined to (matching
244/// how the git path joins numstat paths), so the churn keys line up with the
245/// analyzed files. Returns a human-readable error (the CLI maps it to exit code
246/// 2) on a missing file, malformed JSON, wrong `schema`, an empty event path, a
247/// far-future timestamp, or an event count past `MAX_CHURN_EVENTS`. An empty
248/// `events` array is valid (no hotspots), not an error. Never runs `git`.
249pub fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
250    let raw = std::fs::read_to_string(path)
251        .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
252    let doc: ChurnFileDoc = serde_json::from_str(&raw)
253        .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
254    if doc.schema != CHURN_FILE_SCHEMA {
255        return Err(format!(
256            "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
257            path.display(),
258            doc.schema
259        ));
260    }
261    if doc.events.len() > MAX_CHURN_EVENTS {
262        return Err(format!(
263            "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
264            path.display(),
265            doc.events.len()
266        ));
267    }
268
269    let state = churn_event_state_from_doc(&doc, path, root)?;
270    Ok(build_churn_result(state, false))
271}
272
273/// Validate and fold a parsed `fallow-churn/v1` document into event state.
274///
275/// Rejects empty paths and far-future (likely millisecond) timestamps; interns
276/// authors into the pool exactly as the git-log path does.
277fn churn_event_state_from_doc(
278    doc: &ChurnFileDoc,
279    path: &Path,
280    root: &Path,
281) -> Result<ChurnEventState, String> {
282    let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
283
284    for event in &doc.events {
285        builder.push_event(event)?;
286    }
287
288    Ok(builder.finish())
289}
290
291fn churn_file_future_limit() -> u64 {
292    let now_secs = std::time::SystemTime::now()
293        .duration_since(std::time::UNIX_EPOCH)
294        .unwrap_or_default()
295        .as_secs();
296    now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
297}
298
299struct ChurnFileImportBuilder<'a> {
300    path: &'a Path,
301    root: &'a Path,
302    future_limit: u64,
303    files: FxHashMap<PathBuf, FileEvents>,
304    author_pool: Vec<String>,
305    author_index: FxHashMap<String, u32>,
306}
307
308impl<'a> ChurnFileImportBuilder<'a> {
309    fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
310        Self {
311            path,
312            root,
313            future_limit,
314            files: FxHashMap::default(),
315            author_pool: Vec::new(),
316            author_index: FxHashMap::default(),
317        }
318    }
319
320    fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
321        let rel = normalize_churn_event_path(self.path, &event.path)?;
322        validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
323
324        let abs_path = self.root.join(&rel);
325        let author_idx = self.intern_author(event.author.as_deref());
326        self.files
327            .entry(abs_path)
328            .or_insert_with(|| FileEvents { events: Vec::new() })
329            .events
330            .push(CachedCommitEvent {
331                timestamp: event.timestamp,
332                lines_added: event.added,
333                lines_deleted: event.deleted,
334                author_idx,
335            });
336        Ok(())
337    }
338
339    fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
340        author
341            .map(str::trim)
342            .filter(|email| !email.is_empty())
343            .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
344    }
345
346    fn finish(self) -> ChurnEventState {
347        ChurnEventState {
348            files: self.files,
349            author_pool: self.author_pool,
350        }
351    }
352}
353
354fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
355    let normalized = event_path.replace('\\', "/");
356    let rel = normalized.trim();
357    if rel.is_empty() {
358        return Err(format!(
359            "churn file {} has an event with an empty path",
360            path.display()
361        ));
362    }
363    Ok(rel.to_string())
364}
365
366fn validate_churn_event_timestamp(
367    path: &Path,
368    timestamp: u64,
369    future_limit: u64,
370    rel: &str,
371) -> Result<(), String> {
372    if timestamp <= future_limit {
373        return Ok(());
374    }
375
376    Err(format!(
377        "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
378         future; timestamps must be unix SECONDS (not milliseconds), UTC",
379        path.display(),
380        timestamp
381    ))
382}
383
384/// Check if the repository is a shallow clone.
385#[must_use]
386pub fn is_shallow_clone(root: &Path) -> bool {
387    let mut command = git_command();
388    command
389        .args(["rev-parse", "--is-shallow-repository"])
390        .current_dir(root);
391    command.output().is_ok_and(|o| {
392        String::from_utf8_lossy(&o.stdout)
393            .trim()
394            .eq_ignore_ascii_case("true")
395    })
396}
397
398/// Check if the directory is inside a git repository.
399#[must_use]
400pub fn is_git_repo(root: &Path) -> bool {
401    let mut command = git_command();
402    command
403        .args(["rev-parse", "--git-dir"])
404        .current_dir(root)
405        .stdout(std::process::Stdio::null())
406        .stderr(std::process::Stdio::null());
407    command.status().is_ok_and(|s| s.success())
408}
409
410/// Maximum size of a churn cache file (64 MB). The incremental cache stores
411/// per-commit events, so it needs more headroom than the old aggregate rows.
412const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
413
414/// Cache schema version. Bump when the on-disk shape of [`ChurnCache`]
415/// changes so older payloads are rejected on load. Version 5 stores paths in
416/// their platform-native byte representation instead of lossy UTF-8 strings.
417const CHURN_CACHE_VERSION: u8 = 5;
418
419/// Serializable per-commit event for the disk cache.
420#[derive(Clone, bitcode::Encode, bitcode::Decode)]
421struct CachedCommitEvent {
422    timestamp: u64,
423    lines_added: u32,
424    lines_deleted: u32,
425    author_idx: Option<u32>,
426}
427
428/// Serializable per-file churn entry for the disk cache.
429#[derive(Clone, bitcode::Encode, bitcode::Decode)]
430struct CachedFileChurn {
431    path: Vec<u8>,
432    events: Vec<CachedCommitEvent>,
433}
434
435/// Cached churn data keyed by last indexed SHA and since string.
436#[derive(Clone, bitcode::Encode, bitcode::Decode)]
437struct ChurnCache {
438    /// Schema version; must equal [`CHURN_CACHE_VERSION`] to be accepted.
439    version: u8,
440    last_indexed_sha: String,
441    git_after: String,
442    files: Vec<CachedFileChurn>,
443    shallow_clone: bool,
444    /// Author email pool referenced by [`CachedCommitEvent::author_idx`].
445    author_pool: Vec<String>,
446}
447
448/// Per-file commit events retained in memory while building or updating churn.
449struct FileEvents {
450    events: Vec<CachedCommitEvent>,
451}
452
453/// Event-level churn state. Unlike [`ChurnResult`], this preserves commit
454/// timestamps so a cache can merge new commits and recompute trend/recency.
455struct ChurnEventState {
456    files: FxHashMap<PathBuf, FileEvents>,
457    author_pool: Vec<String>,
458}
459
460/// Get the full HEAD SHA for cache keying.
461fn get_head_sha(root: &Path) -> Option<String> {
462    let mut command = git_command();
463    command.args(["rev-parse", "HEAD"]).current_dir(root);
464    command
465        .output()
466        .ok()
467        .filter(|o| o.status.success())
468        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
469}
470
471/// Check whether `ancestor` is still reachable from `descendant`.
472fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
473    let mut command = git_command();
474    command
475        .args(["merge-base", "--is-ancestor", ancestor, descendant])
476        .current_dir(root);
477    command.status().is_ok_and(|s| s.success())
478}
479
480/// Try to load churn data from disk cache. Returns `None` on cache miss
481/// or version mismatch.
482fn load_churn_cache(cache_dir: &Path, git_after: &str) -> Option<ChurnCache> {
483    let cache_file = cache_dir.join("churn.bin");
484    let data = std::fs::read(&cache_file).ok()?;
485    if data.len() > MAX_CHURN_CACHE_SIZE {
486        return None;
487    }
488    let cache: ChurnCache = bitcode::decode(&data).ok()?;
489    if cache.version != CHURN_CACHE_VERSION || cache.git_after != git_after {
490        return None;
491    }
492    Some(cache)
493}
494
495/// Save churn data to disk cache.
496fn save_churn_cache(
497    cache_dir: &Path,
498    last_indexed_sha: &str,
499    git_after: &str,
500    state: &ChurnEventState,
501    shallow_clone: bool,
502) {
503    let files: Vec<CachedFileChurn> = state
504        .files
505        .iter()
506        .map(|f| CachedFileChurn {
507            path: path_to_cache_bytes(f.0),
508            events: f.1.events.clone(),
509        })
510        .collect();
511    let cache = ChurnCache {
512        version: CHURN_CACHE_VERSION,
513        last_indexed_sha: last_indexed_sha.to_string(),
514        git_after: git_after.to_string(),
515        files,
516        shallow_clone,
517        author_pool: state.author_pool.clone(),
518    };
519    let _ = std::fs::create_dir_all(cache_dir);
520    let data = bitcode::encode(&cache);
521    let tmp = cache_dir.join("churn.bin.tmp");
522    if std::fs::write(&tmp, data).is_ok() {
523        let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
524    }
525}
526
527/// Analyze churn with disk caching. Uses cached result when HEAD SHA and
528/// since duration match. If HEAD advanced from the cached SHA, runs an
529/// incremental `git log <cached>..HEAD --numstat` scan and merges it.
530///
531/// Returns `(ChurnResult, bool)` where the bool indicates whether reusable
532/// cache state was used.
533/// Returns `None` if git analysis fails.
534pub fn analyze_churn_cached(
535    root: &Path,
536    since: &SinceDuration,
537    cache_dir: &Path,
538    no_cache: bool,
539) -> Option<(ChurnResult, bool)> {
540    let head_sha = get_head_sha(root)?;
541
542    if !no_cache && let Some(result) = try_reuse_churn_cache(root, since, cache_dir, &head_sha) {
543        return Some((result, true));
544    }
545
546    analyze_fresh_churn(root, since, cache_dir, no_cache, &head_sha).map(|result| (result, false))
547}
548
549fn try_reuse_churn_cache(
550    root: &Path,
551    since: &SinceDuration,
552    cache_dir: &Path,
553    head_sha: &str,
554) -> Option<ChurnResult> {
555    let cache = load_churn_cache(cache_dir, &since.git_after)?;
556    if cache.last_indexed_sha == head_sha {
557        let shallow_clone = cache.shallow_clone;
558        return Some(build_churn_result(cache.into_event_state(), shallow_clone));
559    }
560
561    if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
562        return None;
563    }
564
565    extend_churn_cache(root, since, cache_dir, head_sha, cache)
566}
567
568fn extend_churn_cache(
569    root: &Path,
570    since: &SinceDuration,
571    cache_dir: &Path,
572    head_sha: &str,
573    cache: ChurnCache,
574) -> Option<ChurnResult> {
575    let shallow_clone = is_shallow_clone(root);
576    let range = format!("{}..HEAD", cache.last_indexed_sha);
577    let delta = analyze_churn_events(root, since, Some(&range))?;
578    let mut state = cache.into_event_state();
579    merge_churn_states(&mut state, delta);
580    save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
581    Some(build_churn_result(state, shallow_clone))
582}
583
584fn analyze_fresh_churn(
585    root: &Path,
586    since: &SinceDuration,
587    cache_dir: &Path,
588    no_cache: bool,
589    head_sha: &str,
590) -> Option<ChurnResult> {
591    let shallow_clone = is_shallow_clone(root);
592    let state = analyze_churn_events(root, since, None)?;
593    if !no_cache {
594        save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
595    }
596
597    Some(build_churn_result(state, shallow_clone))
598}
599
600impl ChurnCache {
601    fn into_event_state(self) -> ChurnEventState {
602        let files = self
603            .files
604            .into_iter()
605            .filter_map(|entry| {
606                path_from_cache_bytes(&entry.path).map(|path| {
607                    (
608                        path,
609                        FileEvents {
610                            events: entry.events,
611                        },
612                    )
613                })
614            })
615            .collect();
616        ChurnEventState {
617            files,
618            author_pool: self.author_pool,
619        }
620    }
621}
622
623#[cfg(unix)]
624fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
625    use std::os::unix::ffi::OsStrExt;
626
627    path.as_os_str().as_bytes().to_vec()
628}
629
630#[cfg(unix)]
631#[allow(
632    clippy::unnecessary_wraps,
633    reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
634)]
635fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
636    use std::ffi::OsStr;
637    use std::os::unix::ffi::OsStrExt;
638
639    Some(PathBuf::from(OsStr::from_bytes(path)))
640}
641
642#[cfg(windows)]
643fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
644    use std::os::windows::ffi::OsStrExt;
645
646    path.as_os_str()
647        .encode_wide()
648        .flat_map(u16::to_le_bytes)
649        .collect()
650}
651
652#[cfg(windows)]
653fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
654    use std::ffi::OsString;
655    use std::os::windows::ffi::OsStringExt;
656
657    let chunks = path.chunks_exact(2);
658    if !chunks.remainder().is_empty() {
659        return None;
660    }
661    let wide: Vec<u16> = chunks
662        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
663        .collect();
664    Some(PathBuf::from(OsString::from_wide(&wide)))
665}
666
667/// Run `git log --numstat` and return event-level churn state.
668fn analyze_churn_events(
669    root: &Path,
670    since: &SinceDuration,
671    revision_range: Option<&str>,
672) -> Option<ChurnEventState> {
673    let mut command = git_command();
674    command.arg("log");
675    if let Some(range) = revision_range {
676        command.arg(range);
677    }
678    command
679        .args([
680            "--numstat",
681            "--no-merges",
682            "--no-renames",
683            "--use-mailmap",
684            "-z",
685            "--format=format:%at|%ae%x00",
686            &format!("--after={}", since.git_after),
687        ])
688        .current_dir(root);
689
690    let output = match spawn_output(&mut command) {
691        Ok(o) => o,
692        Err(e) => {
693            tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
694            return None;
695        }
696    };
697
698    if !output.status.success() {
699        let stderr = String::from_utf8_lossy(&output.stderr);
700        tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
701        return None;
702    }
703
704    Some(parse_git_log_events_z(&output.stdout, root))
705}
706
707/// Merge new churn events into cached event state.
708fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
709    let mut base_author_index: FxHashMap<String, u32> = base
710        .author_pool
711        .iter()
712        .enumerate()
713        .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
714        .collect();
715
716    let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
717    for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
718        let Ok(old_idx) = u32::try_from(old_idx) else {
719            continue;
720        };
721        let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
722        author_mapping.insert(old_idx, new_idx);
723    }
724
725    for (path, mut file) in delta.files {
726        for event in &mut file.events {
727            event.author_idx = event
728                .author_idx
729                .and_then(|idx| author_mapping.get(&idx).copied());
730        }
731        base.files
732            .entry(path)
733            .and_modify(|existing| existing.events.append(&mut file.events))
734            .or_insert(file);
735    }
736}
737
738/// Parse `git log --numstat --format=format:%at|%ae` output into events.
739#[cfg(test)]
740fn parse_git_log_events(stdout: &str, root: &Path) -> ChurnEventState {
741    let now_secs = std::time::SystemTime::now()
742        .duration_since(std::time::UNIX_EPOCH)
743        .unwrap_or_default()
744        .as_secs();
745
746    let mut parser = GitLogEventParser::new(root, now_secs);
747
748    for line in stdout.lines() {
749        parser.consume_line(line);
750    }
751
752    parser.finish()
753}
754
755fn parse_git_log_events_z(stdout: &[u8], root: &Path) -> ChurnEventState {
756    let now_secs = std::time::SystemTime::now()
757        .duration_since(std::time::UNIX_EPOCH)
758        .unwrap_or_default()
759        .as_secs();
760
761    let mut parser = GitLogEventParser::new(root, now_secs);
762    for record in stdout.split(|byte| *byte == 0) {
763        let record = record.strip_prefix(b"\n").unwrap_or(record);
764        if record.is_empty() {
765            continue;
766        }
767        if record.contains(&b'\t') {
768            parser.record_numstat_bytes(record);
769        } else {
770            parser.consume_line(&String::from_utf8_lossy(record));
771        }
772    }
773    parser.finish()
774}
775
776struct GitLogEventParser<'a> {
777    root: &'a Path,
778    now_secs: u64,
779    files: FxHashMap<PathBuf, FileEvents>,
780    author_pool: Vec<String>,
781    author_index: FxHashMap<String, u32>,
782    current_timestamp: Option<u64>,
783    current_author_idx: Option<u32>,
784}
785
786impl<'a> GitLogEventParser<'a> {
787    fn new(root: &'a Path, now_secs: u64) -> Self {
788        Self {
789            root,
790            now_secs,
791            files: FxHashMap::default(),
792            author_pool: Vec::new(),
793            author_index: FxHashMap::default(),
794            current_timestamp: None,
795            current_author_idx: None,
796        }
797    }
798
799    fn consume_line(&mut self, line: &str) {
800        let line = line.trim();
801        if line.is_empty() {
802            return;
803        }
804
805        if self.record_commit_header(line) {
806            return;
807        }
808        if self.record_legacy_timestamp(line) {
809            return;
810        }
811        self.record_numstat(line);
812    }
813
814    fn record_commit_header(&mut self, line: &str) -> bool {
815        let Some((ts_str, email)) = line.split_once('|') else {
816            return false;
817        };
818        let Ok(ts) = ts_str.parse::<u64>() else {
819            return false;
820        };
821
822        self.current_timestamp = Some(ts);
823        self.current_author_idx = Some(intern_author(
824            email,
825            &mut self.author_pool,
826            &mut self.author_index,
827        ));
828        true
829    }
830
831    fn record_legacy_timestamp(&mut self, line: &str) -> bool {
832        let Ok(ts) = line.parse::<u64>() else {
833            return false;
834        };
835
836        self.current_timestamp = Some(ts);
837        self.current_author_idx = None;
838        true
839    }
840
841    fn record_numstat(&mut self, line: &str) {
842        let Some((added, deleted, path)) = parse_numstat_line(line) else {
843            return;
844        };
845
846        self.record_numstat_path(added, deleted, PathBuf::from(path));
847    }
848
849    fn record_numstat_bytes(&mut self, record: &[u8]) {
850        let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
851            return;
852        };
853        let Some(second_tab_offset) = record[first_tab + 1..]
854            .iter()
855            .position(|byte| *byte == b'\t')
856        else {
857            return;
858        };
859        let second_tab = first_tab + 1 + second_tab_offset;
860        let Some(added) = std::str::from_utf8(&record[..first_tab])
861            .ok()
862            .and_then(|value| value.parse().ok())
863        else {
864            return;
865        };
866        let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
867            .ok()
868            .and_then(|value| value.parse().ok())
869        else {
870            return;
871        };
872        let path = git_path_from_bytes(&record[second_tab + 1..]);
873        self.record_numstat_path(added, deleted, path);
874    }
875
876    fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
877        let ts = self.current_timestamp.unwrap_or(self.now_secs);
878        self.files
879            .entry(self.root.join(path))
880            .or_insert_with(|| FileEvents { events: Vec::new() })
881            .events
882            .push(CachedCommitEvent {
883                timestamp: ts,
884                lines_added: added,
885                lines_deleted: deleted,
886                author_idx: self.current_author_idx,
887            });
888    }
889
890    fn finish(self) -> ChurnEventState {
891        ChurnEventState {
892            files: self.files,
893            author_pool: self.author_pool,
894        }
895    }
896}
897
898#[cfg(unix)]
899fn git_path_from_bytes(path: &[u8]) -> PathBuf {
900    use std::ffi::OsString;
901    use std::os::unix::ffi::OsStringExt;
902
903    PathBuf::from(OsString::from_vec(path.to_vec()))
904}
905
906#[cfg(windows)]
907fn git_path_from_bytes(path: &[u8]) -> PathBuf {
908    PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
909}
910
911/// Aggregate one file's raw commit events into a [`FileChurn`], applying
912/// recency weighting, trend detection, and per-author accumulation.
913#[expect(
914    clippy::cast_possible_truncation,
915    reason = "commit count per file is bounded by git history depth"
916)]
917fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
918    let mut timestamps = Vec::with_capacity(file.events.len());
919    let mut weighted_commits = 0.0;
920    let mut lines_added = 0;
921    let mut lines_deleted = 0;
922    let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
923
924    for event in file.events {
925        timestamps.push(event.timestamp);
926        let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
927        let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
928        weighted_commits += weight;
929        lines_added += event.lines_added;
930        lines_deleted += event.lines_deleted;
931        accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
932    }
933
934    let commits = timestamps.len() as u32;
935    let trend = compute_trend(&timestamps);
936    for c in authors.values_mut() {
937        c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
938    }
939    FileChurn {
940        path,
941        commits,
942        weighted_commits: (weighted_commits * 100.0).round() / 100.0,
943        lines_added,
944        lines_deleted,
945        trend,
946        authors,
947    }
948}
949
950/// Fold a single commit's author contribution into the per-author map.
951fn accumulate_author(
952    authors: &mut FxHashMap<u32, AuthorContribution>,
953    author_idx: Option<u32>,
954    weight: f64,
955    timestamp: u64,
956) {
957    let Some(idx) = author_idx else {
958        return;
959    };
960    authors
961        .entry(idx)
962        .and_modify(|c| {
963            c.commits += 1;
964            c.weighted_commits += weight;
965            c.first_commit_ts = c.first_commit_ts.min(timestamp);
966            c.last_commit_ts = c.last_commit_ts.max(timestamp);
967        })
968        .or_insert(AuthorContribution {
969            commits: 1,
970            weighted_commits: weight,
971            first_commit_ts: timestamp,
972            last_commit_ts: timestamp,
973        });
974}
975
976/// Convert event-level churn state into the public aggregate result.
977fn build_churn_result(state: ChurnEventState, shallow_clone: bool) -> ChurnResult {
978    let now_secs = std::time::SystemTime::now()
979        .duration_since(std::time::UNIX_EPOCH)
980        .unwrap_or_default()
981        .as_secs();
982
983    let files = state
984        .files
985        .into_iter()
986        .map(|(path, file)| {
987            let churn = aggregate_file_churn(path.clone(), file, now_secs);
988            (path, churn)
989        })
990        .collect();
991
992    ChurnResult {
993        files,
994        shallow_clone,
995        author_pool: state.author_pool,
996    }
997}
998
999/// Parse `git log --numstat --format=format:%at|%ae` output.
1000///
1001/// Returns a per-file churn map plus the author email pool referenced by
1002/// interned indices in [`FileChurn::authors`].
1003#[cfg(test)]
1004fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
1005    let result = build_churn_result(parse_git_log_events(stdout, root), false);
1006    (result.files, result.author_pool)
1007}
1008
1009/// Intern an author email into the pool, returning its stable index.
1010fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1011    if let Some(&idx) = index.get(email) {
1012        return idx;
1013    }
1014    #[expect(
1015        clippy::cast_possible_truncation,
1016        reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1017    )]
1018    let idx = pool.len() as u32;
1019    let owned = email.to_string();
1020    index.insert(owned.clone(), idx);
1021    pool.push(owned);
1022    idx
1023}
1024
1025/// Parse a single numstat line: `"10\t5\tpath/to/file.ts"`.
1026/// Binary files show as `"-\t-\tpath"`, skip those.
1027fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
1028    let mut parts = line.splitn(3, '\t');
1029    let added_str = parts.next()?;
1030    let deleted_str = parts.next()?;
1031    let path = parts.next()?;
1032
1033    let added: u32 = added_str.parse().ok()?;
1034    let deleted: u32 = deleted_str.parse().ok()?;
1035
1036    Some((added, deleted, path))
1037}
1038
1039/// Compute churn trend by splitting commits into two temporal halves.
1040///
1041/// Finds the midpoint between the oldest and newest commit timestamps,
1042/// then compares commit counts in each half:
1043/// - Recent > 1.5× older → Accelerating
1044/// - Recent < 0.67× older → Cooling
1045/// - Otherwise → Stable
1046fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1047    if timestamps.len() < 2 {
1048        return ChurnTrend::Stable;
1049    }
1050
1051    let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1052    let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1053
1054    if max_ts == min_ts {
1055        return ChurnTrend::Stable;
1056    }
1057
1058    let midpoint = min_ts + (max_ts - min_ts) / 2;
1059    let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1060    let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1061
1062    if older < 1.0 {
1063        return ChurnTrend::Stable;
1064    }
1065
1066    let ratio = recent / older;
1067    if ratio > 1.5 {
1068        ChurnTrend::Accelerating
1069    } else if ratio < 0.67 {
1070        ChurnTrend::Cooling
1071    } else {
1072        ChurnTrend::Stable
1073    }
1074}
1075
1076fn is_iso_date(input: &str) -> bool {
1077    input.len() == 10
1078        && input.as_bytes().get(4) == Some(&b'-')
1079        && input.as_bytes().get(7) == Some(&b'-')
1080        && input[..4].bytes().all(|b| b.is_ascii_digit())
1081        && input[5..7].bytes().all(|b| b.is_ascii_digit())
1082        && input[8..10].bytes().all(|b| b.is_ascii_digit())
1083}
1084
1085fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1086    let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1087        format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1088    })?;
1089    if pos == 0 {
1090        return Err(format!(
1091            "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1092        ));
1093    }
1094    Ok((&input[..pos], &input[pos..]))
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    #[test]
1102    fn parse_since_months_short() {
1103        let d = parse_since("6m").unwrap();
1104        assert_eq!(d.git_after, "6 months ago");
1105        assert_eq!(d.display, "6 months");
1106    }
1107
1108    #[test]
1109    fn parse_since_months_long() {
1110        let d = parse_since("6months").unwrap();
1111        assert_eq!(d.git_after, "6 months ago");
1112        assert_eq!(d.display, "6 months");
1113    }
1114
1115    #[test]
1116    fn parse_since_days() {
1117        let d = parse_since("90d").unwrap();
1118        assert_eq!(d.git_after, "90 days ago");
1119        assert_eq!(d.display, "90 days");
1120    }
1121
1122    #[test]
1123    fn parse_since_year_singular() {
1124        let d = parse_since("1y").unwrap();
1125        assert_eq!(d.git_after, "1 year ago");
1126        assert_eq!(d.display, "1 year");
1127    }
1128
1129    #[test]
1130    fn parse_since_years_plural() {
1131        let d = parse_since("2years").unwrap();
1132        assert_eq!(d.git_after, "2 years ago");
1133        assert_eq!(d.display, "2 years");
1134    }
1135
1136    #[test]
1137    fn parse_since_weeks() {
1138        let d = parse_since("2w").unwrap();
1139        assert_eq!(d.git_after, "2 weeks ago");
1140        assert_eq!(d.display, "2 weeks");
1141    }
1142
1143    #[test]
1144    fn parse_since_iso_date() {
1145        let d = parse_since("2025-06-01").unwrap();
1146        assert_eq!(d.git_after, "2025-06-01");
1147        assert_eq!(d.display, "2025-06-01");
1148    }
1149
1150    #[test]
1151    fn parse_since_month_singular() {
1152        let d = parse_since("1month").unwrap();
1153        assert_eq!(d.display, "1 month");
1154    }
1155
1156    #[test]
1157    fn parse_since_day_singular() {
1158        let d = parse_since("1day").unwrap();
1159        assert_eq!(d.display, "1 day");
1160    }
1161
1162    #[test]
1163    fn parse_since_zero_rejected() {
1164        assert!(parse_since("0m").is_err());
1165    }
1166
1167    #[test]
1168    fn parse_since_no_unit_rejected() {
1169        assert!(parse_since("90").is_err());
1170    }
1171
1172    #[test]
1173    fn parse_since_unknown_unit_rejected() {
1174        assert!(parse_since("6x").is_err());
1175    }
1176
1177    #[test]
1178    fn parse_since_no_number_rejected() {
1179        assert!(parse_since("months").is_err());
1180    }
1181
1182    #[test]
1183    fn numstat_normal() {
1184        let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
1185        assert_eq!(a, 10);
1186        assert_eq!(d, 5);
1187        assert_eq!(p, "src/file.ts");
1188    }
1189
1190    #[test]
1191    fn numstat_binary_skipped() {
1192        assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
1193    }
1194
1195    #[test]
1196    fn numstat_zero_lines() {
1197        let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
1198        assert_eq!(a, 0);
1199        assert_eq!(d, 0);
1200        assert_eq!(p, "src/empty.ts");
1201    }
1202
1203    #[test]
1204    fn trend_empty_is_stable() {
1205        assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1206    }
1207
1208    #[test]
1209    fn trend_single_commit_is_stable() {
1210        assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1211    }
1212
1213    #[test]
1214    fn trend_accelerating() {
1215        let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1216        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1217    }
1218
1219    #[test]
1220    fn trend_cooling() {
1221        let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1222        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1223    }
1224
1225    #[test]
1226    fn trend_stable_even_distribution() {
1227        let timestamps = vec![100, 200, 300, 700, 800, 900];
1228        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1229    }
1230
1231    #[test]
1232    fn trend_same_timestamp_is_stable() {
1233        let timestamps = vec![500, 500, 500];
1234        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1235    }
1236
1237    #[test]
1238    fn iso_date_valid() {
1239        assert!(is_iso_date("2025-06-01"));
1240        assert!(is_iso_date("2025-12-31"));
1241    }
1242
1243    #[test]
1244    fn iso_date_with_time_rejected() {
1245        assert!(!is_iso_date("2025-06-01T00:00:00"));
1246    }
1247
1248    #[test]
1249    fn iso_date_invalid() {
1250        assert!(!is_iso_date("6months"));
1251        assert!(!is_iso_date("2025"));
1252        assert!(!is_iso_date("not-a-date"));
1253        assert!(!is_iso_date("abcd-ef-gh"));
1254    }
1255
1256    #[test]
1257    fn trend_display() {
1258        assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1259        assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1260        assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1261    }
1262
1263    #[test]
1264    fn parse_git_log_single_commit() {
1265        let root = Path::new("/project");
1266        let output = "1700000000\n10\t5\tsrc/index.ts\n";
1267        let (result, _) = parse_git_log(output, root);
1268        assert_eq!(result.len(), 1);
1269        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1270        assert_eq!(churn.commits, 1);
1271        assert_eq!(churn.lines_added, 10);
1272        assert_eq!(churn.lines_deleted, 5);
1273    }
1274
1275    #[test]
1276    fn parse_git_log_multiple_commits_same_file() {
1277        let root = Path::new("/project");
1278        let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
1279        let (result, _) = parse_git_log(output, root);
1280        assert_eq!(result.len(), 1);
1281        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1282        assert_eq!(churn.commits, 2);
1283        assert_eq!(churn.lines_added, 13);
1284        assert_eq!(churn.lines_deleted, 7);
1285    }
1286
1287    #[test]
1288    fn parse_git_log_multiple_files() {
1289        let root = Path::new("/project");
1290        let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
1291        let (result, _) = parse_git_log(output, root);
1292        assert_eq!(result.len(), 2);
1293        assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
1294        assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
1295    }
1296
1297    #[test]
1298    fn parse_git_log_empty_output() {
1299        let root = Path::new("/project");
1300        let (result, _) = parse_git_log("", root);
1301        assert!(result.is_empty());
1302    }
1303
1304    #[test]
1305    fn parse_git_log_skips_binary_files() {
1306        let root = Path::new("/project");
1307        let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
1308        let (result, _) = parse_git_log(output, root);
1309        assert_eq!(result.len(), 1);
1310        assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
1311    }
1312
1313    #[test]
1314    fn parse_git_log_weighted_commits_are_positive() {
1315        let root = Path::new("/project");
1316        let now_secs = std::time::SystemTime::now()
1317            .duration_since(std::time::UNIX_EPOCH)
1318            .unwrap()
1319            .as_secs();
1320        let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
1321        let (result, _) = parse_git_log(&output, root);
1322        let churn = &result[&PathBuf::from("/project/src/a.ts")];
1323        assert!(
1324            churn.weighted_commits > 0.0,
1325            "weighted_commits should be positive for recent commits"
1326        );
1327    }
1328
1329    #[test]
1330    fn trend_boundary_1_5x_ratio() {
1331        let timestamps = vec![100, 200, 600, 800, 1000];
1332        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1333    }
1334
1335    #[test]
1336    fn trend_just_above_1_5x() {
1337        let timestamps = vec![100, 600, 800, 1000];
1338        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1339    }
1340
1341    #[test]
1342    fn trend_boundary_0_67x_ratio() {
1343        let timestamps = vec![100, 200, 300, 600, 1000];
1344        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1345    }
1346
1347    #[test]
1348    fn trend_two_timestamps_different() {
1349        let timestamps = vec![100, 200];
1350        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1351    }
1352
1353    #[test]
1354    fn parse_since_week_singular() {
1355        let d = parse_since("1week").unwrap();
1356        assert_eq!(d.git_after, "1 week ago");
1357        assert_eq!(d.display, "1 week");
1358    }
1359
1360    #[test]
1361    fn parse_since_weeks_long() {
1362        let d = parse_since("3weeks").unwrap();
1363        assert_eq!(d.git_after, "3 weeks ago");
1364        assert_eq!(d.display, "3 weeks");
1365    }
1366
1367    #[test]
1368    fn parse_since_days_long() {
1369        let d = parse_since("30days").unwrap();
1370        assert_eq!(d.git_after, "30 days ago");
1371        assert_eq!(d.display, "30 days");
1372    }
1373
1374    #[test]
1375    fn parse_since_year_long() {
1376        let d = parse_since("1year").unwrap();
1377        assert_eq!(d.git_after, "1 year ago");
1378        assert_eq!(d.display, "1 year");
1379    }
1380
1381    #[test]
1382    fn parse_since_overflow_number_rejected() {
1383        let result = parse_since("99999999999999999999d");
1384        assert!(result.is_err());
1385        let err = result.unwrap_err();
1386        assert!(err.contains("invalid number"));
1387    }
1388
1389    #[test]
1390    fn parse_since_zero_days_rejected() {
1391        assert!(parse_since("0d").is_err());
1392    }
1393
1394    #[test]
1395    fn parse_since_zero_weeks_rejected() {
1396        assert!(parse_since("0w").is_err());
1397    }
1398
1399    #[test]
1400    fn parse_since_zero_years_rejected() {
1401        assert!(parse_since("0y").is_err());
1402    }
1403
1404    #[test]
1405    fn numstat_missing_path() {
1406        assert!(parse_numstat_line("10\t5").is_none());
1407    }
1408
1409    #[test]
1410    fn numstat_single_field() {
1411        assert!(parse_numstat_line("10").is_none());
1412    }
1413
1414    #[test]
1415    fn numstat_empty_string() {
1416        assert!(parse_numstat_line("").is_none());
1417    }
1418
1419    #[test]
1420    fn numstat_only_added_is_binary() {
1421        assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1422    }
1423
1424    #[test]
1425    fn numstat_only_deleted_is_binary() {
1426        assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1427    }
1428
1429    #[test]
1430    fn numstat_path_with_spaces() {
1431        let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1432        assert_eq!(a, 3);
1433        assert_eq!(d, 1);
1434        assert_eq!(p, "path with spaces/file.ts");
1435    }
1436
1437    #[test]
1438    fn numstat_large_numbers() {
1439        let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1440        assert_eq!(a, 9999);
1441        assert_eq!(d, 8888);
1442        assert_eq!(p, "src/big.ts");
1443    }
1444
1445    #[test]
1446    fn iso_date_wrong_separator_positions() {
1447        assert!(!is_iso_date("20-25-0601"));
1448        assert!(!is_iso_date("202506-01-"));
1449    }
1450
1451    #[test]
1452    fn iso_date_too_short() {
1453        assert!(!is_iso_date("2025-06-0"));
1454    }
1455
1456    #[test]
1457    fn iso_date_letters_in_day() {
1458        assert!(!is_iso_date("2025-06-ab"));
1459    }
1460
1461    #[test]
1462    fn iso_date_letters_in_month() {
1463        assert!(!is_iso_date("2025-ab-01"));
1464    }
1465
1466    #[test]
1467    fn split_number_unit_valid() {
1468        let (num, unit) = split_number_unit("42days").unwrap();
1469        assert_eq!(num, "42");
1470        assert_eq!(unit, "days");
1471    }
1472
1473    #[test]
1474    fn split_number_unit_single_digit() {
1475        let (num, unit) = split_number_unit("1m").unwrap();
1476        assert_eq!(num, "1");
1477        assert_eq!(unit, "m");
1478    }
1479
1480    #[test]
1481    fn split_number_unit_no_digits() {
1482        let err = split_number_unit("abc").unwrap_err();
1483        assert!(err.contains("must start with a number"));
1484    }
1485
1486    #[test]
1487    fn split_number_unit_no_unit() {
1488        let err = split_number_unit("123").unwrap_err();
1489        assert!(err.contains("requires a unit suffix"));
1490    }
1491
1492    #[test]
1493    fn parse_git_log_numstat_before_timestamp_uses_now() {
1494        let root = Path::new("/project");
1495        let output = "10\t5\tsrc/no_ts.ts\n";
1496        let (result, _) = parse_git_log(output, root);
1497        assert_eq!(result.len(), 1);
1498        let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1499        assert_eq!(churn.commits, 1);
1500        assert_eq!(churn.lines_added, 10);
1501        assert_eq!(churn.lines_deleted, 5);
1502        assert!(
1503            churn.weighted_commits > 0.9,
1504            "weight should be near 1.0 when timestamp defaults to now"
1505        );
1506    }
1507
1508    #[test]
1509    fn parse_git_log_whitespace_lines_ignored() {
1510        let root = Path::new("/project");
1511        let output = "  \n1700000000\n  \n10\t5\tsrc/a.ts\n  \n";
1512        let (result, _) = parse_git_log(output, root);
1513        assert_eq!(result.len(), 1);
1514    }
1515
1516    #[test]
1517    fn parse_git_log_trend_is_computed_per_file() {
1518        let root = Path::new("/project");
1519        let output = "\
15201000\n5\t1\tsrc/old.ts\n\
15212000\n3\t1\tsrc/old.ts\n\
15221000\n1\t0\tsrc/hot.ts\n\
15231800\n1\t0\tsrc/hot.ts\n\
15241900\n1\t0\tsrc/hot.ts\n\
15251950\n1\t0\tsrc/hot.ts\n\
15262000\n1\t0\tsrc/hot.ts\n";
1527        let (result, _) = parse_git_log(output, root);
1528        let old = &result[&PathBuf::from("/project/src/old.ts")];
1529        let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1530        assert_eq!(old.commits, 2);
1531        assert_eq!(hot.commits, 5);
1532        assert_eq!(hot.trend, ChurnTrend::Accelerating);
1533    }
1534
1535    #[test]
1536    fn parse_git_log_weighted_decay_for_old_commits() {
1537        let root = Path::new("/project");
1538        let now = std::time::SystemTime::now()
1539            .duration_since(std::time::UNIX_EPOCH)
1540            .unwrap()
1541            .as_secs();
1542        let old_ts = now - (180 * 86_400);
1543        let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1544        let (result, _) = parse_git_log(&output, root);
1545        let churn = &result[&PathBuf::from("/project/src/old.ts")];
1546        assert!(
1547            churn.weighted_commits < 0.5,
1548            "180-day-old commit should weigh ~0.25, got {}",
1549            churn.weighted_commits
1550        );
1551        assert!(
1552            churn.weighted_commits > 0.1,
1553            "180-day-old commit should weigh ~0.25, got {}",
1554            churn.weighted_commits
1555        );
1556    }
1557
1558    #[test]
1559    fn parse_git_log_path_stored_as_absolute() {
1560        let root = Path::new("/my/project");
1561        let output = "1700000000\n1\t0\tlib/utils.ts\n";
1562        let (result, _) = parse_git_log(output, root);
1563        let key = PathBuf::from("/my/project/lib/utils.ts");
1564        assert!(result.contains_key(&key));
1565        assert_eq!(result[&key].path, key);
1566    }
1567
1568    #[test]
1569    fn parse_git_log_weighted_commits_rounded() {
1570        let root = Path::new("/project");
1571        let now = std::time::SystemTime::now()
1572            .duration_since(std::time::UNIX_EPOCH)
1573            .unwrap()
1574            .as_secs();
1575        let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1576        let (result, _) = parse_git_log(&output, root);
1577        let churn = &result[&PathBuf::from("/project/src/a.ts")];
1578        let decimals = format!("{:.2}", churn.weighted_commits);
1579        assert_eq!(
1580            churn.weighted_commits.to_string().len(),
1581            decimals.len().min(churn.weighted_commits.to_string().len()),
1582            "weighted_commits should be rounded to at most 2 decimal places"
1583        );
1584    }
1585
1586    #[test]
1587    fn trend_serde_serialization() {
1588        assert_eq!(
1589            serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1590            "\"accelerating\""
1591        );
1592        assert_eq!(
1593            serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1594            "\"stable\""
1595        );
1596        assert_eq!(
1597            serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1598            "\"cooling\""
1599        );
1600    }
1601
1602    #[test]
1603    fn parse_git_log_extracts_author_email() {
1604        let root = Path::new("/project");
1605        let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1606        let (result, pool) = parse_git_log(output, root);
1607        assert_eq!(pool, vec!["alice@example.com".to_string()]);
1608        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1609        assert_eq!(churn.authors.len(), 1);
1610        let alice = &churn.authors[&0];
1611        assert_eq!(alice.commits, 1);
1612        assert_eq!(alice.first_commit_ts, 1_700_000_000);
1613        assert_eq!(alice.last_commit_ts, 1_700_000_000);
1614    }
1615
1616    #[test]
1617    fn parse_git_log_intern_dedupes_authors() {
1618        let root = Path::new("/project");
1619        let output = "\
16201700000000|alice@example.com
16211\t0\ta.ts
16221700100000|bob@example.com
16232\t1\tb.ts
16241700200000|alice@example.com
16253\t2\tc.ts
1626";
1627        let (_result, pool) = parse_git_log(output, root);
1628        assert_eq!(pool.len(), 2);
1629        assert!(pool.contains(&"alice@example.com".to_string()));
1630        assert!(pool.contains(&"bob@example.com".to_string()));
1631    }
1632
1633    #[test]
1634    fn parse_git_log_aggregates_per_author() {
1635        let root = Path::new("/project");
1636        let output = "\
16371700000000|alice@example.com
16381\t0\tsrc/index.ts
16391700100000|bob@example.com
16402\t0\tsrc/index.ts
16411700200000|alice@example.com
16421\t1\tsrc/index.ts
1643";
1644        let (result, pool) = parse_git_log(output, root);
1645        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1646        assert_eq!(churn.commits, 3);
1647        assert_eq!(churn.authors.len(), 2);
1648
1649        let alice_idx =
1650            u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1651        let alice = &churn.authors[&alice_idx];
1652        assert_eq!(alice.commits, 2);
1653        assert_eq!(alice.first_commit_ts, 1_700_000_000);
1654        assert_eq!(alice.last_commit_ts, 1_700_200_000);
1655    }
1656
1657    #[test]
1658    fn parse_git_log_legacy_bare_timestamp_still_parses() {
1659        let root = Path::new("/project");
1660        let output = "1700000000\n10\t5\tsrc/index.ts\n";
1661        let (result, pool) = parse_git_log(output, root);
1662        assert!(pool.is_empty());
1663        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1664        assert_eq!(churn.commits, 1);
1665        assert!(churn.authors.is_empty());
1666    }
1667
1668    #[test]
1669    fn intern_author_returns_existing_index() {
1670        let mut pool = Vec::new();
1671        let mut index = FxHashMap::default();
1672        let i1 = intern_author("alice@x", &mut pool, &mut index);
1673        let i2 = intern_author("alice@x", &mut pool, &mut index);
1674        assert_eq!(i1, i2);
1675        assert_eq!(pool.len(), 1);
1676    }
1677
1678    #[test]
1679    fn intern_author_assigns_sequential_indices() {
1680        let mut pool = Vec::new();
1681        let mut index = FxHashMap::default();
1682        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1683        assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1684        assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1685        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1686    }
1687
1688    fn git(root: &Path, args: &[&str]) {
1689        let status = std::process::Command::new("git")
1690            .args(args)
1691            .current_dir(root)
1692            .status()
1693            .expect("run git");
1694        assert!(status.success(), "git {args:?} failed");
1695    }
1696
1697    fn write(root: &Path, path: &str, contents: &str) {
1698        let path = root.join(path);
1699        std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1700        std::fs::write(path, contents).unwrap();
1701    }
1702
1703    #[cfg(unix)]
1704    #[test]
1705    fn churn_preserves_special_filenames() {
1706        let repo = tempfile::tempdir().expect("create repo");
1707        let root = repo.path();
1708        git(root, &["init", "--quiet"]);
1709        git(root, &["config", "user.email", "churn@example.test"]);
1710        git(root, &["config", "user.name", "Churn Test"]);
1711        git(root, &["config", "commit.gpgsign", "false"]);
1712
1713        let special_files = [
1714            "src/line\nbreak.ts",
1715            "src/space name.ts",
1716            "src/quote\"name.ts",
1717            "src/back\\slash.ts",
1718            "src/unicode-λ.ts",
1719        ]
1720        .map(|path| root.join(path));
1721        std::fs::create_dir_all(root.join("src")).expect("source dir");
1722        for special in &special_files {
1723            std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1724        }
1725        git(root, &["add", "."]);
1726        git(root, &["commit", "--quiet", "-m", "initial"]);
1727
1728        let since = parse_since("1y").expect("valid duration");
1729        let churn = analyze_churn(root, &since).expect("churn result");
1730        for special in special_files {
1731            assert!(
1732                churn.files.contains_key(&special),
1733                "missing {special:?}: {:?}",
1734                churn.files.keys()
1735            );
1736        }
1737    }
1738
1739    #[cfg(unix)]
1740    #[test]
1741    fn churn_cache_preserves_non_utf8_filenames() {
1742        use std::ffi::OsString;
1743        use std::os::unix::ffi::OsStringExt;
1744
1745        let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1746        let mut files = FxHashMap::default();
1747        files.insert(
1748            invalid_path.clone(),
1749            FileEvents {
1750                events: vec![CachedCommitEvent {
1751                    timestamp: 1,
1752                    lines_added: 2,
1753                    lines_deleted: 1,
1754                    author_idx: None,
1755                }],
1756            },
1757        );
1758        let state = ChurnEventState {
1759            files,
1760            author_pool: Vec::new(),
1761        };
1762        let cache_dir = tempfile::tempdir().expect("cache directory");
1763        save_churn_cache(cache_dir.path(), "abc123", "1 year ago", &state, false);
1764        let warm = load_churn_cache(cache_dir.path(), "1 year ago")
1765            .expect("warm churn cache")
1766            .into_event_state();
1767
1768        assert!(warm.files.contains_key(&invalid_path));
1769    }
1770
1771    #[cfg(windows)]
1772    #[test]
1773    fn git_path_bytes_use_windows_separators() {
1774        assert_eq!(
1775            git_path_from_bytes(b"src/nested/file.ts"),
1776            PathBuf::from(r"src\nested\file.ts")
1777        );
1778    }
1779
1780    #[test]
1781    fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1782        let cache_dir = tempfile::tempdir().expect("cache directory");
1783        let cache = ChurnCache {
1784            version: 4,
1785            last_indexed_sha: "abc123".to_string(),
1786            git_after: "1 year ago".to_string(),
1787            files: vec![CachedFileChurn {
1788                path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1789                events: Vec::new(),
1790            }],
1791            shallow_clone: false,
1792            author_pool: Vec::new(),
1793        };
1794        std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1795            .expect("cache fixture");
1796
1797        assert!(load_churn_cache(cache_dir.path(), "1 year ago").is_none());
1798    }
1799
1800    #[test]
1801    fn cached_churn_merges_new_commits_after_head_advances() {
1802        let repo = tempfile::tempdir().expect("create repo");
1803        let root = repo.path();
1804        git(root, &["init"]);
1805        git(root, &["config", "user.email", "churn@example.test"]);
1806        git(root, &["config", "user.name", "Churn Test"]);
1807        git(root, &["config", "commit.gpgsign", "false"]);
1808
1809        write(root, "src/a.ts", "export const a = 1;\n");
1810        git(root, &["add", "."]);
1811        git(root, &["commit", "-m", "initial"]);
1812
1813        let since = parse_since("1y").unwrap();
1814        let cache = tempfile::tempdir().expect("create cache dir");
1815        let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1816        assert!(!cold_hit);
1817        let file = root.join("src/a.ts");
1818        assert_eq!(cold.files[&file].commits, 1);
1819
1820        let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1821        assert!(warm_hit);
1822
1823        write(
1824            root,
1825            "src/a.ts",
1826            "export const a = 1;\nexport const b = 2;\n",
1827        );
1828        git(root, &["add", "."]);
1829        git(root, &["commit", "-m", "update a"]);
1830        let head = get_head_sha(root).unwrap();
1831
1832        let (incremental, incremental_hit) =
1833            analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1834        assert!(incremental_hit);
1835        assert_eq!(incremental.files[&file].commits, 2);
1836
1837        let cache = load_churn_cache(cache.path(), &since.git_after).unwrap();
1838        assert_eq!(cache.last_indexed_sha, head);
1839    }
1840
1841    fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1842        let path = dir.join("churn.json");
1843        std::fs::write(&path, contents).unwrap();
1844        path
1845    }
1846
1847    #[test]
1848    fn churn_file_happy_path() {
1849        let dir = tempfile::tempdir().unwrap();
1850        let root = Path::new("/project");
1851        let path = write_churn_file(
1852            dir.path(),
1853            r#"{
1854              "schema": "fallow-churn/v1",
1855              "events": [
1856                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1857                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1858              ]
1859            }"#,
1860        );
1861        let result = analyze_churn_from_file(&path, root).unwrap();
1862        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1863        assert_eq!(churn.commits, 2);
1864        assert_eq!(churn.lines_added, 13);
1865        assert_eq!(churn.lines_deleted, 7);
1866        assert_eq!(churn.authors.len(), 2);
1867        assert!(result.author_pool.contains(&"alice@corp".to_string()));
1868        assert!(result.author_pool.contains(&"bob@corp".to_string()));
1869        assert!(!result.shallow_clone);
1870    }
1871
1872    #[test]
1873    fn churn_file_matches_git_parse() {
1874        // The same events fed via git numstat and via the JSON import must
1875        // produce identical aggregate churn: the import reuses
1876        // build_churn_result, so only the SOURCE differs.
1877        let dir = tempfile::tempdir().unwrap();
1878        let root = Path::new("/project");
1879        let git_output = "1700000000|alice@corp\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n\n1700100000|bob@corp\n3\t2\tsrc/a.ts\n";
1880        let (git_files, git_pool) = parse_git_log(git_output, root);
1881
1882        let path = write_churn_file(
1883            dir.path(),
1884            r#"{
1885              "schema": "fallow-churn/v1",
1886              "events": [
1887                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1888                { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
1889                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1890              ]
1891            }"#,
1892        );
1893        let imported = analyze_churn_from_file(&path, root).unwrap();
1894
1895        assert_eq!(git_pool, imported.author_pool, "author pools diverge");
1896        assert_eq!(git_files.len(), imported.files.len());
1897        for (file, git_churn) in &git_files {
1898            let imp = &imported.files[file];
1899            assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
1900            assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
1901            assert_eq!(
1902                git_churn.lines_deleted, imp.lines_deleted,
1903                "deleted for {file:?}"
1904            );
1905            assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
1906            assert_eq!(
1907                git_churn.authors.len(),
1908                imp.authors.len(),
1909                "authors for {file:?}"
1910            );
1911            assert!(
1912                (git_churn.weighted_commits - imp.weighted_commits).abs() < 0.02,
1913                "weighted_commits for {file:?}: {} vs {}",
1914                git_churn.weighted_commits,
1915                imp.weighted_commits
1916            );
1917        }
1918    }
1919
1920    #[test]
1921    fn churn_file_empty_events_is_valid() {
1922        let dir = tempfile::tempdir().unwrap();
1923        let path = write_churn_file(
1924            dir.path(),
1925            r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1926        );
1927        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1928        assert!(result.files.is_empty());
1929        assert!(result.author_pool.is_empty());
1930    }
1931
1932    #[test]
1933    fn churn_file_missing_events_key_is_valid() {
1934        let dir = tempfile::tempdir().unwrap();
1935        let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1936        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1937        assert!(result.files.is_empty());
1938    }
1939
1940    #[test]
1941    fn churn_file_bad_schema_rejected() {
1942        let dir = tempfile::tempdir().unwrap();
1943        let path = write_churn_file(
1944            dir.path(),
1945            r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1946        );
1947        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1948        assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
1949    }
1950
1951    #[test]
1952    fn churn_file_malformed_json_rejected() {
1953        let dir = tempfile::tempdir().unwrap();
1954        let path = write_churn_file(dir.path(), "{ not json");
1955        assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
1956    }
1957
1958    #[test]
1959    fn churn_file_missing_file_rejected() {
1960        let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
1961            .unwrap_err();
1962        assert!(err.contains("failed to read churn file"), "{err}");
1963    }
1964
1965    #[test]
1966    fn churn_file_empty_path_rejected() {
1967        let dir = tempfile::tempdir().unwrap();
1968        let path = write_churn_file(
1969            dir.path(),
1970            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "  ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1971        );
1972        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1973        assert!(err.contains("empty path"), "{err}");
1974    }
1975
1976    #[test]
1977    fn churn_file_millisecond_timestamp_rejected() {
1978        let dir = tempfile::tempdir().unwrap();
1979        // 1700000000000 is milliseconds; ~52000 years in the future as seconds.
1980        let path = write_churn_file(
1981            dir.path(),
1982            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
1983        );
1984        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1985        assert!(err.contains("milliseconds"), "{err}");
1986    }
1987
1988    #[test]
1989    fn churn_file_missing_author_contributes_no_signal() {
1990        let dir = tempfile::tempdir().unwrap();
1991        let path = write_churn_file(
1992            dir.path(),
1993            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1994        );
1995        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1996        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1997        assert_eq!(churn.commits, 1);
1998        assert!(churn.authors.is_empty());
1999        assert!(result.author_pool.is_empty());
2000    }
2001
2002    #[test]
2003    fn churn_file_empty_author_string_treated_as_absent() {
2004        let dir = tempfile::tempdir().unwrap();
2005        let path = write_churn_file(
2006            dir.path(),
2007            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": "  ", "added": 1, "deleted": 0 } ] }"#,
2008        );
2009        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2010        assert!(result.author_pool.is_empty());
2011    }
2012
2013    #[test]
2014    fn churn_file_unknown_fields_ignored() {
2015        // Extra keys (including the reserved `commit`) are accepted and ignored,
2016        // so a wrapper carrying extra metadata stays forward-compatible.
2017        let dir = tempfile::tempdir().unwrap();
2018        let path = write_churn_file(
2019            dir.path(),
2020            r#"{ "schema": "fallow-churn/v1", "extra": true, "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 1, "deleted": 0, "commit": "abc123", "tz": "+0200" } ] }"#,
2021        );
2022        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2023        assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2024    }
2025
2026    #[test]
2027    fn churn_file_backslash_paths_normalized() {
2028        let dir = tempfile::tempdir().unwrap();
2029        let path = write_churn_file(
2030            dir.path(),
2031            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2032        );
2033        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2034        assert!(
2035            result
2036                .files
2037                .contains_key(&PathBuf::from("/project/src/a.ts"))
2038        );
2039    }
2040}