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::path::{Path, PathBuf};
8use std::process::{Command, Output};
9use std::sync::OnceLock;
10
11use serde::Deserialize;
12
13pub use fallow_types::churn::ChurnTrend;
14
15/// Function pointer signature used by `set_spawn_hook` to intercept the
16/// `git log --numstat` subprocess. Lets the CLI route long-running git
17/// log calls through its `ScopedChild` registry so SIGINT / SIGTERM
18/// reap the subprocess instead of leaving it running after the parent
19/// exits. See `crates/cli/src/signal/` and issue #477.
20pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
21
22static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
23
24#[expect(
25    clippy::disallowed_methods,
26    reason = "engine-owned git spawn wrapper clears ambient git env before churn subprocesses"
27)]
28fn git_command() -> Command {
29    let mut command = Command::new("git");
30    crate::git_env::clear_ambient_git_env(&mut command);
31    command
32}
33
34/// Install a spawn-hook that wraps the `git log` subprocess. Idempotent;
35/// subsequent calls are no-ops. Called once from the CLI's `main()` to
36/// route through the signal registry; defaults to `Command::output`
37/// when not set so the function-pointer indirection stays free for tests
38/// and embedders that don't care.
39pub fn set_spawn_hook(hook: ChurnSpawnHook) {
40    let _ = SPAWN_HOOK.set(hook);
41}
42
43fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
44    if let Some(hook) = SPAWN_HOOK.get() {
45        hook(command)
46    } else {
47        command.output()
48    }
49}
50
51/// Number of seconds in one day.
52const SECS_PER_DAY: f64 = 86_400.0;
53
54/// Recency weight half-life in days. A commit from 90 days ago counts half
55/// as much as today's commit; 180 days ago counts 25%.
56const HALF_LIFE_DAYS: f64 = 90.0;
57
58/// Schema discriminator a `--churn-file` document must declare.
59const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
60
61/// Upper bound on imported churn events. A file past this size is a sign of a
62/// pathological export (whole-history dump of a giant monorepo) rather than a
63/// useful hotspot window; parsing is rejected so we never allocate unbounded
64/// state from a single untrusted file. Mirrors the diff parser's
65/// `MAX_ADDED_LINES` guard in the CLI.
66const MAX_CHURN_EVENTS: usize = 5_000_000;
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/// Parsed duration for the `--since` flag.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SinceDuration {
80    /// Value to pass to `git log --after` (e.g., `"6 months ago"` or `"2025-06-01"`).
81    pub git_after: String,
82    /// Human-readable display string (e.g., `"6 months"`).
83    pub display: String,
84}
85
86/// Per-author commit aggregation for a single file.
87///
88/// Authors are interned via [`ChurnResult::author_pool`] indices to keep
89/// per-file maps small and the bitcode cache compact.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct AuthorContribution {
92    /// Total commits by this author touching this file in the analysis window.
93    pub commits: u32,
94    /// Recency-weighted commit sum (exponential decay, half-life 90 days).
95    pub weighted_commits: f64,
96    /// Earliest commit timestamp by this author (epoch seconds).
97    pub first_commit_ts: u64,
98    /// Latest commit timestamp by this author (epoch seconds).
99    pub last_commit_ts: u64,
100}
101
102/// Per-file churn data collected from git history.
103#[derive(Debug, Clone)]
104pub struct FileChurn {
105    /// Absolute file path.
106    pub path: PathBuf,
107    /// Total number of commits touching this file in the analysis window.
108    pub commits: u32,
109    /// Recency-weighted commit count (exponential decay, half-life 90 days).
110    pub weighted_commits: f64,
111    /// Total lines added across all commits.
112    pub lines_added: u32,
113    /// Total lines deleted across all commits.
114    pub lines_deleted: u32,
115    /// Churn trend: accelerating, stable, or cooling.
116    pub trend: ChurnTrend,
117    /// Per-author contributions keyed by interned author index.
118    /// Indices reference [`ChurnResult::author_pool`].
119    pub authors: FxHashMap<u32, AuthorContribution>,
120}
121
122/// Result of churn analysis.
123#[derive(Debug, Clone)]
124pub struct ChurnResult {
125    /// Per-file churn data, keyed by absolute path.
126    pub files: FxHashMap<PathBuf, FileChurn>,
127    /// Whether the repository is a shallow clone.
128    pub shallow_clone: bool,
129    /// Author email pool. Per-file [`AuthorContribution`] entries reference
130    /// authors by their index into this vector.
131    pub author_pool: Vec<String>,
132}
133
134/// Parse a `--since` value into a git-compatible duration.
135///
136/// Accepts:
137/// - Durations: `6m`, `6months`, `90d`, `90days`, `1y`, `1year`, `2w`, `2weeks`
138/// - ISO dates: `2025-06-01`
139///
140/// # Errors
141///
142/// Returns an error if the input is not a recognized duration format or ISO date,
143/// the numeric part is invalid, or the duration is zero.
144pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
145    if is_iso_date(input) {
146        return Ok(SinceDuration {
147            git_after: input.to_string(),
148            display: input.to_string(),
149        });
150    }
151
152    let (num_str, unit) = split_number_unit(input)?;
153    let num: u64 = num_str
154        .parse()
155        .map_err(|_| format!("invalid number in --since: {input}"))?;
156
157    if num == 0 {
158        return Err("--since duration must be greater than 0".to_string());
159    }
160
161    match unit {
162        "d" | "day" | "days" => {
163            let s = if num == 1 { "" } else { "s" };
164            Ok(SinceDuration {
165                git_after: format!("{num} day{s} ago"),
166                display: format!("{num} day{s}"),
167            })
168        }
169        "w" | "week" | "weeks" => {
170            let s = if num == 1 { "" } else { "s" };
171            Ok(SinceDuration {
172                git_after: format!("{num} week{s} ago"),
173                display: format!("{num} week{s}"),
174            })
175        }
176        "m" | "month" | "months" => {
177            let s = if num == 1 { "" } else { "s" };
178            Ok(SinceDuration {
179                git_after: format!("{num} month{s} ago"),
180                display: format!("{num} month{s}"),
181            })
182        }
183        "y" | "year" | "years" => {
184            let s = if num == 1 { "" } else { "s" };
185            Ok(SinceDuration {
186                git_after: format!("{num} year{s} ago"),
187                display: format!("{num} year{s}"),
188            })
189        }
190        _ => Err(format!(
191            "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
192        )),
193    }
194}
195
196/// Analyze git churn for files in the given root directory.
197///
198/// Returns `None` if git is not available or the directory is not a git repository.
199pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
200    let shallow = is_shallow_clone(root);
201    let state = analyze_churn_events(root, since, None)?;
202    Some(build_churn_result(state, shallow))
203}
204
205/// A `fallow-churn/v1` import document: a normalized, VCS-agnostic stand-in for
206/// `git log --numstat` output. Unknown fields are ignored (no
207/// `deny_unknown_fields`) so wrappers may carry extra metadata and so the
208/// reserved `commit` field can be added in a future revision without breaking
209/// v1 consumers.
210#[derive(Debug, Deserialize)]
211struct ChurnFileDoc {
212    schema: String,
213    #[serde(default)]
214    events: Vec<ChurnFileEvent>,
215}
216
217/// One per-(commit, file) change event, the natural shape of a `<vcs> log
218/// --numstat` row. `commit` is intentionally NOT a field: extra keys are
219/// already ignored, so a wrapper emitting `commit` is forward-compatible and a
220/// future revision can promote it to a real field without a breaking change.
221#[derive(Debug, Deserialize)]
222struct ChurnFileEvent {
223    /// Repo-root-relative, forward-slash path. Joined to `root`.
224    path: String,
225    /// Commit time, unix SECONDS UTC (not milliseconds).
226    timestamp: u64,
227    /// Opaque author identity (email recommended); absent contributes no
228    /// ownership signal. fallow does NOT apply mailmap to imported authors.
229    #[serde(default)]
230    author: Option<String>,
231    /// Lines added in this file in this commit.
232    added: u32,
233    /// Lines deleted in this file in this commit.
234    deleted: u32,
235}
236
237/// Build churn data from a normalized `fallow-churn/v1` JSON import instead of
238/// `git log`. Lets projects on a non-git VCS (Yandex Arc, Mercurial, Perforce)
239/// feed change history into hotspot / ownership / bus-factor analysis: a small
240/// wrapper translates the VCS log into the contract and fallow runs all the
241/// usual recency-weighting, trend, and ownership logic on the imported events.
242///
243/// `root` is the project root that relative event paths are joined to (matching
244/// how the git path joins numstat paths), so the churn keys line up with the
245/// analyzed files. Returns a human-readable error (the CLI maps it to exit code
246/// 2) on a missing file, malformed JSON, wrong `schema`, an empty event path, a
247/// far-future timestamp, or an event count past `MAX_CHURN_EVENTS`. An empty
248/// `events` array is valid (no hotspots), not an error. Never runs `git`.
249pub fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
250    let raw = std::fs::read_to_string(path)
251        .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
252    let doc: ChurnFileDoc = serde_json::from_str(&raw)
253        .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
254    if doc.schema != CHURN_FILE_SCHEMA {
255        return Err(format!(
256            "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
257            path.display(),
258            doc.schema
259        ));
260    }
261    if doc.events.len() > MAX_CHURN_EVENTS {
262        return Err(format!(
263            "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
264            path.display(),
265            doc.events.len()
266        ));
267    }
268
269    let state = churn_event_state_from_doc(&doc, path, root)?;
270    Ok(build_churn_result(state, false))
271}
272
273/// Validate and fold a parsed `fallow-churn/v1` document into event state.
274///
275/// Rejects empty paths and far-future (likely millisecond) timestamps; interns
276/// authors into the pool exactly as the git-log path does.
277fn churn_event_state_from_doc(
278    doc: &ChurnFileDoc,
279    path: &Path,
280    root: &Path,
281) -> Result<ChurnEventState, String> {
282    let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
283
284    for event in &doc.events {
285        builder.push_event(event)?;
286    }
287
288    Ok(builder.finish())
289}
290
291fn churn_file_future_limit() -> u64 {
292    let now_secs = std::time::SystemTime::now()
293        .duration_since(std::time::UNIX_EPOCH)
294        .unwrap_or_default()
295        .as_secs();
296    now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
297}
298
299struct ChurnFileImportBuilder<'a> {
300    path: &'a Path,
301    root: &'a Path,
302    future_limit: u64,
303    files: FxHashMap<PathBuf, FileEvents>,
304    author_pool: Vec<String>,
305    author_index: FxHashMap<String, u32>,
306}
307
308impl<'a> ChurnFileImportBuilder<'a> {
309    fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
310        Self {
311            path,
312            root,
313            future_limit,
314            files: FxHashMap::default(),
315            author_pool: Vec::new(),
316            author_index: FxHashMap::default(),
317        }
318    }
319
320    fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
321        let rel = normalize_churn_event_path(self.path, &event.path)?;
322        validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
323
324        let abs_path = self.root.join(&rel);
325        let author_idx = self.intern_author(event.author.as_deref());
326        self.files
327            .entry(abs_path)
328            .or_insert_with(|| FileEvents { events: Vec::new() })
329            .events
330            .push(CachedCommitEvent {
331                timestamp: event.timestamp,
332                lines_added: event.added,
333                lines_deleted: event.deleted,
334                author_idx,
335            });
336        Ok(())
337    }
338
339    fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
340        author
341            .map(str::trim)
342            .filter(|email| !email.is_empty())
343            .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
344    }
345
346    fn finish(self) -> ChurnEventState {
347        ChurnEventState {
348            files: self.files,
349            author_pool: self.author_pool,
350        }
351    }
352}
353
354fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
355    let normalized = event_path.replace('\\', "/");
356    let rel = normalized.trim();
357    if rel.is_empty() {
358        return Err(format!(
359            "churn file {} has an event with an empty path",
360            path.display()
361        ));
362    }
363    Ok(rel.to_string())
364}
365
366fn validate_churn_event_timestamp(
367    path: &Path,
368    timestamp: u64,
369    future_limit: u64,
370    rel: &str,
371) -> Result<(), String> {
372    if timestamp <= future_limit {
373        return Ok(());
374    }
375
376    Err(format!(
377        "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
378         future; timestamps must be unix SECONDS (not milliseconds), UTC",
379        path.display(),
380        timestamp
381    ))
382}
383
384/// Check if the repository is a shallow clone.
385#[must_use]
386pub fn is_shallow_clone(root: &Path) -> bool {
387    let mut command = git_command();
388    command
389        .args(["rev-parse", "--is-shallow-repository"])
390        .current_dir(root);
391    command.output().is_ok_and(|o| {
392        String::from_utf8_lossy(&o.stdout)
393            .trim()
394            .eq_ignore_ascii_case("true")
395    })
396}
397
398/// Check if the directory is inside a git repository.
399#[must_use]
400pub fn is_git_repo(root: &Path) -> bool {
401    let mut command = git_command();
402    command
403        .args(["rev-parse", "--git-dir"])
404        .current_dir(root)
405        .stdout(std::process::Stdio::null())
406        .stderr(std::process::Stdio::null());
407    command.status().is_ok_and(|s| s.success())
408}
409
410/// Maximum size of a churn cache file (64 MB). The incremental cache stores
411/// per-commit events, so it needs more headroom than the old aggregate rows.
412const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
413
414/// Cache schema version. Bump when the on-disk shape of [`ChurnCache`]
415/// changes so older payloads are rejected on load. Bumped to 3 when the cache
416/// switched from aggregate rows to per-commit events for incremental updates.
417const CHURN_CACHE_VERSION: u8 = 3;
418
419/// Serializable per-commit event for the disk cache.
420#[derive(Clone, bitcode::Encode, bitcode::Decode)]
421struct CachedCommitEvent {
422    timestamp: u64,
423    lines_added: u32,
424    lines_deleted: u32,
425    author_idx: Option<u32>,
426}
427
428/// Serializable per-file churn entry for the disk cache.
429#[derive(Clone, bitcode::Encode, bitcode::Decode)]
430struct CachedFileChurn {
431    path: String,
432    events: Vec<CachedCommitEvent>,
433}
434
435/// Cached churn data keyed by last indexed SHA and since string.
436#[derive(Clone, bitcode::Encode, bitcode::Decode)]
437struct ChurnCache {
438    /// Schema version; must equal [`CHURN_CACHE_VERSION`] to be accepted.
439    version: u8,
440    last_indexed_sha: String,
441    git_after: String,
442    files: Vec<CachedFileChurn>,
443    shallow_clone: bool,
444    /// Author email pool referenced by [`CachedCommitEvent::author_idx`].
445    author_pool: Vec<String>,
446}
447
448/// Per-file commit events retained in memory while building or updating churn.
449struct FileEvents {
450    events: Vec<CachedCommitEvent>,
451}
452
453/// Event-level churn state. Unlike [`ChurnResult`], this preserves commit
454/// timestamps so a cache can merge new commits and recompute trend/recency.
455struct ChurnEventState {
456    files: FxHashMap<PathBuf, FileEvents>,
457    author_pool: Vec<String>,
458}
459
460/// Get the full HEAD SHA for cache keying.
461fn get_head_sha(root: &Path) -> Option<String> {
462    let mut command = git_command();
463    command.args(["rev-parse", "HEAD"]).current_dir(root);
464    command
465        .output()
466        .ok()
467        .filter(|o| o.status.success())
468        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
469}
470
471/// Check whether `ancestor` is still reachable from `descendant`.
472fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
473    let mut command = git_command();
474    command
475        .args(["merge-base", "--is-ancestor", ancestor, descendant])
476        .current_dir(root);
477    command.status().is_ok_and(|s| s.success())
478}
479
480/// Try to load churn data from disk cache. Returns `None` on cache miss
481/// or version mismatch.
482fn load_churn_cache(cache_dir: &Path, git_after: &str) -> Option<ChurnCache> {
483    let cache_file = cache_dir.join("churn.bin");
484    let data = std::fs::read(&cache_file).ok()?;
485    if data.len() > MAX_CHURN_CACHE_SIZE {
486        return None;
487    }
488    let cache: ChurnCache = bitcode::decode(&data).ok()?;
489    if cache.version != CHURN_CACHE_VERSION || cache.git_after != git_after {
490        return None;
491    }
492    Some(cache)
493}
494
495/// Save churn data to disk cache.
496fn save_churn_cache(
497    cache_dir: &Path,
498    last_indexed_sha: &str,
499    git_after: &str,
500    state: &ChurnEventState,
501    shallow_clone: bool,
502) {
503    let files: Vec<CachedFileChurn> = state
504        .files
505        .iter()
506        .map(|f| CachedFileChurn {
507            path: f.0.to_string_lossy().to_string(),
508            events: f.1.events.clone(),
509        })
510        .collect();
511    let cache = ChurnCache {
512        version: CHURN_CACHE_VERSION,
513        last_indexed_sha: last_indexed_sha.to_string(),
514        git_after: git_after.to_string(),
515        files,
516        shallow_clone,
517        author_pool: state.author_pool.clone(),
518    };
519    let _ = std::fs::create_dir_all(cache_dir);
520    let data = bitcode::encode(&cache);
521    let tmp = cache_dir.join("churn.bin.tmp");
522    if std::fs::write(&tmp, data).is_ok() {
523        let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
524    }
525}
526
527/// Analyze churn with disk caching. Uses cached result when HEAD SHA and
528/// since duration match. If HEAD advanced from the cached SHA, runs an
529/// incremental `git log <cached>..HEAD --numstat` scan and merges it.
530///
531/// Returns `(ChurnResult, bool)` where the bool indicates whether reusable
532/// cache state was used.
533/// Returns `None` if git analysis fails.
534pub fn analyze_churn_cached(
535    root: &Path,
536    since: &SinceDuration,
537    cache_dir: &Path,
538    no_cache: bool,
539) -> Option<(ChurnResult, bool)> {
540    let head_sha = get_head_sha(root)?;
541
542    if !no_cache && let Some(result) = try_reuse_churn_cache(root, since, cache_dir, &head_sha) {
543        return Some((result, true));
544    }
545
546    analyze_fresh_churn(root, since, cache_dir, no_cache, &head_sha).map(|result| (result, false))
547}
548
549fn try_reuse_churn_cache(
550    root: &Path,
551    since: &SinceDuration,
552    cache_dir: &Path,
553    head_sha: &str,
554) -> Option<ChurnResult> {
555    let cache = load_churn_cache(cache_dir, &since.git_after)?;
556    if cache.last_indexed_sha == head_sha {
557        let shallow_clone = cache.shallow_clone;
558        return Some(build_churn_result(cache.into_event_state(), shallow_clone));
559    }
560
561    if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
562        return None;
563    }
564
565    extend_churn_cache(root, since, cache_dir, head_sha, cache)
566}
567
568fn extend_churn_cache(
569    root: &Path,
570    since: &SinceDuration,
571    cache_dir: &Path,
572    head_sha: &str,
573    cache: ChurnCache,
574) -> Option<ChurnResult> {
575    let shallow_clone = is_shallow_clone(root);
576    let range = format!("{}..HEAD", cache.last_indexed_sha);
577    let delta = analyze_churn_events(root, since, Some(&range))?;
578    let mut state = cache.into_event_state();
579    merge_churn_states(&mut state, delta);
580    save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
581    Some(build_churn_result(state, shallow_clone))
582}
583
584fn analyze_fresh_churn(
585    root: &Path,
586    since: &SinceDuration,
587    cache_dir: &Path,
588    no_cache: bool,
589    head_sha: &str,
590) -> Option<ChurnResult> {
591    let shallow_clone = is_shallow_clone(root);
592    let state = analyze_churn_events(root, since, None)?;
593    if !no_cache {
594        save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
595    }
596
597    Some(build_churn_result(state, shallow_clone))
598}
599
600impl ChurnCache {
601    fn into_event_state(self) -> ChurnEventState {
602        let files = self
603            .files
604            .into_iter()
605            .map(|entry| {
606                (
607                    PathBuf::from(entry.path),
608                    FileEvents {
609                        events: entry.events,
610                    },
611                )
612            })
613            .collect();
614        ChurnEventState {
615            files,
616            author_pool: self.author_pool,
617        }
618    }
619}
620
621/// Run `git log --numstat` and return event-level churn state.
622fn analyze_churn_events(
623    root: &Path,
624    since: &SinceDuration,
625    revision_range: Option<&str>,
626) -> Option<ChurnEventState> {
627    let mut command = git_command();
628    command.arg("log");
629    if let Some(range) = revision_range {
630        command.arg(range);
631    }
632    command
633        .args([
634            "--numstat",
635            "--no-merges",
636            "--no-renames",
637            "--use-mailmap",
638            "--format=format:%at|%ae",
639            &format!("--after={}", since.git_after),
640        ])
641        .current_dir(root);
642
643    let output = match spawn_output(&mut command) {
644        Ok(o) => o,
645        Err(e) => {
646            tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
647            return None;
648        }
649    };
650
651    if !output.status.success() {
652        let stderr = String::from_utf8_lossy(&output.stderr);
653        tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
654        return None;
655    }
656
657    let stdout = String::from_utf8_lossy(&output.stdout);
658    Some(parse_git_log_events(&stdout, root))
659}
660
661/// Merge new churn events into cached event state.
662fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
663    let mut base_author_index: FxHashMap<String, u32> = base
664        .author_pool
665        .iter()
666        .enumerate()
667        .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
668        .collect();
669
670    let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
671    for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
672        let Ok(old_idx) = u32::try_from(old_idx) else {
673            continue;
674        };
675        let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
676        author_mapping.insert(old_idx, new_idx);
677    }
678
679    for (path, mut file) in delta.files {
680        for event in &mut file.events {
681            event.author_idx = event
682                .author_idx
683                .and_then(|idx| author_mapping.get(&idx).copied());
684        }
685        base.files
686            .entry(path)
687            .and_modify(|existing| existing.events.append(&mut file.events))
688            .or_insert(file);
689    }
690}
691
692/// Parse `git log --numstat --format=format:%at|%ae` output into events.
693fn parse_git_log_events(stdout: &str, root: &Path) -> ChurnEventState {
694    let now_secs = std::time::SystemTime::now()
695        .duration_since(std::time::UNIX_EPOCH)
696        .unwrap_or_default()
697        .as_secs();
698
699    let mut parser = GitLogEventParser::new(root, now_secs);
700
701    for line in stdout.lines() {
702        parser.consume_line(line);
703    }
704
705    parser.finish()
706}
707
708struct GitLogEventParser<'a> {
709    root: &'a Path,
710    now_secs: u64,
711    files: FxHashMap<PathBuf, FileEvents>,
712    author_pool: Vec<String>,
713    author_index: FxHashMap<String, u32>,
714    current_timestamp: Option<u64>,
715    current_author_idx: Option<u32>,
716}
717
718impl<'a> GitLogEventParser<'a> {
719    fn new(root: &'a Path, now_secs: u64) -> Self {
720        Self {
721            root,
722            now_secs,
723            files: FxHashMap::default(),
724            author_pool: Vec::new(),
725            author_index: FxHashMap::default(),
726            current_timestamp: None,
727            current_author_idx: None,
728        }
729    }
730
731    fn consume_line(&mut self, line: &str) {
732        let line = line.trim();
733        if line.is_empty() {
734            return;
735        }
736
737        if self.record_commit_header(line) {
738            return;
739        }
740        if self.record_legacy_timestamp(line) {
741            return;
742        }
743        self.record_numstat(line);
744    }
745
746    fn record_commit_header(&mut self, line: &str) -> bool {
747        let Some((ts_str, email)) = line.split_once('|') else {
748            return false;
749        };
750        let Ok(ts) = ts_str.parse::<u64>() else {
751            return false;
752        };
753
754        self.current_timestamp = Some(ts);
755        self.current_author_idx = Some(intern_author(
756            email,
757            &mut self.author_pool,
758            &mut self.author_index,
759        ));
760        true
761    }
762
763    fn record_legacy_timestamp(&mut self, line: &str) -> bool {
764        let Ok(ts) = line.parse::<u64>() else {
765            return false;
766        };
767
768        self.current_timestamp = Some(ts);
769        self.current_author_idx = None;
770        true
771    }
772
773    fn record_numstat(&mut self, line: &str) {
774        let Some((added, deleted, path)) = parse_numstat_line(line) else {
775            return;
776        };
777
778        let ts = self.current_timestamp.unwrap_or(self.now_secs);
779        self.files
780            .entry(self.root.join(path))
781            .or_insert_with(|| FileEvents { events: Vec::new() })
782            .events
783            .push(CachedCommitEvent {
784                timestamp: ts,
785                lines_added: added,
786                lines_deleted: deleted,
787                author_idx: self.current_author_idx,
788            });
789    }
790
791    fn finish(self) -> ChurnEventState {
792        ChurnEventState {
793            files: self.files,
794            author_pool: self.author_pool,
795        }
796    }
797}
798
799/// Aggregate one file's raw commit events into a [`FileChurn`], applying
800/// recency weighting, trend detection, and per-author accumulation.
801#[expect(
802    clippy::cast_possible_truncation,
803    reason = "commit count per file is bounded by git history depth"
804)]
805fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
806    let mut timestamps = Vec::with_capacity(file.events.len());
807    let mut weighted_commits = 0.0;
808    let mut lines_added = 0;
809    let mut lines_deleted = 0;
810    let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
811
812    for event in file.events {
813        timestamps.push(event.timestamp);
814        let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
815        let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
816        weighted_commits += weight;
817        lines_added += event.lines_added;
818        lines_deleted += event.lines_deleted;
819        accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
820    }
821
822    let commits = timestamps.len() as u32;
823    let trend = compute_trend(&timestamps);
824    for c in authors.values_mut() {
825        c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
826    }
827    FileChurn {
828        path,
829        commits,
830        weighted_commits: (weighted_commits * 100.0).round() / 100.0,
831        lines_added,
832        lines_deleted,
833        trend,
834        authors,
835    }
836}
837
838/// Fold a single commit's author contribution into the per-author map.
839fn accumulate_author(
840    authors: &mut FxHashMap<u32, AuthorContribution>,
841    author_idx: Option<u32>,
842    weight: f64,
843    timestamp: u64,
844) {
845    let Some(idx) = author_idx else {
846        return;
847    };
848    authors
849        .entry(idx)
850        .and_modify(|c| {
851            c.commits += 1;
852            c.weighted_commits += weight;
853            c.first_commit_ts = c.first_commit_ts.min(timestamp);
854            c.last_commit_ts = c.last_commit_ts.max(timestamp);
855        })
856        .or_insert(AuthorContribution {
857            commits: 1,
858            weighted_commits: weight,
859            first_commit_ts: timestamp,
860            last_commit_ts: timestamp,
861        });
862}
863
864/// Convert event-level churn state into the public aggregate result.
865fn build_churn_result(state: ChurnEventState, shallow_clone: bool) -> ChurnResult {
866    let now_secs = std::time::SystemTime::now()
867        .duration_since(std::time::UNIX_EPOCH)
868        .unwrap_or_default()
869        .as_secs();
870
871    let files = state
872        .files
873        .into_iter()
874        .map(|(path, file)| {
875            let churn = aggregate_file_churn(path.clone(), file, now_secs);
876            (path, churn)
877        })
878        .collect();
879
880    ChurnResult {
881        files,
882        shallow_clone,
883        author_pool: state.author_pool,
884    }
885}
886
887/// Parse `git log --numstat --format=format:%at|%ae` output.
888///
889/// Returns a per-file churn map plus the author email pool referenced by
890/// interned indices in [`FileChurn::authors`].
891#[cfg(test)]
892fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
893    let result = build_churn_result(parse_git_log_events(stdout, root), false);
894    (result.files, result.author_pool)
895}
896
897/// Intern an author email into the pool, returning its stable index.
898fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
899    if let Some(&idx) = index.get(email) {
900        return idx;
901    }
902    #[expect(
903        clippy::cast_possible_truncation,
904        reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
905    )]
906    let idx = pool.len() as u32;
907    let owned = email.to_string();
908    index.insert(owned.clone(), idx);
909    pool.push(owned);
910    idx
911}
912
913/// Parse a single numstat line: `"10\t5\tpath/to/file.ts"`.
914/// Binary files show as `"-\t-\tpath"`, skip those.
915fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
916    let mut parts = line.splitn(3, '\t');
917    let added_str = parts.next()?;
918    let deleted_str = parts.next()?;
919    let path = parts.next()?;
920
921    let added: u32 = added_str.parse().ok()?;
922    let deleted: u32 = deleted_str.parse().ok()?;
923
924    Some((added, deleted, path))
925}
926
927/// Compute churn trend by splitting commits into two temporal halves.
928///
929/// Finds the midpoint between the oldest and newest commit timestamps,
930/// then compares commit counts in each half:
931/// - Recent > 1.5× older → Accelerating
932/// - Recent < 0.67× older → Cooling
933/// - Otherwise → Stable
934fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
935    if timestamps.len() < 2 {
936        return ChurnTrend::Stable;
937    }
938
939    let min_ts = timestamps.iter().copied().min().unwrap_or(0);
940    let max_ts = timestamps.iter().copied().max().unwrap_or(0);
941
942    if max_ts == min_ts {
943        return ChurnTrend::Stable;
944    }
945
946    let midpoint = min_ts + (max_ts - min_ts) / 2;
947    let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
948    let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
949
950    if older < 1.0 {
951        return ChurnTrend::Stable;
952    }
953
954    let ratio = recent / older;
955    if ratio > 1.5 {
956        ChurnTrend::Accelerating
957    } else if ratio < 0.67 {
958        ChurnTrend::Cooling
959    } else {
960        ChurnTrend::Stable
961    }
962}
963
964fn is_iso_date(input: &str) -> bool {
965    input.len() == 10
966        && input.as_bytes().get(4) == Some(&b'-')
967        && input.as_bytes().get(7) == Some(&b'-')
968        && input[..4].bytes().all(|b| b.is_ascii_digit())
969        && input[5..7].bytes().all(|b| b.is_ascii_digit())
970        && input[8..10].bytes().all(|b| b.is_ascii_digit())
971}
972
973fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
974    let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
975        format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
976    })?;
977    if pos == 0 {
978        return Err(format!(
979            "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
980        ));
981    }
982    Ok((&input[..pos], &input[pos..]))
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    #[test]
990    fn parse_since_months_short() {
991        let d = parse_since("6m").unwrap();
992        assert_eq!(d.git_after, "6 months ago");
993        assert_eq!(d.display, "6 months");
994    }
995
996    #[test]
997    fn parse_since_months_long() {
998        let d = parse_since("6months").unwrap();
999        assert_eq!(d.git_after, "6 months ago");
1000        assert_eq!(d.display, "6 months");
1001    }
1002
1003    #[test]
1004    fn parse_since_days() {
1005        let d = parse_since("90d").unwrap();
1006        assert_eq!(d.git_after, "90 days ago");
1007        assert_eq!(d.display, "90 days");
1008    }
1009
1010    #[test]
1011    fn parse_since_year_singular() {
1012        let d = parse_since("1y").unwrap();
1013        assert_eq!(d.git_after, "1 year ago");
1014        assert_eq!(d.display, "1 year");
1015    }
1016
1017    #[test]
1018    fn parse_since_years_plural() {
1019        let d = parse_since("2years").unwrap();
1020        assert_eq!(d.git_after, "2 years ago");
1021        assert_eq!(d.display, "2 years");
1022    }
1023
1024    #[test]
1025    fn parse_since_weeks() {
1026        let d = parse_since("2w").unwrap();
1027        assert_eq!(d.git_after, "2 weeks ago");
1028        assert_eq!(d.display, "2 weeks");
1029    }
1030
1031    #[test]
1032    fn parse_since_iso_date() {
1033        let d = parse_since("2025-06-01").unwrap();
1034        assert_eq!(d.git_after, "2025-06-01");
1035        assert_eq!(d.display, "2025-06-01");
1036    }
1037
1038    #[test]
1039    fn parse_since_month_singular() {
1040        let d = parse_since("1month").unwrap();
1041        assert_eq!(d.display, "1 month");
1042    }
1043
1044    #[test]
1045    fn parse_since_day_singular() {
1046        let d = parse_since("1day").unwrap();
1047        assert_eq!(d.display, "1 day");
1048    }
1049
1050    #[test]
1051    fn parse_since_zero_rejected() {
1052        assert!(parse_since("0m").is_err());
1053    }
1054
1055    #[test]
1056    fn parse_since_no_unit_rejected() {
1057        assert!(parse_since("90").is_err());
1058    }
1059
1060    #[test]
1061    fn parse_since_unknown_unit_rejected() {
1062        assert!(parse_since("6x").is_err());
1063    }
1064
1065    #[test]
1066    fn parse_since_no_number_rejected() {
1067        assert!(parse_since("months").is_err());
1068    }
1069
1070    #[test]
1071    fn numstat_normal() {
1072        let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
1073        assert_eq!(a, 10);
1074        assert_eq!(d, 5);
1075        assert_eq!(p, "src/file.ts");
1076    }
1077
1078    #[test]
1079    fn numstat_binary_skipped() {
1080        assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
1081    }
1082
1083    #[test]
1084    fn numstat_zero_lines() {
1085        let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
1086        assert_eq!(a, 0);
1087        assert_eq!(d, 0);
1088        assert_eq!(p, "src/empty.ts");
1089    }
1090
1091    #[test]
1092    fn trend_empty_is_stable() {
1093        assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1094    }
1095
1096    #[test]
1097    fn trend_single_commit_is_stable() {
1098        assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1099    }
1100
1101    #[test]
1102    fn trend_accelerating() {
1103        let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1104        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1105    }
1106
1107    #[test]
1108    fn trend_cooling() {
1109        let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1110        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1111    }
1112
1113    #[test]
1114    fn trend_stable_even_distribution() {
1115        let timestamps = vec![100, 200, 300, 700, 800, 900];
1116        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1117    }
1118
1119    #[test]
1120    fn trend_same_timestamp_is_stable() {
1121        let timestamps = vec![500, 500, 500];
1122        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1123    }
1124
1125    #[test]
1126    fn iso_date_valid() {
1127        assert!(is_iso_date("2025-06-01"));
1128        assert!(is_iso_date("2025-12-31"));
1129    }
1130
1131    #[test]
1132    fn iso_date_with_time_rejected() {
1133        assert!(!is_iso_date("2025-06-01T00:00:00"));
1134    }
1135
1136    #[test]
1137    fn iso_date_invalid() {
1138        assert!(!is_iso_date("6months"));
1139        assert!(!is_iso_date("2025"));
1140        assert!(!is_iso_date("not-a-date"));
1141        assert!(!is_iso_date("abcd-ef-gh"));
1142    }
1143
1144    #[test]
1145    fn trend_display() {
1146        assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1147        assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1148        assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1149    }
1150
1151    #[test]
1152    fn parse_git_log_single_commit() {
1153        let root = Path::new("/project");
1154        let output = "1700000000\n10\t5\tsrc/index.ts\n";
1155        let (result, _) = parse_git_log(output, root);
1156        assert_eq!(result.len(), 1);
1157        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1158        assert_eq!(churn.commits, 1);
1159        assert_eq!(churn.lines_added, 10);
1160        assert_eq!(churn.lines_deleted, 5);
1161    }
1162
1163    #[test]
1164    fn parse_git_log_multiple_commits_same_file() {
1165        let root = Path::new("/project");
1166        let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
1167        let (result, _) = parse_git_log(output, root);
1168        assert_eq!(result.len(), 1);
1169        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1170        assert_eq!(churn.commits, 2);
1171        assert_eq!(churn.lines_added, 13);
1172        assert_eq!(churn.lines_deleted, 7);
1173    }
1174
1175    #[test]
1176    fn parse_git_log_multiple_files() {
1177        let root = Path::new("/project");
1178        let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
1179        let (result, _) = parse_git_log(output, root);
1180        assert_eq!(result.len(), 2);
1181        assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
1182        assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
1183    }
1184
1185    #[test]
1186    fn parse_git_log_empty_output() {
1187        let root = Path::new("/project");
1188        let (result, _) = parse_git_log("", root);
1189        assert!(result.is_empty());
1190    }
1191
1192    #[test]
1193    fn parse_git_log_skips_binary_files() {
1194        let root = Path::new("/project");
1195        let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
1196        let (result, _) = parse_git_log(output, root);
1197        assert_eq!(result.len(), 1);
1198        assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
1199    }
1200
1201    #[test]
1202    fn parse_git_log_weighted_commits_are_positive() {
1203        let root = Path::new("/project");
1204        let now_secs = std::time::SystemTime::now()
1205            .duration_since(std::time::UNIX_EPOCH)
1206            .unwrap()
1207            .as_secs();
1208        let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
1209        let (result, _) = parse_git_log(&output, root);
1210        let churn = &result[&PathBuf::from("/project/src/a.ts")];
1211        assert!(
1212            churn.weighted_commits > 0.0,
1213            "weighted_commits should be positive for recent commits"
1214        );
1215    }
1216
1217    #[test]
1218    fn trend_boundary_1_5x_ratio() {
1219        let timestamps = vec![100, 200, 600, 800, 1000];
1220        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1221    }
1222
1223    #[test]
1224    fn trend_just_above_1_5x() {
1225        let timestamps = vec![100, 600, 800, 1000];
1226        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1227    }
1228
1229    #[test]
1230    fn trend_boundary_0_67x_ratio() {
1231        let timestamps = vec![100, 200, 300, 600, 1000];
1232        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1233    }
1234
1235    #[test]
1236    fn trend_two_timestamps_different() {
1237        let timestamps = vec![100, 200];
1238        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1239    }
1240
1241    #[test]
1242    fn parse_since_week_singular() {
1243        let d = parse_since("1week").unwrap();
1244        assert_eq!(d.git_after, "1 week ago");
1245        assert_eq!(d.display, "1 week");
1246    }
1247
1248    #[test]
1249    fn parse_since_weeks_long() {
1250        let d = parse_since("3weeks").unwrap();
1251        assert_eq!(d.git_after, "3 weeks ago");
1252        assert_eq!(d.display, "3 weeks");
1253    }
1254
1255    #[test]
1256    fn parse_since_days_long() {
1257        let d = parse_since("30days").unwrap();
1258        assert_eq!(d.git_after, "30 days ago");
1259        assert_eq!(d.display, "30 days");
1260    }
1261
1262    #[test]
1263    fn parse_since_year_long() {
1264        let d = parse_since("1year").unwrap();
1265        assert_eq!(d.git_after, "1 year ago");
1266        assert_eq!(d.display, "1 year");
1267    }
1268
1269    #[test]
1270    fn parse_since_overflow_number_rejected() {
1271        let result = parse_since("99999999999999999999d");
1272        assert!(result.is_err());
1273        let err = result.unwrap_err();
1274        assert!(err.contains("invalid number"));
1275    }
1276
1277    #[test]
1278    fn parse_since_zero_days_rejected() {
1279        assert!(parse_since("0d").is_err());
1280    }
1281
1282    #[test]
1283    fn parse_since_zero_weeks_rejected() {
1284        assert!(parse_since("0w").is_err());
1285    }
1286
1287    #[test]
1288    fn parse_since_zero_years_rejected() {
1289        assert!(parse_since("0y").is_err());
1290    }
1291
1292    #[test]
1293    fn numstat_missing_path() {
1294        assert!(parse_numstat_line("10\t5").is_none());
1295    }
1296
1297    #[test]
1298    fn numstat_single_field() {
1299        assert!(parse_numstat_line("10").is_none());
1300    }
1301
1302    #[test]
1303    fn numstat_empty_string() {
1304        assert!(parse_numstat_line("").is_none());
1305    }
1306
1307    #[test]
1308    fn numstat_only_added_is_binary() {
1309        assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1310    }
1311
1312    #[test]
1313    fn numstat_only_deleted_is_binary() {
1314        assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1315    }
1316
1317    #[test]
1318    fn numstat_path_with_spaces() {
1319        let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1320        assert_eq!(a, 3);
1321        assert_eq!(d, 1);
1322        assert_eq!(p, "path with spaces/file.ts");
1323    }
1324
1325    #[test]
1326    fn numstat_large_numbers() {
1327        let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1328        assert_eq!(a, 9999);
1329        assert_eq!(d, 8888);
1330        assert_eq!(p, "src/big.ts");
1331    }
1332
1333    #[test]
1334    fn iso_date_wrong_separator_positions() {
1335        assert!(!is_iso_date("20-25-0601"));
1336        assert!(!is_iso_date("202506-01-"));
1337    }
1338
1339    #[test]
1340    fn iso_date_too_short() {
1341        assert!(!is_iso_date("2025-06-0"));
1342    }
1343
1344    #[test]
1345    fn iso_date_letters_in_day() {
1346        assert!(!is_iso_date("2025-06-ab"));
1347    }
1348
1349    #[test]
1350    fn iso_date_letters_in_month() {
1351        assert!(!is_iso_date("2025-ab-01"));
1352    }
1353
1354    #[test]
1355    fn split_number_unit_valid() {
1356        let (num, unit) = split_number_unit("42days").unwrap();
1357        assert_eq!(num, "42");
1358        assert_eq!(unit, "days");
1359    }
1360
1361    #[test]
1362    fn split_number_unit_single_digit() {
1363        let (num, unit) = split_number_unit("1m").unwrap();
1364        assert_eq!(num, "1");
1365        assert_eq!(unit, "m");
1366    }
1367
1368    #[test]
1369    fn split_number_unit_no_digits() {
1370        let err = split_number_unit("abc").unwrap_err();
1371        assert!(err.contains("must start with a number"));
1372    }
1373
1374    #[test]
1375    fn split_number_unit_no_unit() {
1376        let err = split_number_unit("123").unwrap_err();
1377        assert!(err.contains("requires a unit suffix"));
1378    }
1379
1380    #[test]
1381    fn parse_git_log_numstat_before_timestamp_uses_now() {
1382        let root = Path::new("/project");
1383        let output = "10\t5\tsrc/no_ts.ts\n";
1384        let (result, _) = parse_git_log(output, root);
1385        assert_eq!(result.len(), 1);
1386        let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1387        assert_eq!(churn.commits, 1);
1388        assert_eq!(churn.lines_added, 10);
1389        assert_eq!(churn.lines_deleted, 5);
1390        assert!(
1391            churn.weighted_commits > 0.9,
1392            "weight should be near 1.0 when timestamp defaults to now"
1393        );
1394    }
1395
1396    #[test]
1397    fn parse_git_log_whitespace_lines_ignored() {
1398        let root = Path::new("/project");
1399        let output = "  \n1700000000\n  \n10\t5\tsrc/a.ts\n  \n";
1400        let (result, _) = parse_git_log(output, root);
1401        assert_eq!(result.len(), 1);
1402    }
1403
1404    #[test]
1405    fn parse_git_log_trend_is_computed_per_file() {
1406        let root = Path::new("/project");
1407        let output = "\
14081000\n5\t1\tsrc/old.ts\n\
14092000\n3\t1\tsrc/old.ts\n\
14101000\n1\t0\tsrc/hot.ts\n\
14111800\n1\t0\tsrc/hot.ts\n\
14121900\n1\t0\tsrc/hot.ts\n\
14131950\n1\t0\tsrc/hot.ts\n\
14142000\n1\t0\tsrc/hot.ts\n";
1415        let (result, _) = parse_git_log(output, root);
1416        let old = &result[&PathBuf::from("/project/src/old.ts")];
1417        let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1418        assert_eq!(old.commits, 2);
1419        assert_eq!(hot.commits, 5);
1420        assert_eq!(hot.trend, ChurnTrend::Accelerating);
1421    }
1422
1423    #[test]
1424    fn parse_git_log_weighted_decay_for_old_commits() {
1425        let root = Path::new("/project");
1426        let now = std::time::SystemTime::now()
1427            .duration_since(std::time::UNIX_EPOCH)
1428            .unwrap()
1429            .as_secs();
1430        let old_ts = now - (180 * 86_400);
1431        let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1432        let (result, _) = parse_git_log(&output, root);
1433        let churn = &result[&PathBuf::from("/project/src/old.ts")];
1434        assert!(
1435            churn.weighted_commits < 0.5,
1436            "180-day-old commit should weigh ~0.25, got {}",
1437            churn.weighted_commits
1438        );
1439        assert!(
1440            churn.weighted_commits > 0.1,
1441            "180-day-old commit should weigh ~0.25, got {}",
1442            churn.weighted_commits
1443        );
1444    }
1445
1446    #[test]
1447    fn parse_git_log_path_stored_as_absolute() {
1448        let root = Path::new("/my/project");
1449        let output = "1700000000\n1\t0\tlib/utils.ts\n";
1450        let (result, _) = parse_git_log(output, root);
1451        let key = PathBuf::from("/my/project/lib/utils.ts");
1452        assert!(result.contains_key(&key));
1453        assert_eq!(result[&key].path, key);
1454    }
1455
1456    #[test]
1457    fn parse_git_log_weighted_commits_rounded() {
1458        let root = Path::new("/project");
1459        let now = std::time::SystemTime::now()
1460            .duration_since(std::time::UNIX_EPOCH)
1461            .unwrap()
1462            .as_secs();
1463        let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1464        let (result, _) = parse_git_log(&output, root);
1465        let churn = &result[&PathBuf::from("/project/src/a.ts")];
1466        let decimals = format!("{:.2}", churn.weighted_commits);
1467        assert_eq!(
1468            churn.weighted_commits.to_string().len(),
1469            decimals.len().min(churn.weighted_commits.to_string().len()),
1470            "weighted_commits should be rounded to at most 2 decimal places"
1471        );
1472    }
1473
1474    #[test]
1475    fn trend_serde_serialization() {
1476        assert_eq!(
1477            serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1478            "\"accelerating\""
1479        );
1480        assert_eq!(
1481            serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1482            "\"stable\""
1483        );
1484        assert_eq!(
1485            serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1486            "\"cooling\""
1487        );
1488    }
1489
1490    #[test]
1491    fn parse_git_log_extracts_author_email() {
1492        let root = Path::new("/project");
1493        let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1494        let (result, pool) = parse_git_log(output, root);
1495        assert_eq!(pool, vec!["alice@example.com".to_string()]);
1496        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1497        assert_eq!(churn.authors.len(), 1);
1498        let alice = &churn.authors[&0];
1499        assert_eq!(alice.commits, 1);
1500        assert_eq!(alice.first_commit_ts, 1_700_000_000);
1501        assert_eq!(alice.last_commit_ts, 1_700_000_000);
1502    }
1503
1504    #[test]
1505    fn parse_git_log_intern_dedupes_authors() {
1506        let root = Path::new("/project");
1507        let output = "\
15081700000000|alice@example.com
15091\t0\ta.ts
15101700100000|bob@example.com
15112\t1\tb.ts
15121700200000|alice@example.com
15133\t2\tc.ts
1514";
1515        let (_result, pool) = parse_git_log(output, root);
1516        assert_eq!(pool.len(), 2);
1517        assert!(pool.contains(&"alice@example.com".to_string()));
1518        assert!(pool.contains(&"bob@example.com".to_string()));
1519    }
1520
1521    #[test]
1522    fn parse_git_log_aggregates_per_author() {
1523        let root = Path::new("/project");
1524        let output = "\
15251700000000|alice@example.com
15261\t0\tsrc/index.ts
15271700100000|bob@example.com
15282\t0\tsrc/index.ts
15291700200000|alice@example.com
15301\t1\tsrc/index.ts
1531";
1532        let (result, pool) = parse_git_log(output, root);
1533        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1534        assert_eq!(churn.commits, 3);
1535        assert_eq!(churn.authors.len(), 2);
1536
1537        let alice_idx =
1538            u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1539        let alice = &churn.authors[&alice_idx];
1540        assert_eq!(alice.commits, 2);
1541        assert_eq!(alice.first_commit_ts, 1_700_000_000);
1542        assert_eq!(alice.last_commit_ts, 1_700_200_000);
1543    }
1544
1545    #[test]
1546    fn parse_git_log_legacy_bare_timestamp_still_parses() {
1547        let root = Path::new("/project");
1548        let output = "1700000000\n10\t5\tsrc/index.ts\n";
1549        let (result, pool) = parse_git_log(output, root);
1550        assert!(pool.is_empty());
1551        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1552        assert_eq!(churn.commits, 1);
1553        assert!(churn.authors.is_empty());
1554    }
1555
1556    #[test]
1557    fn intern_author_returns_existing_index() {
1558        let mut pool = Vec::new();
1559        let mut index = FxHashMap::default();
1560        let i1 = intern_author("alice@x", &mut pool, &mut index);
1561        let i2 = intern_author("alice@x", &mut pool, &mut index);
1562        assert_eq!(i1, i2);
1563        assert_eq!(pool.len(), 1);
1564    }
1565
1566    #[test]
1567    fn intern_author_assigns_sequential_indices() {
1568        let mut pool = Vec::new();
1569        let mut index = FxHashMap::default();
1570        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1571        assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1572        assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1573        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1574    }
1575
1576    fn git(root: &Path, args: &[&str]) {
1577        let status = std::process::Command::new("git")
1578            .args(args)
1579            .current_dir(root)
1580            .status()
1581            .expect("run git");
1582        assert!(status.success(), "git {args:?} failed");
1583    }
1584
1585    fn write(root: &Path, path: &str, contents: &str) {
1586        let path = root.join(path);
1587        std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1588        std::fs::write(path, contents).unwrap();
1589    }
1590
1591    #[test]
1592    fn cached_churn_merges_new_commits_after_head_advances() {
1593        let repo = tempfile::tempdir().expect("create repo");
1594        let root = repo.path();
1595        git(root, &["init"]);
1596        git(root, &["config", "user.email", "churn@example.test"]);
1597        git(root, &["config", "user.name", "Churn Test"]);
1598        git(root, &["config", "commit.gpgsign", "false"]);
1599
1600        write(root, "src/a.ts", "export const a = 1;\n");
1601        git(root, &["add", "."]);
1602        git(root, &["commit", "-m", "initial"]);
1603
1604        let since = parse_since("1y").unwrap();
1605        let cache = tempfile::tempdir().expect("create cache dir");
1606        let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1607        assert!(!cold_hit);
1608        let file = root.join("src/a.ts");
1609        assert_eq!(cold.files[&file].commits, 1);
1610
1611        let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1612        assert!(warm_hit);
1613
1614        write(
1615            root,
1616            "src/a.ts",
1617            "export const a = 1;\nexport const b = 2;\n",
1618        );
1619        git(root, &["add", "."]);
1620        git(root, &["commit", "-m", "update a"]);
1621        let head = get_head_sha(root).unwrap();
1622
1623        let (incremental, incremental_hit) =
1624            analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1625        assert!(incremental_hit);
1626        assert_eq!(incremental.files[&file].commits, 2);
1627
1628        let cache = load_churn_cache(cache.path(), &since.git_after).unwrap();
1629        assert_eq!(cache.last_indexed_sha, head);
1630    }
1631
1632    fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1633        let path = dir.join("churn.json");
1634        std::fs::write(&path, contents).unwrap();
1635        path
1636    }
1637
1638    #[test]
1639    fn churn_file_happy_path() {
1640        let dir = tempfile::tempdir().unwrap();
1641        let root = Path::new("/project");
1642        let path = write_churn_file(
1643            dir.path(),
1644            r#"{
1645              "schema": "fallow-churn/v1",
1646              "events": [
1647                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1648                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1649              ]
1650            }"#,
1651        );
1652        let result = analyze_churn_from_file(&path, root).unwrap();
1653        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1654        assert_eq!(churn.commits, 2);
1655        assert_eq!(churn.lines_added, 13);
1656        assert_eq!(churn.lines_deleted, 7);
1657        assert_eq!(churn.authors.len(), 2);
1658        assert!(result.author_pool.contains(&"alice@corp".to_string()));
1659        assert!(result.author_pool.contains(&"bob@corp".to_string()));
1660        assert!(!result.shallow_clone);
1661    }
1662
1663    #[test]
1664    fn churn_file_matches_git_parse() {
1665        // The same events fed via git numstat and via the JSON import must
1666        // produce identical aggregate churn: the import reuses
1667        // build_churn_result, so only the SOURCE differs.
1668        let dir = tempfile::tempdir().unwrap();
1669        let root = Path::new("/project");
1670        let git_output = "1700000000|alice@corp\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n\n1700100000|bob@corp\n3\t2\tsrc/a.ts\n";
1671        let (git_files, git_pool) = parse_git_log(git_output, root);
1672
1673        let path = write_churn_file(
1674            dir.path(),
1675            r#"{
1676              "schema": "fallow-churn/v1",
1677              "events": [
1678                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1679                { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
1680                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1681              ]
1682            }"#,
1683        );
1684        let imported = analyze_churn_from_file(&path, root).unwrap();
1685
1686        assert_eq!(git_pool, imported.author_pool, "author pools diverge");
1687        assert_eq!(git_files.len(), imported.files.len());
1688        for (file, git_churn) in &git_files {
1689            let imp = &imported.files[file];
1690            assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
1691            assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
1692            assert_eq!(
1693                git_churn.lines_deleted, imp.lines_deleted,
1694                "deleted for {file:?}"
1695            );
1696            assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
1697            assert_eq!(
1698                git_churn.authors.len(),
1699                imp.authors.len(),
1700                "authors for {file:?}"
1701            );
1702            assert!(
1703                (git_churn.weighted_commits - imp.weighted_commits).abs() < 0.02,
1704                "weighted_commits for {file:?}: {} vs {}",
1705                git_churn.weighted_commits,
1706                imp.weighted_commits
1707            );
1708        }
1709    }
1710
1711    #[test]
1712    fn churn_file_empty_events_is_valid() {
1713        let dir = tempfile::tempdir().unwrap();
1714        let path = write_churn_file(
1715            dir.path(),
1716            r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1717        );
1718        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1719        assert!(result.files.is_empty());
1720        assert!(result.author_pool.is_empty());
1721    }
1722
1723    #[test]
1724    fn churn_file_missing_events_key_is_valid() {
1725        let dir = tempfile::tempdir().unwrap();
1726        let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1727        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1728        assert!(result.files.is_empty());
1729    }
1730
1731    #[test]
1732    fn churn_file_bad_schema_rejected() {
1733        let dir = tempfile::tempdir().unwrap();
1734        let path = write_churn_file(
1735            dir.path(),
1736            r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1737        );
1738        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1739        assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
1740    }
1741
1742    #[test]
1743    fn churn_file_malformed_json_rejected() {
1744        let dir = tempfile::tempdir().unwrap();
1745        let path = write_churn_file(dir.path(), "{ not json");
1746        assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
1747    }
1748
1749    #[test]
1750    fn churn_file_missing_file_rejected() {
1751        let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
1752            .unwrap_err();
1753        assert!(err.contains("failed to read churn file"), "{err}");
1754    }
1755
1756    #[test]
1757    fn churn_file_empty_path_rejected() {
1758        let dir = tempfile::tempdir().unwrap();
1759        let path = write_churn_file(
1760            dir.path(),
1761            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "  ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1762        );
1763        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1764        assert!(err.contains("empty path"), "{err}");
1765    }
1766
1767    #[test]
1768    fn churn_file_millisecond_timestamp_rejected() {
1769        let dir = tempfile::tempdir().unwrap();
1770        // 1700000000000 is milliseconds; ~52000 years in the future as seconds.
1771        let path = write_churn_file(
1772            dir.path(),
1773            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
1774        );
1775        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1776        assert!(err.contains("milliseconds"), "{err}");
1777    }
1778
1779    #[test]
1780    fn churn_file_missing_author_contributes_no_signal() {
1781        let dir = tempfile::tempdir().unwrap();
1782        let path = write_churn_file(
1783            dir.path(),
1784            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1785        );
1786        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1787        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1788        assert_eq!(churn.commits, 1);
1789        assert!(churn.authors.is_empty());
1790        assert!(result.author_pool.is_empty());
1791    }
1792
1793    #[test]
1794    fn churn_file_empty_author_string_treated_as_absent() {
1795        let dir = tempfile::tempdir().unwrap();
1796        let path = write_churn_file(
1797            dir.path(),
1798            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": "  ", "added": 1, "deleted": 0 } ] }"#,
1799        );
1800        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1801        assert!(result.author_pool.is_empty());
1802    }
1803
1804    #[test]
1805    fn churn_file_unknown_fields_ignored() {
1806        // Extra keys (including the reserved `commit`) are accepted and ignored,
1807        // so a wrapper carrying extra metadata stays forward-compatible.
1808        let dir = tempfile::tempdir().unwrap();
1809        let path = write_churn_file(
1810            dir.path(),
1811            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" } ] }"#,
1812        );
1813        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1814        assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
1815    }
1816
1817    #[test]
1818    fn churn_file_backslash_paths_normalized() {
1819        let dir = tempfile::tempdir().unwrap();
1820        let path = write_churn_file(
1821            dir.path(),
1822            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1823        );
1824        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1825        assert!(
1826            result
1827                .files
1828                .contains_key(&PathBuf::from("/project/src/a.ts"))
1829        );
1830    }
1831}