Skip to main content

fallow_core/
churn.rs

1//! Git churn analysis for hotspot detection.
2//!
3//! Shells out to `git log` to collect per-file change history, then computes
4//! recency-weighted churn scores and trend indicators.
5
6use rustc_hash::FxHashMap;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use serde::Serialize;
11
12/// Number of seconds in one day.
13const SECS_PER_DAY: f64 = 86_400.0;
14
15/// Recency weight half-life in days. A commit from 90 days ago counts half
16/// as much as today's commit; 180 days ago counts 25%.
17const HALF_LIFE_DAYS: f64 = 90.0;
18
19/// Parsed duration for the `--since` flag.
20#[derive(Debug, Clone)]
21pub struct SinceDuration {
22    /// Value to pass to `git log --after` (e.g., `"6 months ago"` or `"2025-06-01"`).
23    pub git_after: String,
24    /// Human-readable display string (e.g., `"6 months"`).
25    pub display: String,
26}
27
28/// Churn trend indicator based on comparing recent vs older halves of the analysis period.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, bitcode::Encode, bitcode::Decode)]
30#[serde(rename_all = "snake_case")]
31pub enum ChurnTrend {
32    /// Recent half has >1.5× the commits of the older half.
33    Accelerating,
34    /// Churn is roughly stable between halves.
35    Stable,
36    /// Recent half has <0.67× the commits of the older half.
37    Cooling,
38}
39
40impl std::fmt::Display for ChurnTrend {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Accelerating => write!(f, "accelerating"),
44            Self::Stable => write!(f, "stable"),
45            Self::Cooling => write!(f, "cooling"),
46        }
47    }
48}
49
50/// Per-file churn data collected from git history.
51#[derive(Debug, Clone)]
52pub struct FileChurn {
53    /// Absolute file path.
54    pub path: PathBuf,
55    /// Total number of commits touching this file in the analysis window.
56    pub commits: u32,
57    /// Recency-weighted commit count (exponential decay, half-life 90 days).
58    pub weighted_commits: f64,
59    /// Total lines added across all commits.
60    pub lines_added: u32,
61    /// Total lines deleted across all commits.
62    pub lines_deleted: u32,
63    /// Churn trend: accelerating, stable, or cooling.
64    pub trend: ChurnTrend,
65}
66
67/// Result of churn analysis.
68pub struct ChurnResult {
69    /// Per-file churn data, keyed by absolute path.
70    pub files: FxHashMap<PathBuf, FileChurn>,
71    /// Whether the repository is a shallow clone.
72    pub shallow_clone: bool,
73}
74
75/// Parse a `--since` value into a git-compatible duration.
76///
77/// Accepts:
78/// - Durations: `6m`, `6months`, `90d`, `90days`, `1y`, `1year`, `2w`, `2weeks`
79/// - ISO dates: `2025-06-01`
80///
81/// # Errors
82///
83/// Returns an error if the input is not a recognized duration format or ISO date,
84/// the numeric part is invalid, or the duration is zero.
85pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
86    // Try ISO date first (YYYY-MM-DD)
87    if is_iso_date(input) {
88        return Ok(SinceDuration {
89            git_after: input.to_string(),
90            display: input.to_string(),
91        });
92    }
93
94    // Parse duration: number + unit
95    let (num_str, unit) = split_number_unit(input)?;
96    let num: u64 = num_str
97        .parse()
98        .map_err(|_| format!("invalid number in --since: {input}"))?;
99
100    if num == 0 {
101        return Err("--since duration must be greater than 0".to_string());
102    }
103
104    match unit {
105        "d" | "day" | "days" => {
106            let s = if num == 1 { "" } else { "s" };
107            Ok(SinceDuration {
108                git_after: format!("{num} day{s} ago"),
109                display: format!("{num} day{s}"),
110            })
111        }
112        "w" | "week" | "weeks" => {
113            let s = if num == 1 { "" } else { "s" };
114            Ok(SinceDuration {
115                git_after: format!("{num} week{s} ago"),
116                display: format!("{num} week{s}"),
117            })
118        }
119        "m" | "month" | "months" => {
120            let s = if num == 1 { "" } else { "s" };
121            Ok(SinceDuration {
122                git_after: format!("{num} month{s} ago"),
123                display: format!("{num} month{s}"),
124            })
125        }
126        "y" | "year" | "years" => {
127            let s = if num == 1 { "" } else { "s" };
128            Ok(SinceDuration {
129                git_after: format!("{num} year{s} ago"),
130                display: format!("{num} year{s}"),
131            })
132        }
133        _ => Err(format!(
134            "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
135        )),
136    }
137}
138
139/// Analyze git churn for files in the given root directory.
140///
141/// Returns `None` if git is not available or the directory is not a git repository.
142pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
143    let shallow = is_shallow_clone(root);
144
145    let output = Command::new("git")
146        .args([
147            "log",
148            "--numstat",
149            "--no-merges",
150            "--no-renames",
151            "--format=format:%at",
152            &format!("--after={}", since.git_after),
153        ])
154        .current_dir(root)
155        .output();
156
157    let output = match output {
158        Ok(o) => o,
159        Err(e) => {
160            tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
161            return None;
162        }
163    };
164
165    if !output.status.success() {
166        let stderr = String::from_utf8_lossy(&output.stderr);
167        tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
168        return None;
169    }
170
171    let stdout = String::from_utf8_lossy(&output.stdout);
172    let files = parse_git_log(&stdout, root);
173
174    Some(ChurnResult {
175        files,
176        shallow_clone: shallow,
177    })
178}
179
180/// Check if the repository is a shallow clone.
181#[must_use]
182pub fn is_shallow_clone(root: &Path) -> bool {
183    Command::new("git")
184        .args(["rev-parse", "--is-shallow-repository"])
185        .current_dir(root)
186        .output()
187        .map(|o| {
188            String::from_utf8_lossy(&o.stdout)
189                .trim()
190                .eq_ignore_ascii_case("true")
191        })
192        .unwrap_or(false)
193}
194
195/// Check if the directory is inside a git repository.
196#[must_use]
197pub fn is_git_repo(root: &Path) -> bool {
198    Command::new("git")
199        .args(["rev-parse", "--git-dir"])
200        .current_dir(root)
201        .stdout(std::process::Stdio::null())
202        .stderr(std::process::Stdio::null())
203        .status()
204        .map(|s| s.success())
205        .unwrap_or(false)
206}
207
208// ── Churn cache ──────────────────────────────────────────────────
209
210/// Maximum size of a churn cache file (16 MB).
211const MAX_CHURN_CACHE_SIZE: usize = 16 * 1024 * 1024;
212
213/// Serializable per-file churn entry for the disk cache.
214#[derive(bitcode::Encode, bitcode::Decode)]
215struct CachedFileChurn {
216    path: String,
217    commits: u32,
218    weighted_commits: f64,
219    lines_added: u32,
220    lines_deleted: u32,
221    trend: ChurnTrend,
222}
223
224/// Cached churn data keyed by HEAD SHA and since string.
225#[derive(bitcode::Encode, bitcode::Decode)]
226struct ChurnCache {
227    head_sha: String,
228    git_after: String,
229    files: Vec<CachedFileChurn>,
230    shallow_clone: bool,
231}
232
233/// Get the full HEAD SHA for cache keying.
234fn get_head_sha(root: &Path) -> Option<String> {
235    Command::new("git")
236        .args(["rev-parse", "HEAD"])
237        .current_dir(root)
238        .output()
239        .ok()
240        .filter(|o| o.status.success())
241        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
242}
243
244/// Try to load churn data from disk cache. Returns `None` on cache miss.
245fn load_churn_cache(cache_dir: &Path, head_sha: &str, git_after: &str) -> Option<ChurnResult> {
246    let cache_file = cache_dir.join("churn.bin");
247    let data = std::fs::read(&cache_file).ok()?;
248    if data.len() > MAX_CHURN_CACHE_SIZE {
249        return None;
250    }
251    let cache: ChurnCache = bitcode::decode(&data).ok()?;
252    if cache.head_sha != head_sha || cache.git_after != git_after {
253        return None;
254    }
255    let mut files = FxHashMap::default();
256    for entry in cache.files {
257        let path = PathBuf::from(&entry.path);
258        files.insert(
259            path.clone(),
260            FileChurn {
261                path,
262                commits: entry.commits,
263                weighted_commits: entry.weighted_commits,
264                lines_added: entry.lines_added,
265                lines_deleted: entry.lines_deleted,
266                trend: entry.trend,
267            },
268        );
269    }
270    Some(ChurnResult {
271        files,
272        shallow_clone: cache.shallow_clone,
273    })
274}
275
276/// Save churn data to disk cache.
277fn save_churn_cache(cache_dir: &Path, head_sha: &str, git_after: &str, result: &ChurnResult) {
278    let files: Vec<CachedFileChurn> = result
279        .files
280        .values()
281        .map(|f| CachedFileChurn {
282            path: f.path.to_string_lossy().to_string(),
283            commits: f.commits,
284            weighted_commits: f.weighted_commits,
285            lines_added: f.lines_added,
286            lines_deleted: f.lines_deleted,
287            trend: f.trend,
288        })
289        .collect();
290    let cache = ChurnCache {
291        head_sha: head_sha.to_string(),
292        git_after: git_after.to_string(),
293        files,
294        shallow_clone: result.shallow_clone,
295    };
296    let _ = std::fs::create_dir_all(cache_dir);
297    let data = bitcode::encode(&cache);
298    // Write to temp file then rename for atomic update (avoids partial reads by concurrent processes)
299    let tmp = cache_dir.join("churn.bin.tmp");
300    if std::fs::write(&tmp, data).is_ok() {
301        let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
302    }
303}
304
305/// Analyze churn with disk caching. Uses cached result when HEAD SHA and
306/// since duration match. On cache miss, runs `git log` and saves the result.
307///
308/// Returns `(ChurnResult, bool)` where the bool indicates whether the cache was hit.
309/// Returns `None` if git analysis fails.
310pub fn analyze_churn_cached(
311    root: &Path,
312    since: &SinceDuration,
313    cache_dir: &Path,
314    no_cache: bool,
315) -> Option<(ChurnResult, bool)> {
316    let head_sha = get_head_sha(root)?;
317
318    if !no_cache && let Some(cached) = load_churn_cache(cache_dir, &head_sha, &since.git_after) {
319        return Some((cached, true));
320    }
321
322    let result = analyze_churn(root, since)?;
323
324    if !no_cache {
325        save_churn_cache(cache_dir, &head_sha, &since.git_after, &result);
326    }
327
328    Some((result, false))
329}
330
331// ── Internal ──────────────────────────────────────────────────────
332
333/// Intermediate per-file accumulator during git log parsing.
334struct FileAccum {
335    /// Commit timestamps (epoch seconds) for trend computation.
336    commit_timestamps: Vec<u64>,
337    /// Recency-weighted commit sum.
338    weighted_commits: f64,
339    lines_added: u32,
340    lines_deleted: u32,
341}
342
343/// Parse `git log --numstat --format=format:%at` output.
344#[expect(
345    clippy::cast_possible_truncation,
346    reason = "commit count per file is bounded by git history depth"
347)]
348fn parse_git_log(stdout: &str, root: &Path) -> FxHashMap<PathBuf, FileChurn> {
349    let now_secs = std::time::SystemTime::now()
350        .duration_since(std::time::UNIX_EPOCH)
351        .unwrap_or_default()
352        .as_secs();
353
354    let mut accum: FxHashMap<PathBuf, FileAccum> = FxHashMap::default();
355    let mut current_timestamp: Option<u64> = None;
356
357    for line in stdout.lines() {
358        let line = line.trim();
359        if line.is_empty() {
360            continue;
361        }
362
363        // Try to parse as epoch timestamp (from %at format)
364        if let Ok(ts) = line.parse::<u64>() {
365            current_timestamp = Some(ts);
366            continue;
367        }
368
369        // Try to parse as numstat line: "10\t5\tpath/to/file"
370        if let Some((added, deleted, path)) = parse_numstat_line(line) {
371            let abs_path = root.join(path);
372            let ts = current_timestamp.unwrap_or(now_secs);
373            let age_days = (now_secs.saturating_sub(ts)) as f64 / SECS_PER_DAY;
374            let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
375
376            let entry = accum.entry(abs_path).or_insert_with(|| FileAccum {
377                commit_timestamps: Vec::new(),
378                weighted_commits: 0.0,
379                lines_added: 0,
380                lines_deleted: 0,
381            });
382            entry.commit_timestamps.push(ts);
383            entry.weighted_commits += weight;
384            entry.lines_added += added;
385            entry.lines_deleted += deleted;
386        }
387    }
388
389    // Convert accumulators to FileChurn with trend computation
390    accum
391        .into_iter()
392        .map(|(path, acc)| {
393            let commits = acc.commit_timestamps.len() as u32;
394            let trend = compute_trend(&acc.commit_timestamps);
395            let churn = FileChurn {
396                path: path.clone(),
397                commits,
398                weighted_commits: (acc.weighted_commits * 100.0).round() / 100.0,
399                lines_added: acc.lines_added,
400                lines_deleted: acc.lines_deleted,
401                trend,
402            };
403            (path, churn)
404        })
405        .collect()
406}
407
408/// Parse a single numstat line: `"10\t5\tpath/to/file.ts"`.
409/// Binary files show as `"-\t-\tpath"` — skip those.
410fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
411    let mut parts = line.splitn(3, '\t');
412    let added_str = parts.next()?;
413    let deleted_str = parts.next()?;
414    let path = parts.next()?;
415
416    // Binary files show "-" for added/deleted — skip them
417    let added: u32 = added_str.parse().ok()?;
418    let deleted: u32 = deleted_str.parse().ok()?;
419
420    Some((added, deleted, path))
421}
422
423/// Compute churn trend by splitting commits into two temporal halves.
424///
425/// Finds the midpoint between the oldest and newest commit timestamps,
426/// then compares commit counts in each half:
427/// - Recent > 1.5× older → Accelerating
428/// - Recent < 0.67× older → Cooling
429/// - Otherwise → Stable
430fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
431    if timestamps.len() < 2 {
432        return ChurnTrend::Stable;
433    }
434
435    let min_ts = timestamps.iter().copied().min().unwrap_or(0);
436    let max_ts = timestamps.iter().copied().max().unwrap_or(0);
437
438    if max_ts == min_ts {
439        return ChurnTrend::Stable;
440    }
441
442    let midpoint = min_ts + (max_ts - min_ts) / 2;
443    let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
444    let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
445
446    if older < 1.0 {
447        return ChurnTrend::Stable;
448    }
449
450    let ratio = recent / older;
451    if ratio > 1.5 {
452        ChurnTrend::Accelerating
453    } else if ratio < 0.67 {
454        ChurnTrend::Cooling
455    } else {
456        ChurnTrend::Stable
457    }
458}
459
460fn is_iso_date(input: &str) -> bool {
461    input.len() == 10
462        && input.as_bytes().get(4) == Some(&b'-')
463        && input.as_bytes().get(7) == Some(&b'-')
464        && input[..4].bytes().all(|b| b.is_ascii_digit())
465        && input[5..7].bytes().all(|b| b.is_ascii_digit())
466        && input[8..10].bytes().all(|b| b.is_ascii_digit())
467}
468
469fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
470    let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
471        format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
472    })?;
473    if pos == 0 {
474        return Err(format!(
475            "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
476        ));
477    }
478    Ok((&input[..pos], &input[pos..]))
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    // ── parse_since ──────────────────────────────────────────────
486
487    #[test]
488    fn parse_since_months_short() {
489        let d = parse_since("6m").unwrap();
490        assert_eq!(d.git_after, "6 months ago");
491        assert_eq!(d.display, "6 months");
492    }
493
494    #[test]
495    fn parse_since_months_long() {
496        let d = parse_since("6months").unwrap();
497        assert_eq!(d.git_after, "6 months ago");
498        assert_eq!(d.display, "6 months");
499    }
500
501    #[test]
502    fn parse_since_days() {
503        let d = parse_since("90d").unwrap();
504        assert_eq!(d.git_after, "90 days ago");
505        assert_eq!(d.display, "90 days");
506    }
507
508    #[test]
509    fn parse_since_year_singular() {
510        let d = parse_since("1y").unwrap();
511        assert_eq!(d.git_after, "1 year ago");
512        assert_eq!(d.display, "1 year");
513    }
514
515    #[test]
516    fn parse_since_years_plural() {
517        let d = parse_since("2years").unwrap();
518        assert_eq!(d.git_after, "2 years ago");
519        assert_eq!(d.display, "2 years");
520    }
521
522    #[test]
523    fn parse_since_weeks() {
524        let d = parse_since("2w").unwrap();
525        assert_eq!(d.git_after, "2 weeks ago");
526        assert_eq!(d.display, "2 weeks");
527    }
528
529    #[test]
530    fn parse_since_iso_date() {
531        let d = parse_since("2025-06-01").unwrap();
532        assert_eq!(d.git_after, "2025-06-01");
533        assert_eq!(d.display, "2025-06-01");
534    }
535
536    #[test]
537    fn parse_since_month_singular() {
538        let d = parse_since("1month").unwrap();
539        assert_eq!(d.display, "1 month");
540    }
541
542    #[test]
543    fn parse_since_day_singular() {
544        let d = parse_since("1day").unwrap();
545        assert_eq!(d.display, "1 day");
546    }
547
548    #[test]
549    fn parse_since_zero_rejected() {
550        assert!(parse_since("0m").is_err());
551    }
552
553    #[test]
554    fn parse_since_no_unit_rejected() {
555        assert!(parse_since("90").is_err());
556    }
557
558    #[test]
559    fn parse_since_unknown_unit_rejected() {
560        assert!(parse_since("6x").is_err());
561    }
562
563    #[test]
564    fn parse_since_no_number_rejected() {
565        assert!(parse_since("months").is_err());
566    }
567
568    // ── parse_numstat_line ───────────────────────────────────────
569
570    #[test]
571    fn numstat_normal() {
572        let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
573        assert_eq!(a, 10);
574        assert_eq!(d, 5);
575        assert_eq!(p, "src/file.ts");
576    }
577
578    #[test]
579    fn numstat_binary_skipped() {
580        assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
581    }
582
583    #[test]
584    fn numstat_zero_lines() {
585        let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
586        assert_eq!(a, 0);
587        assert_eq!(d, 0);
588        assert_eq!(p, "src/empty.ts");
589    }
590
591    // ── compute_trend ────────────────────────────────────────────
592
593    #[test]
594    fn trend_empty_is_stable() {
595        assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
596    }
597
598    #[test]
599    fn trend_single_commit_is_stable() {
600        assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
601    }
602
603    #[test]
604    fn trend_accelerating() {
605        // 2 old commits, 5 recent commits
606        let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
607        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
608    }
609
610    #[test]
611    fn trend_cooling() {
612        // 5 old commits, 2 recent commits
613        let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
614        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
615    }
616
617    #[test]
618    fn trend_stable_even_distribution() {
619        // 3 old commits, 3 recent commits → ratio = 1.0 → stable
620        let timestamps = vec![100, 200, 300, 700, 800, 900];
621        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
622    }
623
624    #[test]
625    fn trend_same_timestamp_is_stable() {
626        let timestamps = vec![500, 500, 500];
627        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
628    }
629
630    // ── is_iso_date ──────────────────────────────────────────────
631
632    #[test]
633    fn iso_date_valid() {
634        assert!(is_iso_date("2025-06-01"));
635        assert!(is_iso_date("2025-12-31"));
636    }
637
638    #[test]
639    fn iso_date_with_time_rejected() {
640        // Only exact YYYY-MM-DD (10 chars) is accepted
641        assert!(!is_iso_date("2025-06-01T00:00:00"));
642    }
643
644    #[test]
645    fn iso_date_invalid() {
646        assert!(!is_iso_date("6months"));
647        assert!(!is_iso_date("2025"));
648        assert!(!is_iso_date("not-a-date"));
649        assert!(!is_iso_date("abcd-ef-gh"));
650    }
651
652    // ── Display ──────────────────────────────────────────────────
653
654    #[test]
655    fn trend_display() {
656        assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
657        assert_eq!(ChurnTrend::Stable.to_string(), "stable");
658        assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
659    }
660
661    // ── parse_git_log ───────────────────────────────────────────
662
663    #[test]
664    fn parse_git_log_single_commit() {
665        let root = Path::new("/project");
666        let output = "1700000000\n10\t5\tsrc/index.ts\n";
667        let result = parse_git_log(output, root);
668        assert_eq!(result.len(), 1);
669        let churn = &result[&PathBuf::from("/project/src/index.ts")];
670        assert_eq!(churn.commits, 1);
671        assert_eq!(churn.lines_added, 10);
672        assert_eq!(churn.lines_deleted, 5);
673    }
674
675    #[test]
676    fn parse_git_log_multiple_commits_same_file() {
677        let root = Path::new("/project");
678        let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
679        let result = parse_git_log(output, root);
680        assert_eq!(result.len(), 1);
681        let churn = &result[&PathBuf::from("/project/src/index.ts")];
682        assert_eq!(churn.commits, 2);
683        assert_eq!(churn.lines_added, 13);
684        assert_eq!(churn.lines_deleted, 7);
685    }
686
687    #[test]
688    fn parse_git_log_multiple_files() {
689        let root = Path::new("/project");
690        let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
691        let result = parse_git_log(output, root);
692        assert_eq!(result.len(), 2);
693        assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
694        assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
695    }
696
697    #[test]
698    fn parse_git_log_empty_output() {
699        let root = Path::new("/project");
700        let result = parse_git_log("", root);
701        assert!(result.is_empty());
702    }
703
704    #[test]
705    fn parse_git_log_skips_binary_files() {
706        let root = Path::new("/project");
707        let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
708        let result = parse_git_log(output, root);
709        assert_eq!(result.len(), 1);
710        assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
711    }
712
713    #[test]
714    fn parse_git_log_weighted_commits_are_positive() {
715        let root = Path::new("/project");
716        // Use a timestamp near "now" to ensure weight doesn't decay to zero
717        let now_secs = std::time::SystemTime::now()
718            .duration_since(std::time::UNIX_EPOCH)
719            .unwrap()
720            .as_secs();
721        let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
722        let result = parse_git_log(&output, root);
723        let churn = &result[&PathBuf::from("/project/src/a.ts")];
724        assert!(
725            churn.weighted_commits > 0.0,
726            "weighted_commits should be positive for recent commits"
727        );
728    }
729
730    // ── compute_trend edge cases ─────────────────────────────────
731
732    #[test]
733    fn trend_boundary_1_5x_ratio() {
734        // Exactly 1.5x ratio (3 recent : 2 old) → boundary between stable and accelerating
735        // midpoint = 100 + (1000-100)/2 = 550
736        // old: 100, 200 (2 timestamps <= 550)
737        // recent: 600, 800, 1000 (3 timestamps > 550)
738        // ratio = 3/2 = 1.5 — NOT > 1.5, so stable
739        let timestamps = vec![100, 200, 600, 800, 1000];
740        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
741    }
742
743    #[test]
744    fn trend_just_above_1_5x() {
745        // midpoint = 100 + (1000-100)/2 = 550
746        // old: 100 (1 timestamp <= 550)
747        // recent: 600, 800, 1000 (3 timestamps > 550)
748        // ratio = 3/1 = 3.0 → accelerating
749        let timestamps = vec![100, 600, 800, 1000];
750        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
751    }
752
753    #[test]
754    fn trend_boundary_0_67x_ratio() {
755        // Exactly 0.67x ratio → boundary between cooling and stable
756        // midpoint = 100 + (1000-100)/2 = 550
757        // old: 100, 200, 300 (3 timestamps <= 550)
758        // recent: 600, 1000 (2 timestamps > 550)
759        // ratio = 2/3 = 0.666... < 0.67 → cooling
760        let timestamps = vec![100, 200, 300, 600, 1000];
761        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
762    }
763
764    #[test]
765    fn trend_two_timestamps_different() {
766        // Only 2 timestamps: midpoint = 100 + (200-100)/2 = 150
767        // old: 100 (1 timestamp <= 150)
768        // recent: 200 (1 timestamp > 150)
769        // ratio = 1/1 = 1.0 → stable
770        let timestamps = vec![100, 200];
771        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
772    }
773
774    // ── parse_since additional coverage ─────────────────────────
775
776    #[test]
777    fn parse_since_week_singular() {
778        let d = parse_since("1week").unwrap();
779        assert_eq!(d.git_after, "1 week ago");
780        assert_eq!(d.display, "1 week");
781    }
782
783    #[test]
784    fn parse_since_weeks_long() {
785        let d = parse_since("3weeks").unwrap();
786        assert_eq!(d.git_after, "3 weeks ago");
787        assert_eq!(d.display, "3 weeks");
788    }
789
790    #[test]
791    fn parse_since_days_long() {
792        let d = parse_since("30days").unwrap();
793        assert_eq!(d.git_after, "30 days ago");
794        assert_eq!(d.display, "30 days");
795    }
796
797    #[test]
798    fn parse_since_year_long() {
799        let d = parse_since("1year").unwrap();
800        assert_eq!(d.git_after, "1 year ago");
801        assert_eq!(d.display, "1 year");
802    }
803
804    #[test]
805    fn parse_since_overflow_number_rejected() {
806        // Number too large for u64
807        let result = parse_since("99999999999999999999d");
808        assert!(result.is_err());
809        let err = result.unwrap_err();
810        assert!(err.contains("invalid number"));
811    }
812
813    #[test]
814    fn parse_since_zero_days_rejected() {
815        assert!(parse_since("0d").is_err());
816    }
817
818    #[test]
819    fn parse_since_zero_weeks_rejected() {
820        assert!(parse_since("0w").is_err());
821    }
822
823    #[test]
824    fn parse_since_zero_years_rejected() {
825        assert!(parse_since("0y").is_err());
826    }
827
828    // ── parse_numstat_line additional coverage ──────────────────
829
830    #[test]
831    fn numstat_missing_path() {
832        // Only two tab-separated fields, no path
833        assert!(parse_numstat_line("10\t5").is_none());
834    }
835
836    #[test]
837    fn numstat_single_field() {
838        assert!(parse_numstat_line("10").is_none());
839    }
840
841    #[test]
842    fn numstat_empty_string() {
843        assert!(parse_numstat_line("").is_none());
844    }
845
846    #[test]
847    fn numstat_only_added_is_binary() {
848        // Added is "-" but deleted is numeric
849        assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
850    }
851
852    #[test]
853    fn numstat_only_deleted_is_binary() {
854        // Added is numeric but deleted is "-"
855        assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
856    }
857
858    #[test]
859    fn numstat_path_with_spaces() {
860        let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
861        assert_eq!(a, 3);
862        assert_eq!(d, 1);
863        assert_eq!(p, "path with spaces/file.ts");
864    }
865
866    #[test]
867    fn numstat_large_numbers() {
868        let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
869        assert_eq!(a, 9999);
870        assert_eq!(d, 8888);
871        assert_eq!(p, "src/big.ts");
872    }
873
874    // ── is_iso_date additional coverage ─────────────────────────
875
876    #[test]
877    fn iso_date_wrong_separator_positions() {
878        // Dashes in wrong positions
879        assert!(!is_iso_date("20-25-0601"));
880        assert!(!is_iso_date("202506-01-"));
881    }
882
883    #[test]
884    fn iso_date_too_short() {
885        assert!(!is_iso_date("2025-06-0"));
886    }
887
888    #[test]
889    fn iso_date_letters_in_day() {
890        assert!(!is_iso_date("2025-06-ab"));
891    }
892
893    #[test]
894    fn iso_date_letters_in_month() {
895        assert!(!is_iso_date("2025-ab-01"));
896    }
897
898    // ── split_number_unit additional coverage ───────────────────
899
900    #[test]
901    fn split_number_unit_valid() {
902        let (num, unit) = split_number_unit("42days").unwrap();
903        assert_eq!(num, "42");
904        assert_eq!(unit, "days");
905    }
906
907    #[test]
908    fn split_number_unit_single_digit() {
909        let (num, unit) = split_number_unit("1m").unwrap();
910        assert_eq!(num, "1");
911        assert_eq!(unit, "m");
912    }
913
914    #[test]
915    fn split_number_unit_no_digits() {
916        let err = split_number_unit("abc").unwrap_err();
917        assert!(err.contains("must start with a number"));
918    }
919
920    #[test]
921    fn split_number_unit_no_unit() {
922        let err = split_number_unit("123").unwrap_err();
923        assert!(err.contains("requires a unit suffix"));
924    }
925
926    // ── parse_git_log additional coverage ───────────────────────
927
928    #[test]
929    fn parse_git_log_numstat_before_timestamp_uses_now() {
930        let root = Path::new("/project");
931        // No timestamp line before the numstat line
932        let output = "10\t5\tsrc/no_ts.ts\n";
933        let result = parse_git_log(output, root);
934        assert_eq!(result.len(), 1);
935        let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
936        assert_eq!(churn.commits, 1);
937        assert_eq!(churn.lines_added, 10);
938        assert_eq!(churn.lines_deleted, 5);
939        // Without a timestamp, it falls back to now_secs, so weight should be ~1.0
940        assert!(
941            churn.weighted_commits > 0.9,
942            "weight should be near 1.0 when timestamp defaults to now"
943        );
944    }
945
946    #[test]
947    fn parse_git_log_whitespace_lines_ignored() {
948        let root = Path::new("/project");
949        let output = "  \n1700000000\n  \n10\t5\tsrc/a.ts\n  \n";
950        let result = parse_git_log(output, root);
951        assert_eq!(result.len(), 1);
952    }
953
954    #[test]
955    fn parse_git_log_trend_is_computed_per_file() {
956        let root = Path::new("/project");
957        // Two commits far apart for one file, recent-heavy for another
958        let output = "\
9591000\n5\t1\tsrc/old.ts\n\
9602000\n3\t1\tsrc/old.ts\n\
9611000\n1\t0\tsrc/hot.ts\n\
9621800\n1\t0\tsrc/hot.ts\n\
9631900\n1\t0\tsrc/hot.ts\n\
9641950\n1\t0\tsrc/hot.ts\n\
9652000\n1\t0\tsrc/hot.ts\n";
966        let result = parse_git_log(output, root);
967        let old = &result[&PathBuf::from("/project/src/old.ts")];
968        let hot = &result[&PathBuf::from("/project/src/hot.ts")];
969        assert_eq!(old.commits, 2);
970        assert_eq!(hot.commits, 5);
971        // hot.ts has 4 recent vs 1 old => accelerating
972        assert_eq!(hot.trend, ChurnTrend::Accelerating);
973    }
974
975    #[test]
976    fn parse_git_log_weighted_decay_for_old_commits() {
977        let root = Path::new("/project");
978        let now = std::time::SystemTime::now()
979            .duration_since(std::time::UNIX_EPOCH)
980            .unwrap()
981            .as_secs();
982        // One commit from 180 days ago (two half-lives) should weigh ~0.25
983        let old_ts = now - (180 * 86_400);
984        let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
985        let result = parse_git_log(&output, root);
986        let churn = &result[&PathBuf::from("/project/src/old.ts")];
987        assert!(
988            churn.weighted_commits < 0.5,
989            "180-day-old commit should weigh ~0.25, got {}",
990            churn.weighted_commits
991        );
992        assert!(
993            churn.weighted_commits > 0.1,
994            "180-day-old commit should weigh ~0.25, got {}",
995            churn.weighted_commits
996        );
997    }
998
999    #[test]
1000    fn parse_git_log_path_stored_as_absolute() {
1001        let root = Path::new("/my/project");
1002        let output = "1700000000\n1\t0\tlib/utils.ts\n";
1003        let result = parse_git_log(output, root);
1004        let key = PathBuf::from("/my/project/lib/utils.ts");
1005        assert!(result.contains_key(&key));
1006        assert_eq!(result[&key].path, key);
1007    }
1008
1009    #[test]
1010    fn parse_git_log_weighted_commits_rounded() {
1011        let root = Path::new("/project");
1012        let now = std::time::SystemTime::now()
1013            .duration_since(std::time::UNIX_EPOCH)
1014            .unwrap()
1015            .as_secs();
1016        // A commit right now should weigh exactly 1.00
1017        let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1018        let result = parse_git_log(&output, root);
1019        let churn = &result[&PathBuf::from("/project/src/a.ts")];
1020        // Weighted commits are rounded to 2 decimal places
1021        let decimals = format!("{:.2}", churn.weighted_commits);
1022        assert_eq!(
1023            churn.weighted_commits.to_string().len(),
1024            decimals.len().min(churn.weighted_commits.to_string().len()),
1025            "weighted_commits should be rounded to at most 2 decimal places"
1026        );
1027    }
1028
1029    // ── ChurnTrend serde ────────────────────────────────────────
1030
1031    #[test]
1032    fn trend_serde_serialization() {
1033        assert_eq!(
1034            serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1035            "\"accelerating\""
1036        );
1037        assert_eq!(
1038            serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1039            "\"stable\""
1040        );
1041        assert_eq!(
1042            serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1043            "\"cooling\""
1044        );
1045    }
1046}