Skip to main content

fallow_engine/
churn.rs

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