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