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