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