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