Skip to main content

fallow_engine/
churn.rs

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