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, remainder) = path.as_chunks::<2>();
836    if !remainder.is_empty() {
837        return None;
838    }
839    let wide: Vec<u16> = chunks
840        .iter()
841        .map(|&pair| u16::from_le_bytes(pair))
842        .collect();
843    Some(PathBuf::from(OsString::from_wide(&wide)))
844}
845
846/// Run `git log --numstat` and return event-level churn state.
847///
848/// The window is passed as an absolute `--after=@<epoch>` resolved against the
849/// run clock rather than as a phrase git re-resolves against the wall clock, so
850/// two runs over one commit see the same window boundary.
851fn analyze_churn_events(
852    root: &Path,
853    since: &SinceDuration,
854    revision_range: Option<&str>,
855    clock: &crate::clock::AnalysisClock,
856) -> Option<ChurnEventState> {
857    let mut command = git_command();
858    command.arg("log");
859    if let Some(range) = revision_range {
860        command.arg(range);
861    }
862    command
863        .args([
864            "--numstat",
865            "--no-merges",
866            "--no-renames",
867            "--use-mailmap",
868            "-z",
869            "--format=format:%at|%ct|%ae%x00",
870        ])
871        .current_dir(root);
872    if let Some(cutoff) = since.window.cutoff_secs(clock) {
873        command.arg(format!("--after=@{cutoff}"));
874    }
875    // numstat paths are relative to the git toplevel. When the project root
876    // is a subdirectory, `--relative` rewrites them relative to the root, and
877    // the `.` pathspec skips commits and files outside it. Both are no-ops at
878    // the toplevel.
879    command.args(["--relative", "--", "."]);
880
881    let output = match spawn_output(&mut command) {
882        Ok(o) => o,
883        Err(e) => {
884            tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
885            return None;
886        }
887    };
888
889    if !output.status.success() {
890        let stderr = String::from_utf8_lossy(&output.stderr);
891        tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
892        return None;
893    }
894
895    let mut state = parse_git_log_events_z(&output.stdout, root, clock.epoch_secs());
896    state.git_log_bytes = output.stdout.len() as u64;
897    Some(state)
898}
899
900/// Merge new churn events into cached event state.
901fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
902    base.git_log_bytes += delta.git_log_bytes;
903    let mut base_author_index: FxHashMap<String, u32> = base
904        .author_pool
905        .iter()
906        .enumerate()
907        .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
908        .collect();
909
910    let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
911    for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
912        let Ok(old_idx) = u32::try_from(old_idx) else {
913            continue;
914        };
915        let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
916        author_mapping.insert(old_idx, new_idx);
917    }
918
919    for (path, mut file) in delta.files {
920        for event in &mut file.events {
921            event.author_idx = event
922                .author_idx
923                .and_then(|idx| author_mapping.get(&idx).copied());
924        }
925        base.files
926            .entry(path)
927            .and_modify(|existing| existing.events.append(&mut file.events))
928            .or_insert(file);
929    }
930}
931
932/// `now_secs` is the run clock's epoch, not the wall clock: it is the fallback
933/// timestamp for a numstat record that arrives before any commit header, so
934/// truncated or malformed git output still scores against the pinned instant.
935fn parse_git_log_events_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnEventState {
936    let mut parser = GitLogEventParser::new(root, now_secs);
937    for record in stdout.split(|byte| *byte == 0) {
938        let record = record.strip_prefix(b"\n").unwrap_or(record);
939        if record.is_empty() {
940            continue;
941        }
942        if record.contains(&b'\t') {
943            parser.record_numstat_bytes(record);
944        } else {
945            parser.consume_header(&String::from_utf8_lossy(record));
946        }
947    }
948    parser.finish()
949}
950
951struct GitLogEventParser<'a> {
952    root: &'a Path,
953    now_secs: u64,
954    files: FxHashMap<PathBuf, FileEvents>,
955    author_pool: Vec<String>,
956    author_index: FxHashMap<String, u32>,
957    current_timestamp: Option<u64>,
958    current_committed_at: Option<u64>,
959    current_author_idx: Option<u32>,
960}
961
962impl<'a> GitLogEventParser<'a> {
963    fn new(root: &'a Path, now_secs: u64) -> Self {
964        Self {
965            root,
966            now_secs,
967            files: FxHashMap::default(),
968            author_pool: Vec::new(),
969            author_index: FxHashMap::default(),
970            current_timestamp: None,
971            current_committed_at: None,
972            current_author_idx: None,
973        }
974    }
975
976    /// Numstat rows always hold a tab, so a tab-free record is a commit header,
977    /// a legacy bare timestamp, or noise.
978    fn consume_header(&mut self, line: &str) {
979        let line = line.trim();
980        if line.is_empty() || self.record_commit_header(line) {
981            return;
982        }
983        self.record_legacy_timestamp(line);
984    }
985
986    /// Parse a `%at|%ct|%ae` commit header. A two-field `%at|%ae` header is
987    /// still accepted so a fixture or an embedder pinned to the older format
988    /// keeps parsing; its committer timestamp falls back to the author one.
989    fn record_commit_header(&mut self, line: &str) -> bool {
990        let Some((ts_str, rest)) = line.split_once('|') else {
991            return false;
992        };
993        let Ok(ts) = ts_str.parse::<u64>() else {
994            return false;
995        };
996        let (committed_at, email) = match rest.split_once('|') {
997            Some((committer_str, email)) => (committer_str.parse::<u64>().unwrap_or(ts), email),
998            None => (ts, rest),
999        };
1000
1001        self.current_timestamp = Some(ts);
1002        self.current_committed_at = Some(committed_at);
1003        self.current_author_idx = Some(intern_author(
1004            email,
1005            &mut self.author_pool,
1006            &mut self.author_index,
1007        ));
1008        true
1009    }
1010
1011    fn record_legacy_timestamp(&mut self, line: &str) -> bool {
1012        let Ok(ts) = line.parse::<u64>() else {
1013            return false;
1014        };
1015
1016        self.current_timestamp = Some(ts);
1017        self.current_committed_at = Some(ts);
1018        self.current_author_idx = None;
1019        true
1020    }
1021
1022    fn record_numstat_bytes(&mut self, record: &[u8]) {
1023        let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
1024            return;
1025        };
1026        let Some(second_tab_offset) = record[first_tab + 1..]
1027            .iter()
1028            .position(|byte| *byte == b'\t')
1029        else {
1030            return;
1031        };
1032        let second_tab = first_tab + 1 + second_tab_offset;
1033        let Some(added) = std::str::from_utf8(&record[..first_tab])
1034            .ok()
1035            .and_then(|value| value.parse().ok())
1036        else {
1037            return;
1038        };
1039        let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
1040            .ok()
1041            .and_then(|value| value.parse().ok())
1042        else {
1043            return;
1044        };
1045        let path = git_path_from_bytes(&record[second_tab + 1..]);
1046        self.record_numstat_path(added, deleted, path);
1047    }
1048
1049    fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
1050        let ts = self.current_timestamp.unwrap_or(self.now_secs);
1051        self.files
1052            .entry(self.root.join(path))
1053            .or_insert_with(|| FileEvents { events: Vec::new() })
1054            .events
1055            .push(CachedCommitEvent {
1056                timestamp: ts,
1057                committed_at: self.current_committed_at.unwrap_or(ts),
1058                lines_added: added,
1059                lines_deleted: deleted,
1060                author_idx: self.current_author_idx,
1061            });
1062    }
1063
1064    fn finish(self) -> ChurnEventState {
1065        ChurnEventState {
1066            files: self.files,
1067            author_pool: self.author_pool,
1068            git_log_bytes: 0,
1069        }
1070    }
1071}
1072
1073/// Aggregate one file's raw commit events into a [`FileChurn`], applying
1074/// recency weighting, trend detection, and per-author accumulation.
1075#[expect(
1076    clippy::cast_possible_truncation,
1077    reason = "commit count per file is bounded by git history depth"
1078)]
1079fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
1080    let mut timestamps = Vec::with_capacity(file.events.len());
1081    let mut weighted_commits = 0.0;
1082    let mut lines_added = 0_u32;
1083    let mut lines_deleted = 0_u32;
1084    let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
1085
1086    for event in file.events {
1087        timestamps.push(event.timestamp);
1088        let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
1089        let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
1090        weighted_commits += weight;
1091        lines_added = lines_added.saturating_add(event.lines_added);
1092        lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
1093        accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
1094    }
1095
1096    let commits = timestamps.len() as u32;
1097    let trend = compute_trend(&timestamps);
1098    for c in authors.values_mut() {
1099        c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
1100    }
1101    FileChurn {
1102        path,
1103        commits,
1104        weighted_commits: (weighted_commits * 100.0).round() / 100.0,
1105        lines_added,
1106        lines_deleted,
1107        trend,
1108        authors,
1109    }
1110}
1111
1112/// Fold a single commit's author contribution into the per-author map.
1113fn accumulate_author(
1114    authors: &mut FxHashMap<u32, AuthorContribution>,
1115    author_idx: Option<u32>,
1116    weight: f64,
1117    timestamp: u64,
1118) {
1119    let Some(idx) = author_idx else {
1120        return;
1121    };
1122    authors
1123        .entry(idx)
1124        .and_modify(|c| {
1125            c.commits += 1;
1126            c.weighted_commits += weight;
1127            c.first_commit_ts = c.first_commit_ts.min(timestamp);
1128            c.last_commit_ts = c.last_commit_ts.max(timestamp);
1129        })
1130        .or_insert(AuthorContribution {
1131            commits: 1,
1132            weighted_commits: weight,
1133            first_commit_ts: timestamp,
1134            last_commit_ts: timestamp,
1135        });
1136}
1137
1138/// Convert event-level churn state into the public aggregate result.
1139///
1140/// Recency weighting is measured against `clock`, not the system clock, so the
1141/// same commit yields the same `weighted_commits` on every run.
1142fn build_churn_result(
1143    state: ChurnEventState,
1144    shallow_clone: bool,
1145    clock: crate::clock::AnalysisClock,
1146) -> ChurnResult {
1147    let now_secs = clock.epoch_secs();
1148
1149    let files = state
1150        .files
1151        .into_iter()
1152        .map(|(path, file)| {
1153            let churn = aggregate_file_churn(path.clone(), file, now_secs);
1154            (path, churn)
1155        })
1156        .collect();
1157
1158    ChurnResult {
1159        files,
1160        shallow_clone,
1161        author_pool: state.author_pool,
1162        clock,
1163        git_log_bytes: state.git_log_bytes,
1164    }
1165}
1166
1167/// Intern an author email into the pool, returning its stable index.
1168fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1169    if let Some(&idx) = index.get(email) {
1170        return idx;
1171    }
1172    #[expect(
1173        clippy::cast_possible_truncation,
1174        reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1175    )]
1176    let idx = pool.len() as u32;
1177    let owned = email.to_string();
1178    index.insert(owned.clone(), idx);
1179    pool.push(owned);
1180    idx
1181}
1182
1183/// Compute churn trend by splitting commits into two temporal halves.
1184///
1185/// Finds the midpoint between the oldest and newest commit timestamps,
1186/// then compares commit counts in each half:
1187/// - Recent > 1.5× older → Accelerating
1188/// - Recent < 0.67× older → Cooling
1189/// - Otherwise → Stable
1190fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1191    if timestamps.len() < 2 {
1192        return ChurnTrend::Stable;
1193    }
1194
1195    let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1196    let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1197
1198    if max_ts == min_ts {
1199        return ChurnTrend::Stable;
1200    }
1201
1202    let midpoint = min_ts + (max_ts - min_ts) / 2;
1203    let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1204    let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1205
1206    if older < 1.0 {
1207        return ChurnTrend::Stable;
1208    }
1209
1210    let ratio = recent / older;
1211    if ratio > 1.5 {
1212        ChurnTrend::Accelerating
1213    } else if ratio < 0.67 {
1214        ChurnTrend::Cooling
1215    } else {
1216        ChurnTrend::Stable
1217    }
1218}
1219
1220fn is_iso_date(input: &str) -> bool {
1221    input.len() == 10
1222        && input.as_bytes().get(4) == Some(&b'-')
1223        && input.as_bytes().get(7) == Some(&b'-')
1224        && input[..4].bytes().all(|b| b.is_ascii_digit())
1225        && input[5..7].bytes().all(|b| b.is_ascii_digit())
1226        && input[8..10].bytes().all(|b| b.is_ascii_digit())
1227}
1228
1229fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1230    let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1231        format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1232    })?;
1233    if pos == 0 {
1234        return Err(format!(
1235            "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1236        ));
1237    }
1238    Ok((&input[..pos], &input[pos..]))
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244
1245    #[test]
1246    fn parse_since_months_short() {
1247        let d = parse_since("6m").unwrap();
1248        assert_eq!(
1249            d.window,
1250            ChurnWindow::Relative {
1251                count: 6,
1252                unit: ChurnWindowUnit::Months
1253            }
1254        );
1255        assert_eq!(d.display, "6 months");
1256    }
1257
1258    #[test]
1259    fn parse_since_months_long() {
1260        let d = parse_since("6months").unwrap();
1261        assert_eq!(
1262            d.window,
1263            ChurnWindow::Relative {
1264                count: 6,
1265                unit: ChurnWindowUnit::Months
1266            }
1267        );
1268        assert_eq!(d.display, "6 months");
1269    }
1270
1271    #[test]
1272    fn parse_since_days() {
1273        let d = parse_since("90d").unwrap();
1274        assert_eq!(
1275            d.window,
1276            ChurnWindow::Relative {
1277                count: 90,
1278                unit: ChurnWindowUnit::Days
1279            }
1280        );
1281        assert_eq!(d.display, "90 days");
1282    }
1283
1284    #[test]
1285    fn parse_since_year_singular() {
1286        let d = parse_since("1y").unwrap();
1287        assert_eq!(
1288            d.window,
1289            ChurnWindow::Relative {
1290                count: 1,
1291                unit: ChurnWindowUnit::Years
1292            }
1293        );
1294        assert_eq!(d.display, "1 year");
1295    }
1296
1297    #[test]
1298    fn parse_since_years_plural() {
1299        let d = parse_since("2years").unwrap();
1300        assert_eq!(
1301            d.window,
1302            ChurnWindow::Relative {
1303                count: 2,
1304                unit: ChurnWindowUnit::Years
1305            }
1306        );
1307        assert_eq!(d.display, "2 years");
1308    }
1309
1310    #[test]
1311    fn parse_since_weeks() {
1312        let d = parse_since("2w").unwrap();
1313        assert_eq!(
1314            d.window,
1315            ChurnWindow::Relative {
1316                count: 2,
1317                unit: ChurnWindowUnit::Weeks
1318            }
1319        );
1320        assert_eq!(d.display, "2 weeks");
1321    }
1322
1323    #[test]
1324    fn parse_since_iso_date() {
1325        let d = parse_since("2025-06-01").unwrap();
1326        assert_eq!(d.window, ChurnWindow::Date("2025-06-01".to_string()));
1327        assert_eq!(d.display, "2025-06-01");
1328    }
1329
1330    #[test]
1331    fn parse_since_month_singular() {
1332        let d = parse_since("1month").unwrap();
1333        assert_eq!(d.display, "1 month");
1334    }
1335
1336    #[test]
1337    fn parse_since_day_singular() {
1338        let d = parse_since("1day").unwrap();
1339        assert_eq!(d.display, "1 day");
1340    }
1341
1342    #[test]
1343    fn parse_since_zero_rejected() {
1344        assert!(parse_since("0m").is_err());
1345    }
1346
1347    #[test]
1348    fn parse_since_no_unit_rejected() {
1349        assert!(parse_since("90").is_err());
1350    }
1351
1352    #[test]
1353    fn parse_since_unknown_unit_rejected() {
1354        assert!(parse_since("6x").is_err());
1355    }
1356
1357    #[test]
1358    fn parse_since_no_number_rejected() {
1359        assert!(parse_since("months").is_err());
1360    }
1361
1362    #[test]
1363    fn trend_empty_is_stable() {
1364        assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1365    }
1366
1367    #[test]
1368    fn trend_single_commit_is_stable() {
1369        assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1370    }
1371
1372    #[test]
1373    fn trend_accelerating() {
1374        let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1375        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1376    }
1377
1378    #[test]
1379    fn trend_cooling() {
1380        let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1381        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1382    }
1383
1384    #[test]
1385    fn trend_stable_even_distribution() {
1386        let timestamps = vec![100, 200, 300, 700, 800, 900];
1387        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1388    }
1389
1390    #[test]
1391    fn trend_same_timestamp_is_stable() {
1392        let timestamps = vec![500, 500, 500];
1393        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1394    }
1395
1396    #[test]
1397    fn iso_date_valid() {
1398        assert!(is_iso_date("2025-06-01"));
1399        assert!(is_iso_date("2025-12-31"));
1400    }
1401
1402    #[test]
1403    fn iso_date_with_time_rejected() {
1404        assert!(!is_iso_date("2025-06-01T00:00:00"));
1405    }
1406
1407    #[test]
1408    fn iso_date_invalid() {
1409        assert!(!is_iso_date("6months"));
1410        assert!(!is_iso_date("2025"));
1411        assert!(!is_iso_date("not-a-date"));
1412        assert!(!is_iso_date("abcd-ef-gh"));
1413    }
1414
1415    #[test]
1416    fn trend_display() {
1417        assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1418        assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1419        assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1420    }
1421
1422    #[test]
1423    fn trend_boundary_1_5x_ratio() {
1424        let timestamps = vec![100, 200, 600, 800, 1000];
1425        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1426    }
1427
1428    #[test]
1429    fn trend_just_above_1_5x() {
1430        let timestamps = vec![100, 600, 800, 1000];
1431        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1432    }
1433
1434    #[test]
1435    fn trend_boundary_0_67x_ratio() {
1436        let timestamps = vec![100, 200, 300, 600, 1000];
1437        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1438    }
1439
1440    #[test]
1441    fn trend_two_timestamps_different() {
1442        let timestamps = vec![100, 200];
1443        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1444    }
1445
1446    #[test]
1447    fn parse_since_week_singular() {
1448        let d = parse_since("1week").unwrap();
1449        assert_eq!(
1450            d.window,
1451            ChurnWindow::Relative {
1452                count: 1,
1453                unit: ChurnWindowUnit::Weeks
1454            }
1455        );
1456        assert_eq!(d.display, "1 week");
1457    }
1458
1459    #[test]
1460    fn parse_since_weeks_long() {
1461        let d = parse_since("3weeks").unwrap();
1462        assert_eq!(
1463            d.window,
1464            ChurnWindow::Relative {
1465                count: 3,
1466                unit: ChurnWindowUnit::Weeks
1467            }
1468        );
1469        assert_eq!(d.display, "3 weeks");
1470    }
1471
1472    #[test]
1473    fn parse_since_days_long() {
1474        let d = parse_since("30days").unwrap();
1475        assert_eq!(
1476            d.window,
1477            ChurnWindow::Relative {
1478                count: 30,
1479                unit: ChurnWindowUnit::Days
1480            }
1481        );
1482        assert_eq!(d.display, "30 days");
1483    }
1484
1485    #[test]
1486    fn parse_since_year_long() {
1487        let d = parse_since("1year").unwrap();
1488        assert_eq!(
1489            d.window,
1490            ChurnWindow::Relative {
1491                count: 1,
1492                unit: ChurnWindowUnit::Years
1493            }
1494        );
1495        assert_eq!(d.display, "1 year");
1496    }
1497
1498    #[test]
1499    fn parse_since_overflow_number_rejected() {
1500        let result = parse_since("99999999999999999999d");
1501        assert!(result.is_err());
1502        let err = result.unwrap_err();
1503        assert!(err.contains("invalid number"));
1504    }
1505
1506    #[test]
1507    fn parse_since_zero_days_rejected() {
1508        assert!(parse_since("0d").is_err());
1509    }
1510
1511    #[test]
1512    fn parse_since_zero_weeks_rejected() {
1513        assert!(parse_since("0w").is_err());
1514    }
1515
1516    #[test]
1517    fn parse_since_zero_years_rejected() {
1518        assert!(parse_since("0y").is_err());
1519    }
1520
1521    #[test]
1522    fn iso_date_wrong_separator_positions() {
1523        assert!(!is_iso_date("20-25-0601"));
1524        assert!(!is_iso_date("202506-01-"));
1525    }
1526
1527    #[test]
1528    fn iso_date_too_short() {
1529        assert!(!is_iso_date("2025-06-0"));
1530    }
1531
1532    #[test]
1533    fn iso_date_letters_in_day() {
1534        assert!(!is_iso_date("2025-06-ab"));
1535    }
1536
1537    #[test]
1538    fn iso_date_letters_in_month() {
1539        assert!(!is_iso_date("2025-ab-01"));
1540    }
1541
1542    #[test]
1543    fn split_number_unit_valid() {
1544        let (num, unit) = split_number_unit("42days").unwrap();
1545        assert_eq!(num, "42");
1546        assert_eq!(unit, "days");
1547    }
1548
1549    #[test]
1550    fn split_number_unit_single_digit() {
1551        let (num, unit) = split_number_unit("1m").unwrap();
1552        assert_eq!(num, "1");
1553        assert_eq!(unit, "m");
1554    }
1555
1556    #[test]
1557    fn split_number_unit_no_digits() {
1558        let err = split_number_unit("abc").unwrap_err();
1559        assert!(err.contains("must start with a number"));
1560    }
1561
1562    #[test]
1563    fn split_number_unit_no_unit() {
1564        let err = split_number_unit("123").unwrap_err();
1565        assert!(err.contains("requires a unit suffix"));
1566    }
1567
1568    #[test]
1569    fn trend_serde_serialization() {
1570        assert_eq!(
1571            serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1572            "\"accelerating\""
1573        );
1574        assert_eq!(
1575            serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1576            "\"stable\""
1577        );
1578        assert_eq!(
1579            serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1580            "\"cooling\""
1581        );
1582    }
1583
1584    #[test]
1585    fn intern_author_returns_existing_index() {
1586        let mut pool = Vec::new();
1587        let mut index = FxHashMap::default();
1588        let i1 = intern_author("alice@x", &mut pool, &mut index);
1589        let i2 = intern_author("alice@x", &mut pool, &mut index);
1590        assert_eq!(i1, i2);
1591        assert_eq!(pool.len(), 1);
1592    }
1593
1594    #[test]
1595    fn intern_author_assigns_sequential_indices() {
1596        let mut pool = Vec::new();
1597        let mut index = FxHashMap::default();
1598        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1599        assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1600        assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1601        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1602    }
1603
1604    fn git(root: &Path, args: &[&str]) {
1605        let status = super::git_command()
1606            .args(args)
1607            .current_dir(root)
1608            .status()
1609            .expect("run git");
1610        assert!(status.success(), "git {args:?} failed");
1611    }
1612
1613    fn write(root: &Path, path: &str, contents: &str) {
1614        let path = root.join(path);
1615        std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1616        std::fs::write(path, contents).unwrap();
1617    }
1618
1619    #[cfg(unix)]
1620    #[test]
1621    fn churn_preserves_special_filenames() {
1622        let repo = tempfile::tempdir().expect("create repo");
1623        let root = repo.path();
1624        git(root, &["init", "--quiet"]);
1625        git(root, &["config", "user.email", "churn@example.test"]);
1626        git(root, &["config", "user.name", "Churn Test"]);
1627        git(root, &["config", "commit.gpgsign", "false"]);
1628
1629        let special_files = [
1630            "src/line\nbreak.ts",
1631            "src/space name.ts",
1632            "src/quote\"name.ts",
1633            "src/back\\slash.ts",
1634            "src/unicode-λ.ts",
1635        ]
1636        .map(|path| root.join(path));
1637        std::fs::create_dir_all(root.join("src")).expect("source dir");
1638        for special in &special_files {
1639            std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1640        }
1641        git(root, &["add", "."]);
1642        git(root, &["commit", "--quiet", "-m", "initial"]);
1643
1644        let since = parse_since("1y").expect("valid duration");
1645        let churn = analyze_churn(root, &since).expect("churn result");
1646        for special in special_files {
1647            assert!(
1648                churn.files.contains_key(&special),
1649                "missing {special:?}: {:?}",
1650                churn.files.keys()
1651            );
1652        }
1653    }
1654
1655    #[cfg(unix)]
1656    #[test]
1657    fn churn_cache_preserves_non_utf8_filenames() {
1658        use std::ffi::OsString;
1659        use std::os::unix::ffi::OsStringExt;
1660
1661        let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1662        let mut files = FxHashMap::default();
1663        files.insert(
1664            invalid_path.clone(),
1665            FileEvents {
1666                events: vec![CachedCommitEvent {
1667                    timestamp: 1,
1668                    committed_at: 1,
1669                    lines_added: 2,
1670                    lines_deleted: 1,
1671                    author_idx: None,
1672                }],
1673            },
1674        );
1675        let state = ChurnEventState {
1676            files,
1677            author_pool: Vec::new(),
1678            git_log_bytes: 0,
1679        };
1680        let cache_dir = tempfile::tempdir().expect("cache directory");
1681        save_churn_cache(cache_dir.path(), "abc123", "1y", &state, false);
1682        let warm = load_churn_cache(cache_dir.path(), "1y")
1683            .expect("warm churn cache")
1684            .into_event_state(None);
1685
1686        assert!(warm.files.contains_key(&invalid_path));
1687    }
1688
1689    #[test]
1690    fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1691        let cache_dir = tempfile::tempdir().expect("cache directory");
1692        let cache = ChurnCache {
1693            version: 4,
1694            last_indexed_sha: "abc123".to_string(),
1695            window_token: "1y".to_string(),
1696            files: vec![CachedFileChurn {
1697                path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1698                events: Vec::new(),
1699            }],
1700            shallow_clone: false,
1701            author_pool: Vec::new(),
1702        };
1703        std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1704            .expect("cache fixture");
1705
1706        assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1707    }
1708
1709    #[test]
1710    fn cached_churn_merges_new_commits_after_head_advances() {
1711        let repo = tempfile::tempdir().expect("create repo");
1712        let root = repo.path();
1713        git(root, &["init"]);
1714        git(root, &["config", "user.email", "churn@example.test"]);
1715        git(root, &["config", "user.name", "Churn Test"]);
1716        git(root, &["config", "commit.gpgsign", "false"]);
1717
1718        write(root, "src/a.ts", "export const a = 1;\n");
1719        git(root, &["add", "."]);
1720        git(root, &["commit", "-m", "initial"]);
1721
1722        let since = parse_since("1y").unwrap();
1723        let cache = tempfile::tempdir().expect("create cache dir");
1724        let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1725        assert!(!cold_hit);
1726        let file = root.join("src/a.ts");
1727        assert_eq!(cold.files[&file].commits, 1);
1728
1729        let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1730        assert!(warm_hit);
1731
1732        write(
1733            root,
1734            "src/a.ts",
1735            "export const a = 1;\nexport const b = 2;\n",
1736        );
1737        git(root, &["add", "."]);
1738        git(root, &["commit", "-m", "update a"]);
1739        let head = get_head_sha(root).unwrap();
1740
1741        let (incremental, incremental_hit) =
1742            analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1743        assert!(incremental_hit);
1744        assert_eq!(incremental.files[&file].commits, 2);
1745
1746        let cache = load_churn_cache(cache.path(), &since.window.cache_token()).unwrap();
1747        assert_eq!(cache.last_indexed_sha, head);
1748    }
1749
1750    fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1751        let path = dir.join("churn.json");
1752        std::fs::write(&path, contents).unwrap();
1753        path
1754    }
1755
1756    #[test]
1757    fn churn_file_happy_path() {
1758        let dir = tempfile::tempdir().unwrap();
1759        let root = Path::new("/project");
1760        let path = write_churn_file(
1761            dir.path(),
1762            r#"{
1763              "schema": "fallow-churn/v1",
1764              "events": [
1765                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1766                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1767              ]
1768            }"#,
1769        );
1770        let result = analyze_churn_from_file(&path, root).unwrap();
1771        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1772        assert_eq!(churn.commits, 2);
1773        assert_eq!(churn.lines_added, 13);
1774        assert_eq!(churn.lines_deleted, 7);
1775        assert_eq!(churn.authors.len(), 2);
1776        assert!(result.author_pool.contains(&"alice@corp".to_string()));
1777        assert!(result.author_pool.contains(&"bob@corp".to_string()));
1778        assert!(!result.shallow_clone);
1779    }
1780
1781    #[test]
1782    fn churn_file_empty_events_is_valid() {
1783        let dir = tempfile::tempdir().unwrap();
1784        let path = write_churn_file(
1785            dir.path(),
1786            r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1787        );
1788        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1789        assert!(result.files.is_empty());
1790        assert!(result.author_pool.is_empty());
1791    }
1792
1793    #[test]
1794    fn churn_file_missing_events_key_is_valid() {
1795        let dir = tempfile::tempdir().unwrap();
1796        let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1797        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1798        assert!(result.files.is_empty());
1799    }
1800
1801    #[test]
1802    fn churn_file_bad_schema_rejected() {
1803        let dir = tempfile::tempdir().unwrap();
1804        let path = write_churn_file(
1805            dir.path(),
1806            r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1807        );
1808        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1809        assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
1810    }
1811
1812    #[test]
1813    fn churn_file_malformed_json_rejected() {
1814        let dir = tempfile::tempdir().unwrap();
1815        let path = write_churn_file(dir.path(), "{ not json");
1816        assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
1817    }
1818
1819    #[test]
1820    fn churn_file_missing_file_rejected() {
1821        let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
1822            .unwrap_err();
1823        assert!(err.contains("failed to read churn file"), "{err}");
1824    }
1825
1826    #[test]
1827    fn churn_file_reader_accepts_exact_limit() {
1828        let dir = tempfile::tempdir().unwrap();
1829        let path = write_churn_file(dir.path(), "12345678");
1830        assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
1831    }
1832
1833    #[test]
1834    fn churn_file_reader_rejects_limit_plus_one() {
1835        let dir = tempfile::tempdir().unwrap();
1836        let path = write_churn_file(dir.path(), "123456789");
1837        let err = read_churn_file_with_limit(&path, 8).unwrap_err();
1838        assert!(err.contains("at least 9 bytes"), "{err}");
1839        assert!(err.contains("8 byte limit"), "{err}");
1840    }
1841
1842    #[test]
1843    fn churn_file_empty_path_rejected() {
1844        let dir = tempfile::tempdir().unwrap();
1845        let path = write_churn_file(
1846            dir.path(),
1847            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "  ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1848        );
1849        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1850        assert!(err.contains("empty path"), "{err}");
1851    }
1852
1853    #[test]
1854    fn churn_file_rejects_non_relative_paths() {
1855        let invalid = [
1856            "/tmp/a.ts",
1857            r"C:\tmp\a.ts",
1858            "../a.ts",
1859            "src/../../a.ts",
1860            "./src/a.ts",
1861            "//server/share/a.ts",
1862        ];
1863        for event_path in invalid {
1864            let dir = tempfile::tempdir().unwrap();
1865            let body = format!(
1866                r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
1867            );
1868            let path = write_churn_file(dir.path(), &body);
1869            let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1870            assert!(err.contains(event_path), "{event_path}: {err}");
1871            assert!(err.contains("repo-relative"), "{event_path}: {err}");
1872        }
1873    }
1874
1875    #[test]
1876    fn churn_file_accepts_unicode_and_spaces_in_path_components() {
1877        let dir = tempfile::tempdir().unwrap();
1878        let path = write_churn_file(
1879            dir.path(),
1880            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1881        );
1882        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1883        assert!(
1884            result
1885                .files
1886                .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
1887        );
1888    }
1889
1890    #[test]
1891    fn churn_file_millisecond_timestamp_rejected() {
1892        let dir = tempfile::tempdir().unwrap();
1893        // 1700000000000 is milliseconds; ~52000 years in the future as seconds.
1894        let path = write_churn_file(
1895            dir.path(),
1896            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
1897        );
1898        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1899        assert!(err.contains("milliseconds"), "{err}");
1900    }
1901
1902    #[test]
1903    fn churn_file_missing_author_contributes_no_signal() {
1904        let dir = tempfile::tempdir().unwrap();
1905        let path = write_churn_file(
1906            dir.path(),
1907            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1908        );
1909        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1910        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1911        assert_eq!(churn.commits, 1);
1912        assert!(churn.authors.is_empty());
1913        assert!(result.author_pool.is_empty());
1914    }
1915
1916    #[test]
1917    fn churn_file_empty_author_string_treated_as_absent() {
1918        let dir = tempfile::tempdir().unwrap();
1919        let path = write_churn_file(
1920            dir.path(),
1921            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": "  ", "added": 1, "deleted": 0 } ] }"#,
1922        );
1923        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1924        assert!(result.author_pool.is_empty());
1925    }
1926
1927    #[test]
1928    fn churn_file_unknown_fields_ignored() {
1929        // Extra keys (including the reserved `commit`) are accepted and ignored,
1930        // so a wrapper carrying extra metadata stays forward-compatible.
1931        let dir = tempfile::tempdir().unwrap();
1932        let path = write_churn_file(
1933            dir.path(),
1934            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" } ] }"#,
1935        );
1936        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1937        assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
1938    }
1939
1940    #[test]
1941    fn churn_file_backslash_paths_normalized() {
1942        let dir = tempfile::tempdir().unwrap();
1943        let path = write_churn_file(
1944            dir.path(),
1945            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1946        );
1947        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1948        assert!(
1949            result
1950                .files
1951                .contains_key(&PathBuf::from("/project/src/a.ts"))
1952        );
1953    }
1954
1955    #[test]
1956    fn churn_file_rejects_line_totals_above_u32() {
1957        let dir = tempfile::tempdir().unwrap();
1958        let path = write_churn_file(
1959            dir.path(),
1960            r#"{ "schema": "fallow-churn/v1", "events": [
1961                { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
1962                { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
1963            ] }"#,
1964        );
1965        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1966        assert!(err.contains("exceeding the u32 limit"), "{err}");
1967        assert!(err.contains("src/a.ts"), "{err}");
1968    }
1969
1970    #[test]
1971    fn churn_file_accepts_line_totals_at_u32_max() {
1972        let dir = tempfile::tempdir().unwrap();
1973        let path = write_churn_file(
1974            dir.path(),
1975            r#"{ "schema": "fallow-churn/v1", "events": [
1976                { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
1977                { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
1978            ] }"#,
1979        );
1980        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1981        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1982        assert_eq!(churn.lines_added, u32::MAX);
1983        assert_eq!(churn.lines_deleted, u32::MAX);
1984    }
1985
1986    /// A numstat row that arrives before any commit header (truncated or
1987    /// malformed git output) must fall back to the run clock, so the recorded
1988    /// timestamp, and therefore the file's weighted commits and staleness, is
1989    /// the same on every run over one commit instead of moving with wall time.
1990    #[test]
1991    fn headerless_numstat_falls_back_to_the_run_clock() {
1992        let root = Path::new("/project");
1993        let pinned = 1_700_000_000;
1994
1995        let state = parse_git_log_events_z(b"10\t5\tsrc/a.ts", root, pinned);
1996
1997        let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
1998        assert_eq!(events.len(), 1);
1999        assert_eq!(events[0].timestamp, pinned);
2000        assert_eq!(events[0].committed_at, pinned);
2001    }
2002
2003    /// The same truncated output parsed against two different run clocks must
2004    /// disagree only by those clocks: nothing in the fallback path may consult
2005    /// the system clock.
2006    #[test]
2007    fn headerless_numstat_tracks_only_the_supplied_clock() {
2008        let root = Path::new("/project");
2009        let record: &[u8] = b"1\t0\tsrc/a.ts";
2010
2011        let early = parse_git_log_events_z(record, root, 1_600_000_000);
2012        let late = parse_git_log_events_z(record, root, 1_700_000_000);
2013
2014        let key = PathBuf::from("/project/src/a.ts");
2015        assert_eq!(early.files[&key].events[0].timestamp, 1_600_000_000);
2016        assert_eq!(late.files[&key].events[0].timestamp, 1_700_000_000);
2017    }
2018
2019    /// A commit header still wins over the fallback: the clock only fills a gap.
2020    #[test]
2021    fn commit_header_timestamp_beats_the_run_clock_fallback() {
2022        let root = Path::new("/project");
2023        let record = b"1700000000|1700000500|dev@example.com\x002\t1\tsrc/a.ts";
2024
2025        let state = parse_git_log_events_z(record, root, 1_234_567_890);
2026
2027        let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2028        assert_eq!(events[0].timestamp, 1_700_000_000);
2029        assert_eq!(events[0].committed_at, 1_700_000_500);
2030    }
2031
2032    /// The run clock for the ported `git log -z` scenarios below.
2033    const NOW: u64 = 1_750_000_000;
2034    const HEADER: &str = "1700000000|1700000000|dev@example.com";
2035
2036    /// Build the bytes that `git log --numstat -z --format=format:%at|%ct|%ae%x00`
2037    /// prints: each commit is `header NUL LF`, then one NUL-terminated numstat
2038    /// record per file, and consecutive commits are split by one more NUL.
2039    fn git_log_z(commits: &[(&str, &[&str])]) -> Vec<u8> {
2040        let mut out = Vec::new();
2041        for (index, (header, rows)) in commits.iter().enumerate() {
2042            if index > 0 {
2043                out.push(0);
2044            }
2045            out.extend_from_slice(header.as_bytes());
2046            out.extend_from_slice(b"\0\n");
2047            for row in *rows {
2048                out.extend_from_slice(row.as_bytes());
2049                out.push(0);
2050            }
2051        }
2052        out
2053    }
2054
2055    fn churn_from_git_log_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnResult {
2056        build_churn_result(
2057            parse_git_log_events_z(stdout, root, now_secs),
2058            false,
2059            crate::clock::AnalysisClock::pinned(now_secs),
2060        )
2061    }
2062
2063    fn churn_from_commits(commits: &[(&str, &[&str])]) -> ChurnResult {
2064        churn_from_git_log_z(&git_log_z(commits), Path::new("/project"), NOW)
2065    }
2066
2067    #[test]
2068    fn git_log_z_fixture_matches_real_git_output() {
2069        assert_eq!(
2070            git_log_z(&[
2071                ("1|2|a@b", &["1\t0\ta.ts"]),
2072                ("3|4|a@b", &["1\t0\ta.ts", "1\t0\tb c.ts"])
2073            ]),
2074            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()
2075        );
2076    }
2077
2078    #[test]
2079    fn numstat_records_parse_counts_and_skip_binary_or_malformed_rows() {
2080        let root = Path::new("/project");
2081        type Expected = Option<(u32, u32, &'static str)>;
2082        let cases: &[(&str, Expected)] = &[
2083            ("10\t5\tsrc/file.ts", Some((10, 5, "src/file.ts"))),
2084            ("0\t0\tsrc/empty.ts", Some((0, 0, "src/empty.ts"))),
2085            (
2086                "3\t1\tpath with spaces/file.ts",
2087                Some((3, 1, "path with spaces/file.ts")),
2088            ),
2089            ("1\t0\tweird\tname.ts", Some((1, 0, "weird\tname.ts"))),
2090            (
2091                "4294967295\t8888\tsrc/big.ts",
2092                Some((u32::MAX, 8888, "src/big.ts")),
2093            ),
2094            ("4294967296\t0\tsrc/overflow.ts", None),
2095            ("-\t-\tsrc/image.png", None),
2096            ("-\t5\tsrc/file.ts", None),
2097            ("10\t-\tsrc/file.ts", None),
2098            ("10\t5", None),
2099        ];
2100        for (row, expected) in cases {
2101            let state = parse_git_log_events_z(&git_log_z(&[(HEADER, &[row])]), root, NOW);
2102            let Some((added, deleted, path)) = expected else {
2103                assert!(state.files.is_empty(), "{row:?} must not record churn");
2104                continue;
2105            };
2106            assert_eq!(state.files.len(), 1, "{row:?}");
2107            let events = &state.files[&root.join(path)].events;
2108            assert_eq!(events.len(), 1, "{row:?}");
2109            assert_eq!(events[0].lines_added, *added, "{row:?}");
2110            assert_eq!(events[0].lines_deleted, *deleted, "{row:?}");
2111        }
2112    }
2113
2114    #[test]
2115    fn git_log_z_aggregates_commits_and_lines_per_file() {
2116        let result = churn_from_commits(&[
2117            (
2118                HEADER,
2119                &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts", "-\t-\timage.png"],
2120            ),
2121            ("1700100000|1700100000|dev@example.com", &["3\t2\tsrc/a.ts"]),
2122        ]);
2123        assert_eq!(result.files.len(), 2);
2124        let a = &result.files[&PathBuf::from("/project/src/a.ts")];
2125        assert_eq!((a.commits, a.lines_added, a.lines_deleted), (2, 13, 7));
2126        let b = &result.files[&PathBuf::from("/project/src/b.ts")];
2127        assert_eq!((b.commits, b.lines_added, b.lines_deleted), (1, 3, 1));
2128        assert!(
2129            !result
2130                .files
2131                .contains_key(&PathBuf::from("/project/image.png"))
2132        );
2133    }
2134
2135    #[test]
2136    fn git_log_z_empty_or_blank_output_records_nothing() {
2137        assert!(
2138            churn_from_git_log_z(b"", Path::new("/project"), NOW)
2139                .files
2140                .is_empty()
2141        );
2142        assert!(
2143            churn_from_git_log_z(b"\0\0\n\0  \0", Path::new("/project"), NOW)
2144                .files
2145                .is_empty()
2146        );
2147    }
2148
2149    #[test]
2150    fn git_log_z_blank_records_between_commits_are_ignored() {
2151        let stdout = b"  \0\n1700000000|1700000000|dev@example.com\0\n  \0\n10\t5\tsrc/a.ts\0\0";
2152        let result = churn_from_git_log_z(stdout, Path::new("/project"), NOW);
2153        assert_eq!(result.files.len(), 1);
2154        assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2155    }
2156
2157    #[test]
2158    fn git_log_z_paths_are_joined_to_the_root() {
2159        let root = Path::new("/my/project");
2160        let result =
2161            churn_from_git_log_z(&git_log_z(&[(HEADER, &["1\t0\tlib/utils.ts"])]), root, NOW);
2162        let key = PathBuf::from("/my/project/lib/utils.ts");
2163        assert_eq!(result.files[&key].path, key);
2164    }
2165
2166    #[test]
2167    fn git_log_z_weights_commits_by_age_against_the_run_clock() {
2168        let day = 86_400;
2169        let now = format!("{NOW}|{NOW}|dev@example.com");
2170        let half_life = format!("{0}|{0}|dev@example.com", NOW - 45 * day);
2171        let two_half_lives = format!("{0}|{0}|dev@example.com", NOW - 180 * day);
2172        let result = churn_from_commits(&[
2173            (&now, &["1\t0\tsrc/fresh.ts"]),
2174            (&half_life, &["1\t0\tsrc/half.ts"]),
2175            (&two_half_lives, &["1\t0\tsrc/old.ts"]),
2176        ]);
2177        let weight = |name: &str| {
2178            result.files[&PathBuf::from(format!("/project/src/{name}"))].weighted_commits
2179        };
2180        assert!((weight("fresh.ts") - 1.0).abs() < f64::EPSILON);
2181        // 0.5^(45/90) = 0.7071..., rounded to two decimals.
2182        assert!((weight("half.ts") - 0.71).abs() < f64::EPSILON);
2183        assert!((weight("old.ts") - 0.25).abs() < f64::EPSILON);
2184    }
2185
2186    #[test]
2187    fn git_log_z_headerless_numstat_weighs_as_a_commit_at_the_run_clock() {
2188        let result = churn_from_git_log_z(b"10\t5\tsrc/no_ts.ts\0", Path::new("/project"), NOW);
2189        let churn = &result.files[&PathBuf::from("/project/src/no_ts.ts")];
2190        assert_eq!(
2191            (churn.commits, churn.lines_added, churn.lines_deleted),
2192            (1, 10, 5)
2193        );
2194        assert!((churn.weighted_commits - 1.0).abs() < f64::EPSILON);
2195    }
2196
2197    #[test]
2198    fn git_log_z_trend_is_computed_per_file() {
2199        let result = churn_from_commits(&[
2200            (
2201                "1000|1000|dev@example.com",
2202                &["5\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2203            ),
2204            ("1800|1800|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2205            ("1900|1900|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2206            ("1950|1950|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2207            (
2208                "2000|2000|dev@example.com",
2209                &["3\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2210            ),
2211        ]);
2212        let old = &result.files[&PathBuf::from("/project/src/old.ts")];
2213        let hot = &result.files[&PathBuf::from("/project/src/hot.ts")];
2214        assert_eq!(old.commits, 2);
2215        assert_eq!(old.trend, ChurnTrend::Stable);
2216        assert_eq!(hot.commits, 5);
2217        assert_eq!(hot.trend, ChurnTrend::Accelerating);
2218    }
2219
2220    #[test]
2221    fn git_log_z_interns_authors_and_aggregates_per_author() {
2222        let result = churn_from_commits(&[
2223            (
2224                "1700000000|1700000000|alice@example.com",
2225                &["1\t0\tsrc/index.ts"],
2226            ),
2227            (
2228                "1700100000|1700100000|bob@example.com",
2229                &["2\t0\tsrc/index.ts"],
2230            ),
2231            (
2232                "1700200000|1700200000|alice@example.com",
2233                &["1\t1\tsrc/index.ts"],
2234            ),
2235        ]);
2236        assert_eq!(
2237            result.author_pool,
2238            vec![
2239                "alice@example.com".to_string(),
2240                "bob@example.com".to_string()
2241            ]
2242        );
2243        let churn = &result.files[&PathBuf::from("/project/src/index.ts")];
2244        assert_eq!(churn.commits, 3);
2245        assert_eq!(churn.authors.len(), 2);
2246        let alice = &churn.authors[&0];
2247        assert_eq!(alice.commits, 2);
2248        assert_eq!(alice.first_commit_ts, 1_700_000_000);
2249        assert_eq!(alice.last_commit_ts, 1_700_200_000);
2250        let bob = &churn.authors[&1];
2251        assert_eq!(bob.commits, 1);
2252        assert_eq!(bob.first_commit_ts, 1_700_100_000);
2253    }
2254
2255    #[test]
2256    fn git_log_z_legacy_headers_still_parse() {
2257        let result = churn_from_commits(&[
2258            ("1700000000", &["10\t5\tsrc/bare.ts"]),
2259            ("1700100000|alice@example.com", &["1\t0\tsrc/two_field.ts"]),
2260        ]);
2261        assert_eq!(result.author_pool, vec!["alice@example.com".to_string()]);
2262        let bare = &result.files[&PathBuf::from("/project/src/bare.ts")];
2263        assert_eq!(bare.commits, 1);
2264        assert!(bare.authors.is_empty());
2265        let two_field = &result.files[&PathBuf::from("/project/src/two_field.ts")];
2266        assert_eq!(two_field.authors[&0].first_commit_ts, 1_700_100_000);
2267    }
2268
2269    #[test]
2270    fn churn_file_matches_git_log_z_parse() {
2271        // The same events fed via git numstat and via the JSON import must
2272        // produce identical aggregate churn: the import reuses
2273        // build_churn_result, so only the SOURCE differs.
2274        let dir = tempfile::tempdir().unwrap();
2275        let root = Path::new("/project");
2276        let path = write_churn_file(
2277            dir.path(),
2278            r#"{
2279              "schema": "fallow-churn/v1",
2280              "events": [
2281                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2282                { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
2283                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2284              ]
2285            }"#,
2286        );
2287        let imported = analyze_churn_from_file(&path, root).unwrap();
2288        let git = churn_from_git_log_z(
2289            &git_log_z(&[
2290                (
2291                    "1700000000|1700000000|alice@corp",
2292                    &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts"],
2293                ),
2294                ("1700100000|1700100000|bob@corp", &["3\t2\tsrc/a.ts"]),
2295            ]),
2296            root,
2297            imported.clock.epoch_secs(),
2298        );
2299
2300        assert_eq!(
2301            git.author_pool, imported.author_pool,
2302            "author pools diverge"
2303        );
2304        assert_eq!(git.files.len(), imported.files.len());
2305        for (file, git_churn) in &git.files {
2306            let imp = &imported.files[file];
2307            assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
2308            assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
2309            assert_eq!(
2310                git_churn.lines_deleted, imp.lines_deleted,
2311                "deleted for {file:?}"
2312            );
2313            assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
2314            assert_eq!(git_churn.authors, imp.authors, "authors for {file:?}");
2315            assert!(
2316                (git_churn.weighted_commits - imp.weighted_commits).abs() < f64::EPSILON,
2317                "weighted_commits for {file:?}: {} vs {}",
2318                git_churn.weighted_commits,
2319                imp.weighted_commits
2320            );
2321        }
2322    }
2323}