Skip to main content

hotspots_core/
snapshot.rs

1//! Snapshot container and persistence
2//!
3//! Wraps analysis output in immutable, commit-scoped snapshots and persists them.
4//!
5//! Global invariants enforced:
6//! - Snapshots are immutable (never overwrite existing snapshots)
7//! - Commit hash is the sole identity (filename equals commit SHA)
8//! - Byte-for-byte deterministic serialization
9//! - Paths normalized to `/` (forward slashes only)
10//! - ASCII lexical ordering (not locale-aware)
11
12use crate::git::GitContext;
13use crate::language::Language;
14use crate::report::{FunctionRiskReport, MetricsReport};
15use crate::risk::RiskBand;
16use anyhow::{Context, Result};
17use rayon::prelude::*;
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22#[cfg(test)]
23use crate::report::RiskReport;
24
25/// Controls how touch metrics (git churn/recency) are populated.
26#[derive(Debug, Clone, PartialEq)]
27pub enum TouchMode {
28    /// File-level batching: one git call per unique file, result applied to all
29    /// functions in that file. Fast (O(files) git calls), but imprecise when a
30    /// file contains functions with very different churn histories.
31    File,
32    /// Per-function `git log -L`: one subprocess per function. Accurate, but
33    /// O(functions) cold-start cost (~9 ms each) which OOM-kills on large repos.
34    PerFunction,
35    /// File-level first; per-function only for functions in files whose
36    /// touch_count_30d meets or exceeds `threshold`. Bounds subprocess count to
37    /// the functions that actually benefit from precision.
38    Hybrid { threshold: usize },
39}
40
41/// Schema version for snapshots.
42/// v1: LRS + basic metrics only
43/// v2: adds LOC, git churn/touch, call graph, activity risk, percentiles, summary
44pub const SNAPSHOT_SCHEMA_VERSION: u32 = 2;
45const SNAPSHOT_SCHEMA_MIN_VERSION: u32 = 1;
46
47/// Schema version for index
48const INDEX_SCHEMA_VERSION: u32 = 1;
49
50/// Commit information in snapshot
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub struct CommitInfo {
54    pub sha: String,
55    pub parents: Vec<String>,
56    pub timestamp: i64,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub branch: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub message: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub author: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub is_fix_commit: Option<bool>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub is_revert_commit: Option<bool>,
67    #[serde(skip_serializing_if = "Vec::is_empty", default)]
68    pub ticket_ids: Vec<String>,
69}
70
71impl From<GitContext> for CommitInfo {
72    fn from(ctx: GitContext) -> Self {
73        CommitInfo {
74            sha: ctx.head_sha,
75            parents: ctx.parent_shas,
76            timestamp: ctx.timestamp,
77            branch: ctx.branch,
78            message: ctx.message,
79            author: ctx.author,
80            is_fix_commit: ctx.is_fix_commit,
81            is_revert_commit: ctx.is_revert_commit,
82            ticket_ids: ctx.ticket_ids,
83        }
84    }
85}
86
87/// Analysis metadata in snapshot
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub struct AnalysisInfo {
91    pub scope: String,
92    #[serde(rename = "tool_version")]
93    pub tool_version: String,
94}
95
96/// Churn metrics for a file/function
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "snake_case")]
99pub struct ChurnMetrics {
100    pub lines_added: usize,
101    pub lines_deleted: usize,
102    pub net_change: i64,
103}
104
105/// Per-function percentile flags based on activity_risk
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107#[serde(rename_all = "snake_case")]
108pub struct PercentileFlags {
109    pub is_top_10_pct: bool,
110    pub is_top_5_pct: bool,
111    pub is_top_1_pct: bool,
112}
113
114/// Call graph metrics for a function
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116#[serde(rename_all = "snake_case")]
117pub struct CallGraphMetrics {
118    pub fan_in: usize,
119    pub fan_out: usize,
120    pub pagerank: f64,
121    pub betweenness: f64,
122    pub scc_id: usize,
123    pub scc_size: usize,
124    #[serde(default)]
125    pub is_entrypoint: bool,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub dependency_depth: Option<usize>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub neighbor_churn: Option<usize>,
130}
131
132/// Function entry in snapshot
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134#[serde(rename_all = "snake_case")]
135pub struct FunctionSnapshot {
136    pub function_id: String,
137    pub file: String,
138    pub line: u32,
139    pub language: Language,
140    pub metrics: MetricsReport,
141    pub lrs: f64,
142    pub band: RiskBand,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub suppression_reason: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub churn: Option<ChurnMetrics>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub touch_count_30d: Option<usize>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub days_since_last_change: Option<u32>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub callgraph: Option<CallGraphMetrics>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub activity_risk: Option<f64>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub risk_factors: Option<crate::scoring::RiskFactors>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub percentile: Option<PercentileFlags>,
159    /// Primary driving dimension label (e.g. "high_complexity", "high_churn_low_cc").
160    /// Populated by the enricher after activity_risk is computed.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub driver: Option<String>,
163    /// Near-miss detail for composite functions: top dimensions that almost fired,
164    /// with their percentile rank. E.g. "cc (P72), nd (P68)".
165    /// None for non-composite labels or when no metric is near-threshold.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub driver_detail: Option<String>,
168    /// Triage quadrant. Values: "fire", "debt", "watch", "ok".
169    /// fire  = high/critical + active (touches > p50 or changed ≤30d)
170    /// debt  = high/critical + not active
171    /// watch = moderate/low  + active
172    /// ok    = everything else
173    /// Populated by the enricher after driver labels. None before enrichment.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub quadrant: Option<String>,
176    /// Pattern labels derived from metrics. Tier 1 patterns are carried forward
177    /// from the analysis report; Tier 2 are added by `populate_patterns()`.
178    #[serde(skip_serializing_if = "Vec::is_empty", default)]
179    pub patterns: Vec<String>,
180    /// Full pattern detail for `--explain-patterns`. None unless requested.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub pattern_details: Option<Vec<crate::patterns::PatternDetail>>,
183    /// Nearest manifest-root ancestor relative to repo root (e.g. "packages/next",
184    /// "turbopack/crates/turbopack-ecmascript"). Empty string when the file belongs
185    /// to the repo root itself. None when no repo_root was provided at snapshot time.
186    ///
187    /// Detected from: package.json, Cargo.toml, pyproject.toml, go.mod,
188    /// setup.py, setup.cfg, Gemfile, build.gradle, pom.xml, mix.exs.
189    /// Populated in `Snapshot::from_reports` when `repo_root` is available.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub subsystem: Option<String>,
192    /// Number of distinct commit authors for this file in the last 90 days.
193    /// File-level (shared by all functions in the same file).
194    /// Populated by `Snapshot::populate_authors_90d()`.
195    /// Used as a training feature in `hotspots train` — author diversity is a
196    /// non-windowed ownership signal that doesn't share the temporal leakage
197    /// of touch_count_30d.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub authors_90d: Option<u32>,
200    /// Directed coupling score for this file.
201    /// dc(i) = Σ_j [co_changes(i,j) × hotspots_score(j)] / commit_count(i)
202    /// Weights co-change relationships by partner defect density.
203    /// Variant (full-history or 365d) selected by the Jaccard screener (F38/F39).
204    /// File-level (shared by all functions in the same file).
205    /// Populated by `Snapshot::populate_directed_coupling()`.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub directed_coupling: Option<f64>,
208    /// Jaccard label stability score for this repo (F38 screener).
209    /// Overlap of top-20% defect files between train and holdout windows.
210    /// Repo-level: same value for all functions. None when DC was not computed.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub jaccard_label_stability: Option<f64>,
213    /// Count of commits touching this file whose message matches fix-keyword conventions.
214    /// Full-history count (no time window) — avoids temporal leakage when used as a
215    /// training feature with a post-cutoff label window (F54).
216    /// File-level (shared by all functions in the same file).
217    /// Populated by `Snapshot::populate_convention_bug_fix_count()`.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub convention_bug_fix_count: Option<u32>,
220    /// Sliding 30-day-window max/mean commit ratio for this file (F93).
221    /// Always at least 1.0; higher values indicate a burst of frantic commit activity.
222    /// File-level (shared by all functions in the same file).
223    /// Populated by `Snapshot::populate_burst_score()`.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub burst_score: Option<f64>,
226    /// Total commits touching this file across full history (F62/F63 cold-start signal).
227    /// File-level (shared by all functions in the same file).
228    /// Populated by `Snapshot::populate_history_signals()`.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub commit_count: Option<u32>,
231    /// Distinct commit-author emails touching this file across full history.
232    /// File-level (shared by all functions in the same file).
233    /// Populated by `Snapshot::populate_history_signals()`.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub author_count: Option<u32>,
236    /// Shannon entropy of the commit-author distribution for this file.
237    /// File-level (shared by all functions in the same file).
238    /// Populated by `Snapshot::populate_history_signals()`.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub author_entropy: Option<f64>,
241    /// Fraction of this file's commits where it was the only file touched.
242    /// File-level (shared by all functions in the same file).
243    /// Populated by `Snapshot::populate_history_signals()`.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub isolation_rate: Option<f64>,
246    /// Days between this file's first and last commit.
247    /// File-level (shared by all functions in the same file).
248    /// Populated by `Snapshot::populate_history_signals()`.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub age_days: Option<f64>,
251    /// Days since this file's most recent commit.
252    /// File-level (shared by all functions in the same file).
253    /// Populated by `Snapshot::populate_history_signals()`.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub last_touch_days: Option<f64>,
256    /// Human-readable explanation phrase derived from feature percentiles within this repo.
257    /// Populated by the `--explain` path after the trained ranker is applied.
258    /// None unless `--explain` was passed and a trained ranker is present.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub explanation: Option<String>,
261}
262
263/// Risk distribution by band
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
265#[serde(rename_all = "snake_case")]
266pub struct BandStats {
267    pub count: usize,
268    pub sum_risk: f64,
269}
270
271/// Call graph statistics for the whole repo
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273#[serde(rename_all = "snake_case")]
274pub struct CallGraphStats {
275    pub total_edges: usize,
276    pub avg_fan_in: f64,
277    pub scc_count: usize,
278    pub largest_scc_size: usize,
279    /// True when betweenness was computed via k-source approximation rather than
280    /// exact Brandes. Consumers can use this to annotate displayed values.
281    #[serde(default)]
282    pub betweenness_approximate: bool,
283}
284
285/// Repo-level summary statistics
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287#[serde(rename_all = "snake_case")]
288pub struct SnapshotSummary {
289    pub total_functions: usize,
290    pub total_activity_risk: f64,
291    pub top_1_pct_share: f64,
292    pub top_5_pct_share: f64,
293    pub top_10_pct_share: f64,
294    pub by_band: std::collections::BTreeMap<String, BandStats>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub call_graph: Option<CallGraphStats>,
297}
298
299/// Complete snapshot for a commit
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301#[serde(rename_all = "snake_case")]
302pub struct Snapshot {
303    #[serde(rename = "schema_version")]
304    pub schema_version: u32,
305    pub commit: CommitInfo,
306    pub analysis: AnalysisInfo,
307    pub functions: Vec<FunctionSnapshot>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub summary: Option<SnapshotSummary>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub aggregates: Option<crate::aggregates::SnapshotAggregates>,
312}
313
314/// Index entry for a commit
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
316#[serde(rename_all = "snake_case")]
317pub struct IndexEntry {
318    pub sha: String,
319    pub parents: Vec<String>,
320    pub timestamp: i64,
321}
322
323/// Index containing all tracked commits
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325#[serde(rename_all = "snake_case")]
326pub struct Index {
327    #[serde(rename = "schema_version")]
328    pub schema_version: u32,
329    /// Compaction level (0 = full snapshots, 1 = deltas only, 2 = band transitions only)
330    /// None means Level 0 (full snapshots) - for backward compatibility
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub compaction_level: Option<u32>,
333    pub commits: Vec<IndexEntry>,
334}
335
336/// Manifest filenames that mark a subsystem (package/crate/module) boundary.
337const SUBSYSTEM_MANIFESTS: &[&str] = &[
338    "package.json",
339    "Cargo.toml",
340    "pyproject.toml",
341    "go.mod",
342    "setup.py",
343    "setup.cfg",
344    "Gemfile",
345    "build.gradle",
346    "pom.xml",
347    "mix.exs",
348];
349
350/// Directories that are never subsystem roots (generated, vendored, hidden).
351const SKIP_DIRS: &[&str] = &[
352    "node_modules",
353    ".git",
354    "__pycache__",
355    ".venv",
356    "venv",
357    "dist",
358    "build",
359    "out",
360    "target",
361];
362
363/// Build a cache of `absolute_dir → subsystem_rel_path` for every directory
364/// in `repo_root` that contains a manifest file. Skips hidden and vendor dirs.
365///
366/// The cache maps an absolute directory path to its subsystem label (the
367/// relative path from `repo_root`, using `/` separators, or `""` for root).
368fn build_subsystem_cache(repo_root: &Path) -> HashMap<PathBuf, String> {
369    let mut cache: HashMap<PathBuf, String> = HashMap::new();
370    collect_manifest_dirs(repo_root, repo_root, &mut cache);
371    cache
372}
373
374fn collect_manifest_dirs(dir: &Path, repo_root: &Path, cache: &mut HashMap<PathBuf, String>) {
375    // Check if this directory contains any manifest file
376    for manifest in SUBSYSTEM_MANIFESTS {
377        if dir.join(manifest).exists() {
378            let rel = dir
379                .strip_prefix(repo_root)
380                .unwrap_or(dir)
381                .to_string_lossy()
382                .replace('\\', "/");
383            cache.entry(dir.to_path_buf()).or_insert(rel);
384            break;
385        }
386    }
387
388    // Recurse into subdirectories, skipping hidden and vendor dirs
389    let entries = match std::fs::read_dir(dir) {
390        Ok(e) => e,
391        Err(_) => return,
392    };
393    for entry in entries.flatten() {
394        let ft = match entry.file_type() {
395            Ok(ft) => ft,
396            Err(_) => continue,
397        };
398        if !ft.is_dir() {
399            continue;
400        }
401        let name = entry.file_name();
402        let name_str = name.to_string_lossy();
403        if name_str.starts_with('.') || SKIP_DIRS.contains(&name_str.as_ref()) {
404            continue;
405        }
406        collect_manifest_dirs(&entry.path(), repo_root, cache);
407    }
408}
409
410/// Given the absolute path of a source file and a pre-built subsystem cache,
411/// return the label of the deepest manifest-containing ancestor directory.
412///
413/// Returns `Some("")` when the file belongs to the repo root itself (a
414/// manifest exists at `repo_root` but nowhere deeper on this path), and
415/// `None` when no manifest ancestor exists at all.
416fn subsystem_for_file(
417    abs_file: &Path,
418    repo_root: &Path,
419    cache: &HashMap<PathBuf, String>,
420) -> Option<String> {
421    let mut best: Option<&str> = None;
422    let mut best_depth = 0usize;
423
424    let mut dir = abs_file.parent()?;
425    loop {
426        if let Some(label) = cache.get(dir) {
427            let depth = dir.components().count();
428            if depth >= best_depth {
429                best = Some(label.as_str());
430                best_depth = depth;
431            }
432        }
433        match dir.parent() {
434            Some(p) if p != dir && dir.starts_with(repo_root) => dir = p,
435            _ => break,
436        }
437    }
438
439    best.map(|s| s.to_string())
440}
441
442impl Snapshot {
443    /// Create a new snapshot from git context and function reports
444    ///
445    /// # Arguments
446    ///
447    /// * `git_context` - Git context for the current commit
448    /// * `reports` - Function risk reports from analysis
449    ///
450    /// # Function ID Format
451    ///
452    /// Function ID is `<relative_file_path>::<symbol>` where:
453    /// - `relative_file_path` is normalized to use `/` separators
454    /// - `symbol` is the function name (or `<anonymous>` for anonymous functions)
455    pub fn new(git_context: GitContext, reports: Vec<FunctionRiskReport>) -> Self {
456        // Normalize paths and build function snapshots
457        let mut functions: Vec<FunctionSnapshot> = reports
458            .into_iter()
459            .map(|report| {
460                // Normalize file path to use `/` separators
461                let normalized_file = report.file.replace('\\', "/");
462
463                // Extract function name for function_id
464                // Use the function name from report, or derive from file/line if needed
465                let function_symbol = if report.function.starts_with("<anonymous>") {
466                    "<anonymous>"
467                } else {
468                    &report.function
469                };
470
471                // Build function_id: <relative_file_path>::<symbol>
472                let function_id = format!("{}::{}", normalized_file, function_symbol);
473
474                FunctionSnapshot {
475                    function_id,
476                    file: normalized_file,
477                    line: report.line,
478                    language: report.language,
479                    metrics: report.metrics,
480                    lrs: report.lrs,
481                    band: report.band,
482                    suppression_reason: report.suppression_reason,
483                    churn: None, // Churn will be populated separately if available
484                    touch_count_30d: None, // Touch count will be populated separately if available
485                    days_since_last_change: None, // Days since last change will be populated separately if available
486                    callgraph: None, // Call graph metrics will be populated separately if available
487                    activity_risk: None,
488                    risk_factors: None,
489                    percentile: None,
490                    driver: None,
491                    driver_detail: None,
492                    quadrant: None,
493                    patterns: report.patterns,
494                    pattern_details: None,
495                    subsystem: None,
496                    authors_90d: None,
497                    directed_coupling: None,
498                    jaccard_label_stability: None,
499                    convention_bug_fix_count: None,
500                    burst_score: None,
501                    commit_count: None,
502                    author_count: None,
503                    author_entropy: None,
504                    isolation_rate: None,
505                    age_days: None,
506                    last_touch_days: None,
507                    explanation: None,
508                }
509            })
510            .collect();
511
512        // Sort functions deterministically by function_id (ASCII lexical ordering)
513        functions.sort_by(|a, b| a.function_id.cmp(&b.function_id));
514
515        Snapshot {
516            schema_version: SNAPSHOT_SCHEMA_VERSION,
517            commit: CommitInfo::from(git_context),
518            analysis: AnalysisInfo {
519                scope: "full".to_string(),
520                tool_version: env!("CARGO_PKG_VERSION").to_string(),
521            },
522            functions,
523            summary: None,
524            aggregates: None, // Aggregates are computed on-demand, not stored
525        }
526    }
527
528    /// Populate churn metrics from git data
529    ///
530    /// Maps file-level churn to all functions in each file.
531    /// Files not in the churn map will have churn remain as None.
532    ///
533    /// # Arguments
534    ///
535    /// * `file_churns` - Map from file path to churn metrics
536    pub fn populate_churn(
537        &mut self,
538        file_churns: &std::collections::HashMap<String, crate::git::FileChurn>,
539    ) {
540        for function in &mut self.functions {
541            // Normalize file path for lookup (already normalized in constructor)
542            let file_path = &function.file;
543
544            if let Some(file_churn) = file_churns.get(file_path) {
545                let net_change = file_churn.lines_added as i64 - file_churn.lines_deleted as i64;
546                function.churn = Some(ChurnMetrics {
547                    lines_added: file_churn.lines_added,
548                    lines_deleted: file_churn.lines_deleted,
549                    net_change,
550                });
551            }
552        }
553    }
554
555    /// Populate `authors_90d` for every function.
556    ///
557    /// Runs one `git log` subprocess per unique file in the snapshot to count
558    /// distinct commit-author emails in the last 90 days.  File-level: all
559    /// functions in the same file receive the same value.  Functions in files
560    /// with no matching history receive `Some(0)`.
561    ///
562    /// Errors are soft: a failed git call leaves the affected file's functions
563    /// with `authors_90d = None`.
564    pub fn populate_authors_90d(&mut self, repo_root: &Path) {
565        use std::collections::{HashMap, HashSet};
566        use std::process::Command;
567
568        // Build unique-file → function-indices map
569        let mut file_indices: HashMap<String, Vec<usize>> = HashMap::new();
570        for (i, func) in self.functions.iter().enumerate() {
571            file_indices.entry(func.file.clone()).or_default().push(i);
572        }
573
574        for (abs_path, indices) in &file_indices {
575            let rel = if let Ok(r) = std::path::Path::new(abs_path).strip_prefix(repo_root) {
576                r.to_string_lossy().to_string()
577            } else {
578                abs_path.clone()
579            };
580
581            let out = Command::new("git")
582                .args(["log", "--after=90.days.ago", "--format=%ae", "--", &rel])
583                .current_dir(repo_root)
584                .output();
585
586            let count = match out {
587                Ok(o) if o.status.success() => {
588                    let text = String::from_utf8_lossy(&o.stdout);
589                    text.lines()
590                        .filter(|l| !l.is_empty())
591                        .collect::<HashSet<_>>()
592                        .len() as u32
593                }
594                _ => continue, // leave as None on error
595            };
596
597            for &i in indices {
598                self.functions[i].authors_90d = Some(count);
599            }
600        }
601    }
602
603    /// Populate `convention_bug_fix_count` for every function.
604    ///
605    /// Counts full-history commits per file whose message matches fix-keyword conventions
606    /// (same regex as `detect_fix_commit`). File-level: all functions in the same file
607    /// receive the same value. Functions in files with no history receive `Some(0)`.
608    ///
609    /// Errors are soft: a failed git call leaves the affected file's functions with
610    /// `convention_bug_fix_count = None`.
611    pub fn populate_convention_bug_fix_count(&mut self, repo_root: &Path) {
612        use std::collections::HashMap;
613        use std::process::Command;
614
615        let mut file_indices: HashMap<String, Vec<usize>> = HashMap::new();
616        for (i, func) in self.functions.iter().enumerate() {
617            file_indices.entry(func.file.clone()).or_default().push(i);
618        }
619
620        for (abs_path, indices) in &file_indices {
621            let rel = if let Ok(r) = std::path::Path::new(abs_path).strip_prefix(repo_root) {
622                r.to_string_lossy().to_string()
623            } else {
624                abs_path.clone()
625            };
626
627            let out = Command::new("git")
628                .args(["log", "--format=%s", "--", &rel])
629                .current_dir(repo_root)
630                .output();
631
632            let count = match out {
633                Ok(o) if o.status.success() => {
634                    let text = String::from_utf8_lossy(&o.stdout);
635                    text.lines()
636                        .filter(|l| crate::git::detect_fix_commit(l))
637                        .count() as u32
638                }
639                _ => continue,
640            };
641
642            for &i in indices {
643                self.functions[i].convention_bug_fix_count = Some(count);
644            }
645        }
646    }
647
648    /// Populate `burst_score` for every function (F93).
649    ///
650    /// Runs a single `git log --name-only` pass over the whole repository via
651    /// `history_signals::load_commits_with_author` (the same loader
652    /// `populate_history_signals` uses) and reads `burst_score` off the
653    /// resulting per-file `HistorySignals` — no separate `git log` subprocess.
654    /// File-level: all functions in the same file receive the same value.
655    /// Files with fewer than 2 commits receive `Some(1.0)` (no burst signal).
656    ///
657    /// Errors are soft: if the git call fails, all functions keep `burst_score = None`.
658    pub fn populate_burst_score(&mut self, repo_root: &Path) {
659        use std::collections::HashMap;
660
661        let git_dir = crate::coupling::git_dir(repo_root);
662        let commits = crate::history_signals::load_commits_with_author(&git_dir);
663        if commits.is_empty() {
664            return;
665        }
666        let signals = crate::history_signals::compute_history_signals(&commits);
667
668        let mut file_indices: HashMap<String, Vec<usize>> = HashMap::new();
669        for (i, func) in self.functions.iter().enumerate() {
670            let rel = if let Ok(r) = std::path::Path::new(&func.file).strip_prefix(repo_root) {
671                r.to_string_lossy().to_string()
672            } else {
673                func.file.clone()
674            };
675            file_indices.entry(rel).or_default().push(i);
676        }
677
678        for (rel, indices) in &file_indices {
679            let Some(s) = signals.get(rel) else {
680                continue;
681            };
682            for &i in indices {
683                self.functions[i].burst_score = Some(s.burst_score);
684            }
685        }
686    }
687
688    /// Populate `commit_count`, `author_count`, `author_entropy`, `isolation_rate`,
689    /// `age_days`, and `last_touch_days` for every function (F63 signal-porting
690    /// prerequisite). `burst_score` is not touched here — it is populated separately
691    /// by `populate_burst_score` (F93) and reused as-is for cold-start features.
692    ///
693    /// Runs a single `git log --name-only` pass over the whole repository via
694    /// `history_signals::load_commits_with_author`, then computes all six signals
695    /// per file in one aggregation pass. File-level: all functions in the same file
696    /// receive the same values. Files with no matching commit history keep all six
697    /// fields as `None`.
698    ///
699    /// Errors are soft: if the git call fails, all functions keep these fields as `None`.
700    pub fn populate_history_signals(&mut self, repo_root: &Path) {
701        let git_dir = crate::coupling::git_dir(repo_root);
702        let commits = crate::history_signals::load_commits_with_author(&git_dir);
703        if commits.is_empty() {
704            return;
705        }
706        let signals = crate::history_signals::compute_history_signals(&commits);
707
708        let mut file_indices: HashMap<String, Vec<usize>> = HashMap::new();
709        for (i, func) in self.functions.iter().enumerate() {
710            let rel = if let Ok(r) = std::path::Path::new(&func.file).strip_prefix(repo_root) {
711                r.to_string_lossy().to_string()
712            } else {
713                func.file.clone()
714            };
715            file_indices.entry(rel).or_default().push(i);
716        }
717
718        for (rel, indices) in &file_indices {
719            let Some(s) = signals.get(rel) else {
720                continue;
721            };
722            for &i in indices {
723                self.functions[i].commit_count = Some(s.commit_count);
724                self.functions[i].author_count = Some(s.author_count);
725                self.functions[i].author_entropy = Some(s.author_entropy);
726                self.functions[i].isolation_rate = Some(s.isolation_rate);
727                self.functions[i].age_days = Some(s.age_days);
728                self.functions[i].last_touch_days = Some(s.last_touch_days);
729            }
730        }
731    }
732
733    /// Populate `directed_coupling` and `jaccard_label_stability` for every function.
734    ///
735    /// Calls `crate::coupling::compute_directed_coupling_for_repo` which:
736    /// 1. Loads full commit history via git log.
737    /// 2. Computes Jaccard label stability to select dc_full vs dc_365d.
738    /// 3. Computes directed coupling scores keyed by file path.
739    ///
740    /// All functions in the same file receive the same file-level DC score.
741    /// Functions in files with no co-change history receive `directed_coupling = None`.
742    /// `jaccard_label_stability` is set repo-wide (same value for all functions).
743    ///
744    /// `partner_scores` is typically `file → hotspots_score` from the snapshot itself
745    /// (built before calling this method, or passed in from cheap_signals.csv).
746    pub fn populate_directed_coupling(
747        &mut self,
748        repo_root: &Path,
749        partner_scores: &std::collections::HashMap<String, f64>,
750    ) {
751        let (dc_scores, jaccard) =
752            crate::coupling::compute_directed_coupling_for_repo(repo_root, partner_scores);
753
754        // Build file → function-indices map
755        let mut file_indices: std::collections::HashMap<String, Vec<usize>> =
756            std::collections::HashMap::new();
757        for (i, func) in self.functions.iter().enumerate() {
758            // DC scores are keyed by repo-relative path; snapshot paths may be absolute.
759            let rel = std::path::Path::new(&func.file)
760                .strip_prefix(repo_root)
761                .map(|p| p.to_string_lossy().replace('\\', "/"))
762                .unwrap_or_else(|_| func.file.replace('\\', "/"));
763            file_indices.entry(rel).or_default().push(i);
764        }
765
766        for (rel_path, indices) in &file_indices {
767            let score = dc_scores.get(rel_path).copied();
768            for &i in indices {
769                self.functions[i].directed_coupling = score;
770                self.functions[i].jaccard_label_stability = jaccard;
771            }
772        }
773    }
774
775    /// Detect and populate the `subsystem` field for every function.
776    ///
777    /// Walks `repo_root` once to find all manifest files, then assigns each
778    /// function its nearest manifest-containing ancestor directory (relative to
779    /// `repo_root`, using `/` separators). Empty string means the file belongs
780    /// to the repo root. Skips hidden and vendored directories.
781    pub fn populate_subsystems(&mut self, repo_root: &Path) {
782        let cache = build_subsystem_cache(repo_root);
783        for function in &mut self.functions {
784            // function.file may be absolute (--all-functions) or repo-relative.
785            let path = Path::new(&function.file);
786            let abs = if path.is_absolute() {
787                path.to_path_buf()
788            } else {
789                repo_root.join(path)
790            };
791            function.subsystem = subsystem_for_file(&abs, repo_root, &cache);
792        }
793    }
794
795    fn populate_per_function_touch_metrics(
796        &mut self,
797        repo_root: &std::path::Path,
798        progress_fn: Option<&dyn Fn(usize, usize)>,
799    ) -> anyhow::Result<()> {
800        let all: Vec<usize> = (0..self.functions.len()).collect();
801        self.populate_per_function_touch_for_indices(repo_root, &all, progress_fn)
802    }
803
804    // Per-function touch metrics: one `git log -L` subprocess per function (~9 ms each).
805    // A disk cache keyed by (sha, file, start, end) avoids re-running subprocesses for
806    // functions whose line ranges have not changed since the last run (warm path).
807    //
808    // Chunked implementation to bound peak memory: `indices` are processed CHUNK_SIZE at a
809    // time. Each chunk goes through three phases before moving to the next:
810    //   Phase A (serial):   check cache, apply hits, collect misses (≤ CHUNK_SIZE entries).
811    //   Phase B (parallel): run git subprocesses for this chunk's misses via rayon.
812    //   Phase C (serial):   apply results, update cache.
813    // This keeps the live miss buffer O(CHUNK_SIZE) regardless of how many indices are passed.
814    fn populate_per_function_touch_for_indices(
815        &mut self,
816        repo_root: &std::path::Path,
817        indices: &[usize],
818        progress_fn: Option<&dyn Fn(usize, usize)>,
819    ) -> anyhow::Result<()> {
820        let sha = self.commit.sha.clone();
821        let timestamp = self.commit.timestamp;
822        let mut cache = crate::touch_cache::read_touch_cache(repo_root).unwrap_or_default();
823        let total = indices.len();
824
825        // Emit a cold-start warning if the cache holds no entries for the current SHA.
826        const COLD_CACHE_WARN_THRESHOLD: usize = 50;
827        let cache_warm = cache.keys().any(|k| k.starts_with(&sha));
828        if !cache_warm && total >= COLD_CACHE_WARN_THRESHOLD {
829            let threads = rayon::current_num_threads().max(1);
830            let est_secs = (total * 9).div_ceil(1000 * threads);
831            eprintln!("touch cache: cold start for {total} functions (~{est_secs}s; fast on subsequent runs)");
832        }
833
834        // CHUNK_SIZE bounds the miss buffer: at most this many (key, rel, lines) strings
835        // live in memory at once, regardless of total index count.
836        const CHUNK_SIZE: usize = 512;
837        let mut completed = 0usize;
838
839        for chunk in indices.chunks(CHUNK_SIZE) {
840            // Phase A: check cache for this chunk; collect misses.
841            // Each miss: (function_index, cache_key, rel_path, start_line, end_line)
842            let mut chunk_misses: Vec<(usize, String, String, u32, u32)> = Vec::new();
843            for &i in chunk {
844                let function = &self.functions[i];
845                let rel =
846                    if let Ok(r) = std::path::Path::new(&function.file).strip_prefix(repo_root) {
847                        r.to_string_lossy().replace('\\', "/")
848                    } else {
849                        function.file.replace('\\', "/")
850                    };
851                let start_line = function.line;
852                let end_line =
853                    (start_line + function.metrics.loc.saturating_sub(1)).max(start_line);
854                let key = crate::touch_cache::cache_key(&sha, &rel, start_line, end_line);
855                if let Some(&(count, days)) = cache.get(&key) {
856                    self.functions[i].touch_count_30d = Some(count);
857                    self.functions[i].days_since_last_change = days;
858                    completed += 1;
859                } else {
860                    chunk_misses.push((i, key, rel, start_line, end_line));
861                }
862            }
863            if let Some(f) = progress_fn {
864                f(completed, total);
865            }
866            if chunk_misses.is_empty() {
867                continue;
868            }
869
870            // Phase B: run git subprocesses in parallel for this chunk's misses only.
871            let results: Vec<(usize, String, (usize, Option<u32>))> = chunk_misses
872                .par_iter()
873                .map(|(idx, key, rel, start_line, end_line)| {
874                    let value = match crate::git::function_touch_metrics_at(
875                        repo_root,
876                        rel,
877                        *start_line,
878                        *end_line,
879                        timestamp,
880                    ) {
881                        Ok((count, days)) => (count, days),
882                        Err(_) => (0usize, None),
883                    };
884                    (*idx, key.clone(), value)
885                })
886                .collect();
887
888            // Phase C: apply this chunk's results and update the cache.
889            for (idx, key, (count, days)) in results {
890                self.functions[idx].touch_count_30d = Some(count);
891                self.functions[idx].days_since_last_change = days;
892                cache.insert(key, (count, days));
893                completed += 1;
894                if let Some(f) = progress_fn {
895                    f(completed, total);
896                }
897            }
898        }
899
900        // Evict stale entries (most-recent SHAs first) then write.
901        let known_shas: Vec<String> = {
902            let mut commits = Index::load_or_new(&index_path(repo_root))
903                .map(|idx| idx.commits)
904                .unwrap_or_default();
905            commits.sort_by_key(|c| std::cmp::Reverse(c.timestamp));
906            let mut shas = vec![sha.clone()];
907            shas.extend(commits.into_iter().map(|e| e.sha).filter(|s| s != &sha));
908            shas
909        };
910        crate::touch_cache::evict_old_entries(&mut cache, &known_shas);
911        if let Err(e) = crate::touch_cache::write_touch_cache(repo_root, &cache) {
912            eprintln!("warning: failed to write touch cache: {e}");
913        }
914
915        Ok(())
916    }
917
918    /// File-level touch metrics: one batched git call + per-file fallback
919    fn populate_file_level_touch_metrics(
920        &mut self,
921        repo_root: &std::path::Path,
922    ) -> anyhow::Result<()> {
923        use std::collections::HashMap;
924
925        // Build map of absolute path -> function indices
926        let mut unique_files: HashMap<String, Vec<usize>> = HashMap::new();
927        for (idx, function) in self.functions.iter().enumerate() {
928            unique_files
929                .entry(function.file.clone())
930                .or_default()
931                .push(idx);
932        }
933
934        // Convert all absolute paths to relative paths for git
935        let abs_to_rel: HashMap<String, String> = unique_files
936            .keys()
937            .map(|abs| {
938                let rel = if let Ok(r) = std::path::Path::new(abs).strip_prefix(repo_root) {
939                    r.to_string_lossy().to_string()
940                } else {
941                    abs.clone()
942                };
943                (abs.clone(), rel)
944            })
945            .collect();
946
947        // One batched call for the 30-day window (replaces N×2 individual calls)
948        let batched = crate::git::batch_touch_metrics_at(repo_root, self.commit.timestamp)
949            .unwrap_or_else(|_| crate::git::BatchedTouchMetrics {
950                touch_count_30d: HashMap::new(),
951                days_since_last_change: HashMap::new(),
952            });
953
954        // Collect files whose last-touch timestamp isn't in the 30-day window
955        // and resolve them in a single streaming git log pass instead of one
956        // subprocess per file.
957        let stale_files: std::collections::HashSet<&str> = abs_to_rel
958            .values()
959            .map(|s| s.as_str())
960            .filter(|rel| !batched.days_since_last_change.contains_key(*rel))
961            .collect();
962
963        let stale_days =
964            crate::git::batch_last_touch_for_files(repo_root, &stale_files, self.commit.timestamp);
965
966        // Apply results to all functions in each file
967        for (abs_path, function_indices) in &unique_files {
968            let rel = abs_to_rel
969                .get(abs_path)
970                .map(|s| s.as_str())
971                .unwrap_or(abs_path);
972
973            let touch_count = batched.touch_count_30d.get(rel).copied().or(Some(0));
974            let days_since = batched
975                .days_since_last_change
976                .get(rel)
977                .copied()
978                .or_else(|| stale_days.get(rel).copied());
979
980            for &idx in function_indices {
981                self.functions[idx].touch_count_30d = touch_count;
982                self.functions[idx].days_since_last_change = days_since;
983            }
984        }
985
986        Ok(())
987    }
988
989    /// Populate touch count and recency metrics from git data
990    ///
991    /// For each file (or function when `per_function` is true), computes:
992    /// - touch_count_30d: number of commits in last 30 days
993    /// - days_since_last_change: days since last modification
994    ///
995    pub fn populate_touch_metrics(
996        &mut self,
997        repo_root: &std::path::Path,
998        mode: TouchMode,
999        progress_fn: Option<&dyn Fn(usize, usize)>,
1000    ) -> anyhow::Result<()> {
1001        match mode {
1002            TouchMode::File => self.populate_file_level_touch_metrics(repo_root),
1003            TouchMode::PerFunction => {
1004                self.populate_per_function_touch_metrics(repo_root, progress_fn)
1005            }
1006            TouchMode::Hybrid { threshold } => {
1007                self.populate_hybrid_touch_metrics(repo_root, threshold, progress_fn)
1008            }
1009        }
1010    }
1011
1012    /// Hybrid touch: file-level first (cheap), then per-function only for
1013    /// functions in files whose touch_count_30d >= threshold.
1014    fn populate_hybrid_touch_metrics(
1015        &mut self,
1016        repo_root: &std::path::Path,
1017        threshold: usize,
1018        progress_fn: Option<&dyn Fn(usize, usize)>,
1019    ) -> anyhow::Result<()> {
1020        self.populate_file_level_touch_metrics(repo_root)?;
1021
1022        let hot_indices: Vec<usize> = self
1023            .functions
1024            .iter()
1025            .enumerate()
1026            .filter_map(|(i, f)| {
1027                if f.touch_count_30d.unwrap_or(0) >= threshold {
1028                    Some(i)
1029                } else {
1030                    None
1031                }
1032            })
1033            .collect();
1034
1035        if hot_indices.is_empty() {
1036            return Ok(());
1037        }
1038
1039        self.populate_per_function_touch_for_indices(repo_root, &hot_indices, progress_fn)
1040    }
1041
1042    /// Replace branch-inflated recency values with pre-branch last-change dates.
1043    ///
1044    /// For functions touched only on this branch (days_since_last_change < branch age),
1045    /// replaces the inflated recency with the last-change date before the branch diverged.
1046    /// One git call per unique file that needs a lookup; no-op when called without a merge base.
1047    pub fn adjust_recency_for_branch(
1048        &mut self,
1049        repo_root: &std::path::Path,
1050        merge_base_sha: &str,
1051        merge_base_ts: i64,
1052    ) {
1053        let merge_base_age_days = ((self.commit.timestamp - merge_base_ts).max(0) / 86400) as u32;
1054
1055        // Identify unique files touched only on this branch
1056        let mut files_needing_lookup: std::collections::HashSet<String> =
1057            std::collections::HashSet::new();
1058        for func in &self.functions {
1059            if func
1060                .days_since_last_change
1061                .is_some_and(|d| d < merge_base_age_days)
1062            {
1063                files_needing_lookup.insert(func.file.clone());
1064            }
1065        }
1066
1067        // One git call per unique file — get last-change date before branch diverged
1068        let mut pre_branch: std::collections::HashMap<String, Option<u32>> =
1069            std::collections::HashMap::new();
1070        for abs_file in &files_needing_lookup {
1071            let rel = std::path::Path::new(abs_file)
1072                .strip_prefix(repo_root)
1073                .map(|p| p.to_string_lossy().replace('\\', "/"))
1074                .unwrap_or_else(|_| abs_file.replace('\\', "/"));
1075            let days = crate::git::days_since_last_change_at_sha(
1076                repo_root,
1077                &rel,
1078                merge_base_sha,
1079                self.commit.timestamp,
1080            );
1081            pre_branch.insert(abs_file.clone(), days);
1082        }
1083
1084        // Replace inflated recency with pre-branch value
1085        for func in &mut self.functions {
1086            if let Some(Some(pre_days)) = pre_branch.get(&func.file) {
1087                func.days_since_last_change = Some(*pre_days);
1088            }
1089        }
1090    }
1091
1092    /// Populate call graph metrics
1093    ///
1094    /// Populate call graph metrics (PageRank, betweenness, fan-in, SCC, depth, neighbor churn).
1095    ///
1096    /// Betweenness is computed exactly when `call_graph.nodes.len() <= exact_threshold`,
1097    /// and via k-source approximation otherwise. Returns `true` if approximation was used.
1098    pub fn populate_callgraph(
1099        &mut self,
1100        call_graph: &crate::callgraph::CallGraph,
1101        exact_threshold: usize,
1102        approx_k: usize,
1103    ) -> bool {
1104        use std::collections::HashMap;
1105
1106        let n = call_graph.node_count();
1107        let approximate = n > exact_threshold;
1108
1109        // Compute global metrics once
1110        let pagerank_scores = call_graph.pagerank(0.85, 30, 1e-6);
1111        let betweenness_scores = if approximate {
1112            call_graph.betweenness_centrality_approx(approx_k)
1113        } else {
1114            call_graph.betweenness_centrality()
1115        };
1116        let scc_info = call_graph.find_strongly_connected_components();
1117        let dependency_depths = call_graph.compute_dependency_depth();
1118        // Precompute fan-in counts in O(N+E) to avoid O(N*E) repeated fan_in() calls below
1119        let fan_in_map = call_graph.build_fan_in_map();
1120
1121        // Build a map of function_id -> total churn (lines_added + lines_deleted)
1122        let mut churn_map: HashMap<String, usize> = HashMap::new();
1123        for function in &self.functions {
1124            if let Some(ref churn) = function.churn {
1125                let total_churn = churn.lines_added + churn.lines_deleted;
1126                churn_map.insert(function.function_id.clone(), total_churn);
1127            }
1128        }
1129
1130        // Populate metrics for each function
1131        for function in &mut self.functions {
1132            let function_id = &function.function_id;
1133
1134            // Only populate if function is in the call graph
1135            if call_graph.contains(function_id) {
1136                let (scc_id, scc_size) = scc_info.get(function_id).copied().unwrap_or((0, 1));
1137                let dependency_depth = dependency_depths.get(function_id).copied().flatten();
1138
1139                // Compute neighbor churn: sum of churn for all callees
1140                let neighbor_churn = if let Some(callees) = call_graph.callees_of(function_id) {
1141                    let total: usize = callees
1142                        .filter_map(|callee_id| churn_map.get(callee_id))
1143                        .sum();
1144                    if total > 0 {
1145                        Some(total)
1146                    } else {
1147                        None
1148                    }
1149                } else {
1150                    None
1151                };
1152
1153                function.callgraph = Some(CallGraphMetrics {
1154                    fan_in: fan_in_map.get(function_id).copied().unwrap_or(0),
1155                    fan_out: call_graph.fan_out(function_id),
1156                    pagerank: pagerank_scores.get(function_id).copied().unwrap_or(0.0),
1157                    betweenness: betweenness_scores.get(function_id).copied().unwrap_or(0.0),
1158                    scc_id,
1159                    scc_size,
1160                    is_entrypoint: call_graph.is_entry_point(function_id),
1161                    dependency_depth,
1162                    neighbor_churn,
1163                });
1164            }
1165        }
1166
1167        approximate
1168    }
1169
1170    /// Compute and populate activity risk scores
1171    ///
1172    /// Combines LRS with activity metrics and call graph metrics to produce
1173    /// a unified risk score. Should be called after populate_churn, populate_touch_metrics,
1174    /// and populate_callgraph have been called.
1175    ///
1176    /// # Arguments
1177    ///
1178    /// * `weights` - Optional weights for risk factors (uses defaults if None)
1179    pub fn compute_activity_risk(&mut self, weights: Option<&crate::scoring::ScoringWeights>) {
1180        let default_weights = crate::scoring::ScoringWeights::default();
1181        let weights = weights.unwrap_or(&default_weights);
1182
1183        for function in &mut self.functions {
1184            // Extract churn data
1185            let churn = function
1186                .churn
1187                .as_ref()
1188                .map(|c| (c.lines_added, c.lines_deleted));
1189
1190            // Extract call graph data
1191            let (fan_in, scc_size, dependency_depth, neighbor_churn) =
1192                if let Some(ref cg) = function.callgraph {
1193                    (
1194                        Some(cg.fan_in),
1195                        Some(cg.scc_size),
1196                        cg.dependency_depth,
1197                        cg.neighbor_churn,
1198                    )
1199                } else {
1200                    (None, None, None, None)
1201                };
1202
1203            // Compute activity risk
1204            let (activity_risk, risk_factors) = crate::scoring::compute_activity_risk(
1205                &crate::scoring::ActivityRiskInput {
1206                    lrs: function.lrs,
1207                    churn,
1208                    touch_count_30d: function.touch_count_30d,
1209                    days_since_last_change: function.days_since_last_change,
1210                    fan_in,
1211                    scc_size,
1212                    dependency_depth,
1213                    neighbor_churn,
1214                    burst_score: function.burst_score,
1215                },
1216                weights,
1217            );
1218
1219            // Only populate if there are additional risk factors beyond base LRS
1220            if activity_risk > function.lrs || risk_factors.churn > 0.0 {
1221                function.activity_risk = Some(activity_risk);
1222                function.risk_factors = Some(risk_factors);
1223            }
1224        }
1225    }
1226
1227    /// Populate pattern labels using full Tier 1 + Tier 2 data.
1228    ///
1229    /// Re-classifies each function with complete enriched inputs, replacing the
1230    /// Tier 1–only patterns carried from the analysis report. Must be called
1231    /// after `populate_churn()`, `populate_callgraph()`, and
1232    /// `populate_touch_metrics()` for accurate Tier 2 patterns.
1233    pub fn populate_patterns(&mut self, thresholds: &crate::patterns::Thresholds) {
1234        for function in &mut self.functions {
1235            let t1 = crate::patterns::Tier1Input {
1236                cc: function.metrics.cc as usize,
1237                nd: function.metrics.nd as usize,
1238                fo: function.metrics.fo as usize,
1239                ns: function.metrics.ns as usize,
1240                loc: function.metrics.loc as usize,
1241            };
1242            // churn_lines is intentionally None here: function.churn is file-level
1243            // (all functions in a file share the same total), not per-function. Using
1244            // it would cause systematic false positives for churn_magnet, shotgun_target,
1245            // and volatile_god on any multi-function file. Churn-based patterns require
1246            // per-function data that is not yet available in snapshot enrichment.
1247            let (fan_in, scc_size, neighbor_churn, is_entrypoint) =
1248                if let Some(ref cg) = function.callgraph {
1249                    (
1250                        Some(cg.fan_in),
1251                        Some(cg.scc_size),
1252                        cg.neighbor_churn,
1253                        cg.is_entrypoint,
1254                    )
1255                } else {
1256                    (None, None, None, false)
1257                };
1258            let t2 = crate::patterns::Tier2Input {
1259                fan_in,
1260                scc_size,
1261                churn_lines: None,
1262                days_since_last_change: function.days_since_last_change,
1263                neighbor_churn,
1264                is_entrypoint,
1265            };
1266            function.patterns = crate::patterns::classify(&t1, &t2, thresholds);
1267        }
1268    }
1269
1270    /// Populate full pattern details for `--explain-patterns`.
1271    ///
1272    /// Stores `PatternDetail` (triggered conditions) in each function.
1273    /// Must be called after `populate_patterns()`.
1274    pub fn populate_pattern_details(&mut self, thresholds: &crate::patterns::Thresholds) {
1275        for function in &mut self.functions {
1276            let t1 = crate::patterns::Tier1Input {
1277                cc: function.metrics.cc as usize,
1278                nd: function.metrics.nd as usize,
1279                fo: function.metrics.fo as usize,
1280                ns: function.metrics.ns as usize,
1281                loc: function.metrics.loc as usize,
1282            };
1283            let (fan_in, scc_size, neighbor_churn, is_entrypoint) =
1284                if let Some(ref cg) = function.callgraph {
1285                    (
1286                        Some(cg.fan_in),
1287                        Some(cg.scc_size),
1288                        cg.neighbor_churn,
1289                        cg.is_entrypoint,
1290                    )
1291                } else {
1292                    (None, None, None, false)
1293                };
1294            let t2 = crate::patterns::Tier2Input {
1295                fan_in,
1296                scc_size,
1297                churn_lines: None,
1298                days_since_last_change: function.days_since_last_change,
1299                neighbor_churn,
1300                is_entrypoint,
1301            };
1302            function.pattern_details =
1303                Some(crate::patterns::classify_detailed(&t1, &t2, thresholds));
1304        }
1305    }
1306
1307    /// Compute and populate percentile flags for all functions
1308    ///
1309    /// Must be called after compute_activity_risk().
1310    /// Flags: is_top_1_pct, is_top_5_pct, is_top_10_pct based on activity_risk.
1311    pub fn compute_percentiles(&mut self) {
1312        let n = self.functions.len();
1313        if n == 0 {
1314            return;
1315        }
1316
1317        // Collect all activity_risk scores (falling back to lrs)
1318        let mut scores: Vec<f64> = self
1319            .functions
1320            .iter()
1321            .map(|f| f.activity_risk.unwrap_or(f.lrs))
1322            .collect();
1323        scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1324
1325        // Compute threshold values via quantile index
1326        let threshold_10 = scores[n.saturating_sub(1) * 90 / 100];
1327        let threshold_5 = scores[n.saturating_sub(1) * 95 / 100];
1328        let threshold_1 = scores[n.saturating_sub(1) * 99 / 100];
1329
1330        for function in &mut self.functions {
1331            let score = function.activity_risk.unwrap_or(function.lrs);
1332            function.percentile = Some(PercentileFlags {
1333                is_top_10_pct: score >= threshold_10,
1334                is_top_5_pct: score >= threshold_5,
1335                is_top_1_pct: score >= threshold_1,
1336            });
1337        }
1338    }
1339
1340    /// Populate driver labels for all functions using driving_dimension_label.
1341    ///
1342    /// Must be called after compute_activity_risk() and populate_callgraph().
1343    pub fn populate_driver_labels(&mut self, percentile: u8) {
1344        let thresholds = compute_dimension_thresholds(&self.functions, percentile);
1345
1346        let mut sorted_cc: Vec<usize> = self
1347            .functions
1348            .iter()
1349            .map(|f| f.metrics.cc as usize)
1350            .collect();
1351        let mut sorted_nd: Vec<usize> = self
1352            .functions
1353            .iter()
1354            .map(|f| f.metrics.nd as usize)
1355            .collect();
1356        let mut sorted_fo: Vec<usize> = self
1357            .functions
1358            .iter()
1359            .map(|f| f.callgraph.as_ref().map(|cg| cg.fan_out).unwrap_or(0))
1360            .collect();
1361        let mut sorted_fi: Vec<usize> = self
1362            .functions
1363            .iter()
1364            .map(|f| f.callgraph.as_ref().map(|cg| cg.fan_in).unwrap_or(0))
1365            .collect();
1366        let mut sorted_touch: Vec<usize> = self
1367            .functions
1368            .iter()
1369            .map(|f| f.touch_count_30d.unwrap_or(0))
1370            .collect();
1371        sorted_cc.sort_unstable();
1372        sorted_nd.sort_unstable();
1373        sorted_fo.sort_unstable();
1374        sorted_fi.sort_unstable();
1375        sorted_touch.sort_unstable();
1376
1377        for function in &mut self.functions {
1378            let label = driving_dimension_label(function, &thresholds).to_string();
1379            function.driver_detail = if label == "composite" {
1380                compute_near_miss_detail(
1381                    function,
1382                    &sorted_cc,
1383                    &sorted_nd,
1384                    &sorted_fo,
1385                    &sorted_fi,
1386                    &sorted_touch,
1387                )
1388            } else {
1389                None
1390            };
1391            function.driver = Some(label);
1392        }
1393    }
1394
1395    /// Compute and populate triage quadrant for all functions.
1396    ///
1397    /// Quadrant logic (Option C — combines both signals):
1398    ///   is_active = touches_30d > touch_p50 OR days_since_last_change <= 30
1399    ///              [+ activity_risk >= 0.7 when ranker_applied]
1400    ///   fire  = high/critical + is_active
1401    ///   debt  = high/critical + !is_active
1402    ///   watch = moderate/low  + is_active
1403    ///   ok    = everything else
1404    ///
1405    /// When `ranker_applied` is true, a trained RF score >= 0.7 also counts as
1406    /// active — promoting structurally-risky-but-dormant functions from debt to
1407    /// fire when the model predicts they are likely to be touched in a fix commit.
1408    ///
1409    /// Must be called after populate_driver_labels().
1410    pub fn compute_quadrants(&mut self, driver_threshold_percentile: u8, ranker_applied: bool) {
1411        if self.functions.is_empty() {
1412            return;
1413        }
1414        let thresholds = compute_dimension_thresholds(&self.functions, driver_threshold_percentile);
1415        let touch_p50 = thresholds.touch_med;
1416
1417        for function in &mut self.functions {
1418            let touch_above_p50 = function
1419                .touch_count_30d
1420                .map(|t| t > touch_p50)
1421                .unwrap_or(false);
1422            let recently_changed = function
1423                .days_since_last_change
1424                .map(|d| d <= 30)
1425                .unwrap_or(false);
1426            let high_ranker_score =
1427                ranker_applied && function.activity_risk.map(|r| r >= 0.7).unwrap_or(false);
1428            let is_active = touch_above_p50 || recently_changed || high_ranker_score;
1429            let is_high_risk = matches!(function.band, RiskBand::Critical | RiskBand::High);
1430
1431            function.quadrant = Some(
1432                match (is_high_risk, is_active) {
1433                    (true, true) => "fire",
1434                    (true, false) => "debt",
1435                    (false, true) => "watch",
1436                    (false, false) => "ok",
1437                }
1438                .to_string(),
1439            );
1440        }
1441    }
1442
1443    /// Compute repo-level summary statistics
1444    ///
1445    /// Must be called after compute_activity_risk() and populate_callgraph().
1446    pub fn compute_summary(&mut self, betweenness_approximate: bool) {
1447        let n = self.functions.len();
1448        if n == 0 {
1449            self.summary = Some(SnapshotSummary {
1450                total_functions: 0,
1451                total_activity_risk: 0.0,
1452                top_1_pct_share: 0.0,
1453                top_5_pct_share: 0.0,
1454                top_10_pct_share: 0.0,
1455                by_band: std::collections::BTreeMap::new(),
1456                call_graph: None,
1457            });
1458            return;
1459        }
1460
1461        let mut scored: Vec<f64> = self
1462            .functions
1463            .iter()
1464            .map(|f| f.activity_risk.unwrap_or(f.lrs))
1465            .collect();
1466        scored.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
1467
1468        let total_risk: f64 = scored.iter().sum();
1469        let (top_1_pct_share, top_5_pct_share, top_10_pct_share) =
1470            compute_top_k_shares(&scored, total_risk);
1471
1472        self.summary = Some(SnapshotSummary {
1473            total_functions: n,
1474            total_activity_risk: total_risk,
1475            top_1_pct_share,
1476            top_5_pct_share,
1477            top_10_pct_share,
1478            by_band: compute_band_distribution(&self.functions),
1479            call_graph: compute_call_graph_stats(&self.functions, n, betweenness_approximate),
1480        });
1481    }
1482
1483    /// Serialize snapshot as JSONL (one JSON object per line, no outer array)
1484    ///
1485    /// Each line embeds the commit context alongside function data,
1486    /// suitable for streaming ingestion (DuckDB, jq -s, etc.)
1487    pub fn to_jsonl(&self) -> Result<String> {
1488        let commit_json =
1489            serde_json::to_value(&self.commit).context("failed to serialize commit")?;
1490
1491        let mut lines = Vec::with_capacity(self.functions.len());
1492        for func in &self.functions {
1493            let mut obj = serde_json::to_value(func).context("failed to serialize function")?;
1494            // Embed commit context in each row
1495            obj.as_object_mut()
1496                .context("serialized function is not a JSON object")?
1497                .insert("commit".to_string(), commit_json.clone());
1498            lines.push(serde_json::to_string(&obj).context("failed to serialize JSONL line")?);
1499        }
1500
1501        Ok(lines.join("\n"))
1502    }
1503
1504    /// Serialize snapshot to JSON string (deterministic ordering)
1505    pub fn to_json(&self) -> Result<String> {
1506        serde_json::to_string_pretty(self).context("failed to serialize snapshot to JSON")
1507    }
1508
1509    /// Write the snapshot as pretty-printed JSON directly to `writer` without
1510    /// building an intermediate `String`.
1511    ///
1512    /// Use this instead of `to_json()` + `println!` when outputting large
1513    /// snapshots — it avoids allocating a potentially hundreds-of-MB string.
1514    pub fn write_json_to<W: std::io::Write>(&self, writer: &mut W) -> Result<()> {
1515        serde_json::to_writer_pretty(&mut *writer, self)
1516            .context("failed to write snapshot JSON")?;
1517        writeln!(writer).context("failed to write trailing newline")
1518    }
1519
1520    /// Write the snapshot as JSONL (one JSON object per function) directly to
1521    /// `writer`, without building an intermediate `String`.
1522    pub fn write_jsonl_to<W: std::io::Write>(&self, writer: &mut W) -> Result<()> {
1523        let commit_json =
1524            serde_json::to_value(&self.commit).context("failed to serialize commit")?;
1525
1526        for func in &self.functions {
1527            let mut obj = serde_json::to_value(func).context("failed to serialize function")?;
1528            obj.as_object_mut()
1529                .context("serialized function is not a JSON object")?
1530                .insert("commit".to_string(), commit_json.clone());
1531            serde_json::to_writer(writer as &mut dyn std::io::Write, &obj)
1532                .context("failed to write JSONL line")?;
1533            writeln!(writer).context("failed to write JSONL newline")?;
1534        }
1535        Ok(())
1536    }
1537
1538    /// Deserialize snapshot from JSON string
1539    pub fn from_json(json: &str) -> Result<Self> {
1540        let snapshot: Snapshot =
1541            serde_json::from_str(json).context("failed to deserialize snapshot from JSON")?;
1542
1543        // Validate schema version (accept v1 snapshots — missing fields default to None)
1544        if snapshot.schema_version < SNAPSHOT_SCHEMA_MIN_VERSION
1545            || snapshot.schema_version > SNAPSHOT_SCHEMA_VERSION
1546        {
1547            anyhow::bail!(
1548                "unsupported schema version: got {}, supported range {}-{}",
1549                snapshot.schema_version,
1550                SNAPSHOT_SCHEMA_MIN_VERSION,
1551                SNAPSHOT_SCHEMA_VERSION
1552            );
1553        }
1554
1555        Ok(snapshot)
1556    }
1557
1558    /// Get the commit SHA for this snapshot
1559    pub fn commit_sha(&self) -> &str {
1560        &self.commit.sha
1561    }
1562}
1563
1564/// Returns (top_1_pct_share, top_5_pct_share, top_10_pct_share) from a
1565/// descending-sorted score slice and the pre-computed total.
1566fn compute_top_k_shares(scored: &[f64], total_risk: f64) -> (f64, f64, f64) {
1567    let n = scored.len();
1568    let safe_div = |a: f64, b: f64| if b > 0.0 { a / b } else { 0.0 };
1569    let top1_sum: f64 = scored.iter().take((n / 100).max(1)).sum();
1570    let top5_sum: f64 = scored.iter().take((n * 5 / 100).max(1)).sum();
1571    let top10_sum: f64 = scored.iter().take((n / 10).max(1)).sum();
1572    (
1573        safe_div(top1_sum, total_risk),
1574        safe_div(top5_sum, total_risk),
1575        safe_div(top10_sum, total_risk),
1576    )
1577}
1578
1579/// Builds a band → BandStats map from the function list.
1580fn compute_band_distribution(
1581    functions: &[FunctionSnapshot],
1582) -> std::collections::BTreeMap<String, BandStats> {
1583    let mut by_band = std::collections::BTreeMap::new();
1584    for func in functions {
1585        let score = func.activity_risk.unwrap_or(func.lrs);
1586        let entry = by_band
1587            .entry(func.band.as_str().to_string())
1588            .or_insert(BandStats {
1589                count: 0,
1590                sum_risk: 0.0,
1591            });
1592        entry.count += 1;
1593        entry.sum_risk += score;
1594    }
1595    by_band
1596}
1597
1598/// Computes call-graph-level summary statistics, or None if no call graph data.
1599fn compute_call_graph_stats(
1600    functions: &[FunctionSnapshot],
1601    n: usize,
1602    betweenness_approximate: bool,
1603) -> Option<CallGraphStats> {
1604    if !functions.iter().any(|f| f.callgraph.is_some()) {
1605        return None;
1606    }
1607    let total_edges: usize = functions
1608        .iter()
1609        .filter_map(|f| f.callgraph.as_ref())
1610        .map(|cg| cg.fan_out)
1611        .sum();
1612    let total_fan_in: usize = functions
1613        .iter()
1614        .filter_map(|f| f.callgraph.as_ref())
1615        .map(|cg| cg.fan_in)
1616        .sum();
1617    let mut scc_sizes: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1618    for func in functions {
1619        if let Some(ref cg) = func.callgraph {
1620            if cg.scc_size > 1 {
1621                scc_sizes.insert(cg.scc_id, cg.scc_size);
1622            }
1623        }
1624    }
1625    Some(CallGraphStats {
1626        total_edges,
1627        avg_fan_in: total_fan_in as f64 / n as f64,
1628        scc_count: scc_sizes.len(),
1629        largest_scc_size: scc_sizes.values().copied().max().unwrap_or(0),
1630        betweenness_approximate,
1631    })
1632}
1633
1634/// Percentile-derived thresholds for driving dimension detection.
1635/// Computed once per snapshot from the distribution of all functions.
1636pub struct DimensionThresholds {
1637    pub cc_high: usize,      // Pth percentile of cc — "high_complexity" gate
1638    pub cc_med: usize,       // 50th percentile of cc — floor for "high_fanin_complex"
1639    pub cc_low: usize,       // (100-P)th percentile of cc — "low cc" in "high_churn_low_cc"
1640    pub nd_high: usize,      // Pth percentile of nd — "deep_nesting" gate
1641    pub fan_out_high: usize, // Pth percentile of fan_out — "high_fanout_churning" gate
1642    pub fan_in_high: usize,  // Pth percentile of fan_in — "high_fanin_complex" gate
1643    pub touch_high: usize,   // Pth percentile of touch_count — "high churn" gate
1644    pub touch_med: usize,    // 50th percentile of touch_count — floor for "high_fanout_churning"
1645}
1646
1647/// Compute percentile-derived thresholds from a slice of function snapshots.
1648pub fn compute_dimension_thresholds(
1649    functions: &[FunctionSnapshot],
1650    percentile: u8,
1651) -> DimensionThresholds {
1652    let n = functions.len();
1653    if n == 0 {
1654        return DimensionThresholds {
1655            cc_high: 0,
1656            cc_med: 0,
1657            cc_low: 0,
1658            nd_high: 0,
1659            fan_out_high: 0,
1660            fan_in_high: 0,
1661            touch_high: 0,
1662            touch_med: 0,
1663        };
1664    }
1665
1666    let p = percentile as usize;
1667    let anti_p = 100 - p;
1668
1669    let percentile_idx = |pct: usize| (pct * (n - 1)) / 100;
1670
1671    let mut cc_vals: Vec<usize> = functions.iter().map(|f| f.metrics.cc as usize).collect();
1672    cc_vals.sort_unstable();
1673    let cc_high = cc_vals[percentile_idx(p)];
1674    let cc_med = cc_vals[percentile_idx(50)];
1675    let cc_low = cc_vals[percentile_idx(anti_p)];
1676
1677    let mut nd_vals: Vec<usize> = functions.iter().map(|f| f.metrics.nd as usize).collect();
1678    nd_vals.sort_unstable();
1679    let nd_high = nd_vals[percentile_idx(p)];
1680
1681    let mut fo_vals: Vec<usize> = functions
1682        .iter()
1683        .map(|f| f.callgraph.as_ref().map(|cg| cg.fan_out).unwrap_or(0))
1684        .collect();
1685    fo_vals.sort_unstable();
1686    let fan_out_high = fo_vals[percentile_idx(p)];
1687
1688    let mut fi_vals: Vec<usize> = functions
1689        .iter()
1690        .map(|f| f.callgraph.as_ref().map(|cg| cg.fan_in).unwrap_or(0))
1691        .collect();
1692    fi_vals.sort_unstable();
1693    let fan_in_high = fi_vals[percentile_idx(p)];
1694
1695    let mut touch_vals: Vec<usize> = functions
1696        .iter()
1697        .map(|f| f.touch_count_30d.unwrap_or(0))
1698        .collect();
1699    touch_vals.sort_unstable();
1700    let touch_high = touch_vals[percentile_idx(p)];
1701    let touch_med = touch_vals[percentile_idx(50)];
1702
1703    DimensionThresholds {
1704        cc_high,
1705        cc_med,
1706        cc_low,
1707        nd_high,
1708        fan_out_high,
1709        fan_in_high,
1710        touch_high,
1711        touch_med,
1712    }
1713}
1714
1715/// Normalize a driver label string to a canonical `'static` str.
1716pub fn normalize_driver_label(label: &str) -> &'static str {
1717    match label {
1718        "cyclic_dep" => "cyclic_dep",
1719        "high_complexity" => "high_complexity",
1720        "high_churn_low_cc" => "high_churn_low_cc",
1721        "high_fanout_churning" => "high_fanout_churning",
1722        "deep_nesting" => "deep_nesting",
1723        "high_fanin_complex" => "high_fanin_complex",
1724        _ => "composite",
1725    }
1726}
1727
1728/// Map a (driver, quadrant) pair to a recommended action string.
1729///
1730/// `quadrant` is one of `"fire"`, `"debt"`, `"watch"`, `"ok"`, or `""` (unknown).
1731/// When quadrant context is available the action is more specific; the generic
1732/// driver-only text is used as a fallback.
1733pub fn driver_action_for_quadrant(driver: &str, quadrant: &str) -> &'static str {
1734    match (driver, quadrant) {
1735        ("cyclic_dep", "fire") => "Break cycle now — circular dep is actively changing",
1736        ("cyclic_dep", _) => "Resolve dependency cycle",
1737        ("high_complexity", "fire") => "Extract sub-functions now — actively changing",
1738        ("high_complexity", "debt") => "Schedule CC reduction — stable, plan for next sprint",
1739        ("high_complexity", _) => "Reduce cyclomatic complexity",
1740        ("high_churn_low_cc", "fire") => "Add tests now — churning without a safety net",
1741        ("high_churn_low_cc", _) => "Add tests before next change",
1742        ("high_fanout_churning", "fire") => {
1743            "Extract interface boundary — high coupling + active change"
1744        }
1745        ("high_fanout_churning", _) => "Consider extracting an interface boundary",
1746        ("deep_nesting", "fire") => "Flatten nesting before next change",
1747        ("deep_nesting", "debt") => "Schedule flattening — deep nesting, currently quiet",
1748        ("deep_nesting", _) => "Flatten nesting depth",
1749        ("high_fanin_complex", "fire") => "Stabilize interface — many callers + active changes",
1750        ("high_fanin_complex", _) => "Stabilize interface — high fan-in makes changes risky",
1751        (_, "fire") => "Actively risky — plan refactor this sprint",
1752        _ => "Monitor: review complexity trends before next modification",
1753    }
1754}
1755
1756/// Map a driver label to its recommended action text (quadrant-agnostic fallback).
1757pub fn driver_action(label: &str) -> &'static str {
1758    driver_action_for_quadrant(label, "")
1759}
1760
1761/// Identify the primary driving dimension for a function's risk.
1762///
1763/// Returns a stable label: one of `"cyclic_dep"`, `"high_complexity"`,
1764/// `"high_churn_low_cc"`, `"high_fanout_churning"`, `"deep_nesting"`,
1765/// `"high_fanin_complex"`, or `"composite"`. Uses percentile-relative thresholds
1766/// derived from the snapshot's own distribution; `cyclic_dep` stays absolute.
1767pub fn driving_dimension_label(
1768    func: &FunctionSnapshot,
1769    thresholds: &DimensionThresholds,
1770) -> &'static str {
1771    let in_cycle = func
1772        .callgraph
1773        .as_ref()
1774        .map(|cg| cg.scc_size > 1)
1775        .unwrap_or(false);
1776    let fan_out = func.callgraph.as_ref().map(|cg| cg.fan_out).unwrap_or(0);
1777    let fan_in = func.callgraph.as_ref().map(|cg| cg.fan_in).unwrap_or(0);
1778    let touch_count = func.touch_count_30d.unwrap_or(0);
1779    let cc = func.metrics.cc as usize;
1780    let nd = func.metrics.nd as usize;
1781
1782    if in_cycle {
1783        "cyclic_dep"
1784    } else if cc > thresholds.cc_high {
1785        "high_complexity"
1786    } else if touch_count > thresholds.touch_high && cc < thresholds.cc_low {
1787        "high_churn_low_cc"
1788    } else if fan_out > thresholds.fan_out_high && touch_count > thresholds.touch_med {
1789        "high_fanout_churning"
1790    } else if nd > thresholds.nd_high {
1791        "deep_nesting"
1792    } else if fan_in > thresholds.fan_in_high && cc > thresholds.cc_med {
1793        "high_fanin_complex"
1794    } else {
1795        "composite"
1796    }
1797}
1798
1799/// Compute near-miss detail string for composite-labeled functions.
1800///
1801/// Returns a string like "cc (P72), nd (P68)" listing the top dimensions that
1802/// are above the 40th percentile (above median) but below the firing threshold.
1803/// Returns None when no dimension is notable.
1804fn compute_near_miss_detail(
1805    func: &FunctionSnapshot,
1806    sorted_cc: &[usize],
1807    sorted_nd: &[usize],
1808    sorted_fo: &[usize],
1809    sorted_fi: &[usize],
1810    sorted_touch: &[usize],
1811) -> Option<String> {
1812    let pct_rank = |v: usize, sorted: &[usize]| -> u8 {
1813        if sorted.is_empty() {
1814            return 0;
1815        }
1816        ((sorted.partition_point(|&x| x < v) * 100) / sorted.len()) as u8
1817    };
1818
1819    let mut near: Vec<(&str, u8)> = vec![
1820        ("cc", pct_rank(func.metrics.cc as usize, sorted_cc)),
1821        ("nd", pct_rank(func.metrics.nd as usize, sorted_nd)),
1822        (
1823            "fan_out",
1824            pct_rank(
1825                func.callgraph.as_ref().map(|cg| cg.fan_out).unwrap_or(0),
1826                sorted_fo,
1827            ),
1828        ),
1829        (
1830            "fan_in",
1831            pct_rank(
1832                func.callgraph.as_ref().map(|cg| cg.fan_in).unwrap_or(0),
1833                sorted_fi,
1834            ),
1835        ),
1836        (
1837            "touch",
1838            pct_rank(func.touch_count_30d.unwrap_or(0), sorted_touch),
1839        ),
1840    ]
1841    .into_iter()
1842    .filter(|(_, rank)| *rank >= 40)
1843    .collect();
1844
1845    near.sort_by_key(|a| std::cmp::Reverse(a.1));
1846    near.truncate(3);
1847
1848    if near.is_empty() {
1849        return None;
1850    }
1851    Some(
1852        near.iter()
1853            .map(|(name, rank)| format!("{} (P{})", name, rank))
1854            .collect::<Vec<_>>()
1855            .join(", "),
1856    )
1857}
1858
1859/// churn → touch_metrics → callgraph → activity_risk + percentiles + summary.
1860pub struct SnapshotEnricher {
1861    snapshot: Snapshot,
1862    betweenness_approximate: bool,
1863}
1864
1865impl SnapshotEnricher {
1866    /// Create a new enricher wrapping the given snapshot.
1867    pub fn new(snapshot: Snapshot) -> Self {
1868        SnapshotEnricher {
1869            snapshot,
1870            betweenness_approximate: false,
1871        }
1872    }
1873
1874    /// Detect and populate the `subsystem` field for every function.
1875    ///
1876    /// Walks `repo_root` once to find manifest files (package.json, Cargo.toml,
1877    /// pyproject.toml, go.mod, etc.) and tags each function with its nearest
1878    /// manifest-containing ancestor directory relative to `repo_root`.
1879    /// No-op (returns self unchanged) if `repo_root` does not exist.
1880    pub fn with_subsystems(mut self, repo_root: &Path) -> Self {
1881        if repo_root.exists() {
1882            self.snapshot.populate_subsystems(repo_root);
1883        }
1884        self
1885    }
1886
1887    /// Populate `burst_score` for every function (F93).
1888    /// No-op if `repo_root` does not exist.
1889    pub fn with_burst_score(mut self, repo_root: &Path) -> Self {
1890        if repo_root.exists() {
1891            self.snapshot.populate_burst_score(repo_root);
1892        }
1893        self
1894    }
1895
1896    /// Populate churn metrics from a file churn map.
1897    pub fn with_churn(
1898        mut self,
1899        file_churns: &std::collections::HashMap<String, crate::git::FileChurn>,
1900    ) -> Self {
1901        self.snapshot.populate_churn(file_churns);
1902        self
1903    }
1904
1905    /// Populate touch count and recency metrics from git.
1906    /// On error, emits a warning to stderr and continues.
1907    pub fn with_touch_metrics(
1908        mut self,
1909        repo_root: &Path,
1910        mode: TouchMode,
1911        progress_fn: Option<Box<dyn Fn(usize, usize)>>,
1912    ) -> Self {
1913        if let Err(e) =
1914            self.snapshot
1915                .populate_touch_metrics(repo_root, mode, progress_fn.as_deref())
1916        {
1917            eprintln!("Warning: failed to populate touch metrics: {}", e);
1918        }
1919        self
1920    }
1921
1922    /// Replace branch-inflated recency with pre-branch last-change dates.
1923    /// No-op when merge_base is None (on main, or no divergence).
1924    pub fn with_branch_recency_adjustment(
1925        mut self,
1926        repo_root: &Path,
1927        merge_base: Option<&(String, i64)>,
1928    ) -> Self {
1929        if let Some((sha, ts)) = merge_base {
1930            self.snapshot.adjust_recency_for_branch(repo_root, sha, *ts);
1931        }
1932        self
1933    }
1934
1935    /// Populate call graph metrics (PageRank, fan-in, betweenness, SCC, etc).
1936    ///
1937    /// Betweenness is computed exactly when `call_graph.nodes.len() <= exact_threshold`,
1938    /// and via k-source approximation otherwise.
1939    pub fn with_callgraph(
1940        mut self,
1941        call_graph: &crate::callgraph::CallGraph,
1942        exact_threshold: usize,
1943        approx_k: usize,
1944    ) -> Self {
1945        self.betweenness_approximate =
1946            self.snapshot
1947                .populate_callgraph(call_graph, exact_threshold, approx_k);
1948        self
1949    }
1950
1951    /// Compute activity risk, percentile flags, driver labels, and summary statistics.
1952    ///
1953    /// Must be called after with_churn, with_touch_metrics, and with_callgraph.
1954    pub fn enrich(
1955        mut self,
1956        weights: Option<&crate::scoring::ScoringWeights>,
1957        driver_threshold_percentile: u8,
1958    ) -> Self {
1959        self.snapshot.compute_activity_risk(weights);
1960        self.snapshot.compute_percentiles();
1961        self.snapshot
1962            .populate_driver_labels(driver_threshold_percentile);
1963        self.snapshot
1964            .compute_quadrants(driver_threshold_percentile, false);
1965        self.snapshot.compute_summary(self.betweenness_approximate);
1966        self
1967    }
1968
1969    /// Consume the enricher and return the fully enriched snapshot.
1970    pub fn build(self) -> Snapshot {
1971        self.snapshot
1972    }
1973}
1974
1975impl Index {
1976    /// Create a new empty index (default compaction level 0 - full snapshots)
1977    pub fn new() -> Self {
1978        Index {
1979            schema_version: INDEX_SCHEMA_VERSION,
1980            compaction_level: Some(0), // Default: full snapshots
1981            commits: Vec::new(),
1982        }
1983    }
1984
1985    /// Get compaction level (defaults to 0 if not set for backward compatibility)
1986    pub fn compaction_level(&self) -> u32 {
1987        self.compaction_level.unwrap_or(0)
1988    }
1989
1990    /// Set compaction level
1991    pub fn set_compaction_level(&mut self, level: u32) {
1992        self.compaction_level = Some(level);
1993    }
1994
1995    /// Load index from JSON file, or create new if file doesn't exist
1996    pub fn load_or_new(path: &Path) -> Result<Self> {
1997        if path.exists() {
1998            let json = std::fs::read_to_string(path)
1999                .with_context(|| format!("failed to read index file: {}", path.display()))?;
2000            Self::from_json(&json)
2001        } else {
2002            Ok(Self::new())
2003        }
2004    }
2005
2006    /// Deserialize index from JSON string
2007    pub fn from_json(json: &str) -> Result<Self> {
2008        let index: Index =
2009            serde_json::from_str(json).context("failed to deserialize index from JSON")?;
2010
2011        // Validate schema version
2012        if index.schema_version != INDEX_SCHEMA_VERSION {
2013            anyhow::bail!(
2014                "index schema version mismatch: expected {}, got {}",
2015                INDEX_SCHEMA_VERSION,
2016                index.schema_version
2017            );
2018        }
2019
2020        Ok(index)
2021    }
2022
2023    /// Serialize index to JSON string (deterministic ordering)
2024    pub fn to_json(&self) -> Result<String> {
2025        serde_json::to_string_pretty(self).context("failed to serialize index to JSON")
2026    }
2027
2028    /// Add or update a commit entry in the index
2029    ///
2030    /// If the commit already exists, this is idempotent (no-op).
2031    /// Entries are kept sorted deterministically by timestamp, then SHA.
2032    pub fn add_commit(&mut self, entry: IndexEntry) {
2033        // Check if entry already exists
2034        if !self.commits.iter().any(|c| c.sha == entry.sha) {
2035            self.commits.push(entry);
2036
2037            // Sort deterministically: timestamp ascending, then SHA ASCII ascending
2038            self.commits.sort_by(|a, b| {
2039                a.timestamp
2040                    .cmp(&b.timestamp)
2041                    .then_with(|| a.sha.cmp(&b.sha))
2042            });
2043        }
2044    }
2045
2046    /// Remove a commit entry by SHA
2047    pub fn remove_commit(&mut self, sha: &str) {
2048        self.commits.retain(|c| c.sha != sha);
2049    }
2050
2051    /// Check if index contains a commit
2052    pub fn contains(&self, sha: &str) -> bool {
2053        self.commits.iter().any(|c| c.sha == sha)
2054    }
2055}
2056
2057impl Default for Index {
2058    fn default() -> Self {
2059        Self::new()
2060    }
2061}
2062
2063/// Delta encoding schema version
2064const DELTA_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
2065
2066/// Delta between two snapshots — stores only what changed.
2067///
2068/// Stored as `<sha>.delta.json.zst` in the snapshots directory.
2069/// Reconstruct the full snapshot by loading the base and calling `apply_delta`.
2070#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2071#[serde(rename_all = "snake_case")]
2072pub struct DeltaSnapshot {
2073    pub schema_version: u32,
2074    /// Schema version of the full snapshot this delta reconstructs.
2075    pub snapshot_schema_version: u32,
2076    pub commit: CommitInfo,
2077    pub analysis: AnalysisInfo,
2078    /// SHA of the snapshot used as the delta base.
2079    pub base_sha: String,
2080    pub added: Vec<FunctionSnapshot>,
2081    pub modified: Vec<FunctionSnapshot>,
2082    pub removed: Vec<String>,
2083    /// Preserved from the original snapshot so HTML history charts remain intact
2084    /// after reconstruction (callers filter on `summary.is_some()`).
2085    #[serde(skip_serializing_if = "Option::is_none")]
2086    pub summary: Option<SnapshotSummary>,
2087}
2088
2089/// Compute a delta from `base` to `current`.
2090///
2091/// The returned `DeltaSnapshot` encodes only the functions that were added,
2092/// modified, or removed relative to `base`.
2093pub fn compute_delta(base: &Snapshot, current: &Snapshot) -> DeltaSnapshot {
2094    use std::collections::HashMap;
2095
2096    let base_map: HashMap<&str, &FunctionSnapshot> = base
2097        .functions
2098        .iter()
2099        .map(|f| (f.function_id.as_str(), f))
2100        .collect();
2101    let current_map: HashMap<&str, ()> = current
2102        .functions
2103        .iter()
2104        .map(|f| (f.function_id.as_str(), ()))
2105        .collect();
2106
2107    let added: Vec<FunctionSnapshot> = current
2108        .functions
2109        .iter()
2110        .filter(|f| !base_map.contains_key(f.function_id.as_str()))
2111        .cloned()
2112        .collect();
2113
2114    let modified: Vec<FunctionSnapshot> = current
2115        .functions
2116        .iter()
2117        .filter(|f| {
2118            base_map
2119                .get(f.function_id.as_str())
2120                .map(|bf| *bf != *f)
2121                .unwrap_or(false)
2122        })
2123        .cloned()
2124        .collect();
2125
2126    let removed: Vec<String> = base
2127        .functions
2128        .iter()
2129        .filter(|f| !current_map.contains_key(f.function_id.as_str()))
2130        .map(|f| f.function_id.clone())
2131        .collect();
2132
2133    DeltaSnapshot {
2134        schema_version: DELTA_SNAPSHOT_SCHEMA_VERSION,
2135        snapshot_schema_version: current.schema_version,
2136        commit: current.commit.clone(),
2137        analysis: current.analysis.clone(),
2138        base_sha: base.commit.sha.clone(),
2139        added,
2140        modified,
2141        removed,
2142        summary: current.summary.clone(),
2143    }
2144}
2145
2146/// Reconstruct a full `Snapshot` by applying a delta on top of `base`.
2147pub fn apply_delta(base: Snapshot, delta: DeltaSnapshot) -> Snapshot {
2148    use std::collections::HashMap;
2149
2150    let mut functions: HashMap<String, FunctionSnapshot> = base
2151        .functions
2152        .into_iter()
2153        .map(|f| (f.function_id.clone(), f))
2154        .collect();
2155
2156    for id in &delta.removed {
2157        functions.remove(id);
2158    }
2159    for func in delta.modified {
2160        functions.insert(func.function_id.clone(), func);
2161    }
2162    for func in delta.added {
2163        functions.insert(func.function_id.clone(), func);
2164    }
2165
2166    let mut result: Vec<FunctionSnapshot> = functions.into_values().collect();
2167    result.sort_by(|a, b| a.function_id.cmp(&b.function_id));
2168
2169    Snapshot {
2170        schema_version: delta.snapshot_schema_version,
2171        commit: delta.commit,
2172        analysis: delta.analysis,
2173        functions: result,
2174        summary: delta.summary,
2175        aggregates: None,
2176    }
2177}
2178
2179/// Persist a delta snapshot to `<sha>.delta.json.zst`.
2180pub fn persist_delta(repo_root: &Path, delta: &DeltaSnapshot) -> Result<()> {
2181    let path = delta_snapshot_path(repo_root, &delta.commit.sha);
2182    let json = serde_json::to_string_pretty(delta).context("failed to serialize delta snapshot")?;
2183    let compressed =
2184        zstd::encode_all(json.as_bytes(), 3).context("failed to compress delta snapshot")?;
2185    atomic_write_bytes(&path, &compressed)
2186        .with_context(|| format!("failed to persist delta snapshot: {}", path.display()))
2187}
2188
2189/// Get the path to the `.hotspots` directory in the repository root
2190pub fn hotspots_dir(repo_root: &Path) -> PathBuf {
2191    repo_root.join(".hotspots")
2192}
2193
2194/// Get the path to the snapshots directory
2195pub fn snapshots_dir(repo_root: &Path) -> PathBuf {
2196    hotspots_dir(repo_root).join("snapshots")
2197}
2198
2199/// Get the path to the index file
2200pub fn index_path(repo_root: &Path) -> PathBuf {
2201    hotspots_dir(repo_root).join("index.json")
2202}
2203
2204/// Get the path to a snapshot file for a given commit SHA
2205pub fn snapshot_path(repo_root: &Path, commit_sha: &str) -> PathBuf {
2206    snapshots_dir(repo_root).join(format!("{}.json.zst", commit_sha))
2207}
2208
2209/// Get the path to a delta snapshot file for a given commit SHA
2210pub fn delta_snapshot_path(repo_root: &Path, commit_sha: &str) -> PathBuf {
2211    snapshots_dir(repo_root).join(format!("{}.delta.json.zst", commit_sha))
2212}
2213
2214/// Return the path of the snapshot file that actually exists on disk,
2215/// trying `.json.zst` (new) before `.json` (legacy).  Returns `None` if
2216/// neither exists.
2217pub fn snapshot_path_existing(repo_root: &Path, commit_sha: &str) -> Option<PathBuf> {
2218    let zst = snapshot_path(repo_root, commit_sha);
2219    if zst.exists() {
2220        return Some(zst);
2221    }
2222    let json = snapshots_dir(repo_root).join(format!("{}.json", commit_sha));
2223    if json.exists() {
2224        return Some(json);
2225    }
2226    None
2227}
2228
2229/// Load a snapshot for the given commit SHA from disk.
2230///
2231/// Handles full snapshots (`.json.zst`, `.json`), delta snapshots
2232/// (`.delta.json.zst`), and transparent reconstruction of delta chains.
2233/// Returns `None` if no snapshot or delta file exists for the SHA.
2234pub fn load_snapshot(repo_root: &Path, commit_sha: &str) -> Result<Option<Snapshot>> {
2235    // Full snapshot takes priority.
2236    if let Some(path) = snapshot_path_existing(repo_root, commit_sha) {
2237        return Ok(Some(read_snapshot_file(&path)?));
2238    }
2239
2240    // Fall back to delta reconstruction.
2241    let dpath = delta_snapshot_path(repo_root, commit_sha);
2242    if dpath.exists() {
2243        let compressed = std::fs::read(&dpath)
2244            .with_context(|| format!("failed to read delta: {}", dpath.display()))?;
2245        let bytes = zstd::decode_all(compressed.as_slice())
2246            .with_context(|| format!("failed to decompress delta: {}", dpath.display()))?;
2247        let json = String::from_utf8(bytes).context("delta snapshot contains invalid UTF-8")?;
2248        let delta: DeltaSnapshot = serde_json::from_str(&json)
2249            .with_context(|| format!("failed to parse delta: {}", dpath.display()))?;
2250        let base = load_snapshot(repo_root, &delta.base_sha)?.ok_or_else(|| {
2251            anyhow::anyhow!(
2252                "base snapshot {} not found for delta {}",
2253                delta.base_sha,
2254                commit_sha
2255            )
2256        })?;
2257        return Ok(Some(apply_delta(base, delta)));
2258    }
2259
2260    Ok(None)
2261}
2262
2263/// Read and parse a snapshot from an arbitrary path, auto-detecting compression.
2264fn read_snapshot_file(path: &Path) -> Result<Snapshot> {
2265    let is_compressed = path
2266        .file_name()
2267        .and_then(|n| n.to_str())
2268        .map(|n| n.ends_with(".json.zst"))
2269        .unwrap_or(false);
2270
2271    let json: String = if is_compressed {
2272        let compressed = std::fs::read(path)
2273            .with_context(|| format!("failed to read snapshot: {}", path.display()))?;
2274        let bytes = zstd::decode_all(compressed.as_slice())
2275            .with_context(|| format!("failed to decompress snapshot: {}", path.display()))?;
2276        String::from_utf8(bytes).context("snapshot contains invalid UTF-8")?
2277    } else {
2278        std::fs::read_to_string(path)
2279            .with_context(|| format!("failed to read snapshot: {}", path.display()))?
2280    };
2281
2282    Snapshot::from_json(&json)
2283        .with_context(|| format!("failed to parse snapshot: {}", path.display()))
2284}
2285
2286/// Write data to file atomically using temp file + rename
2287pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
2288    use std::fs;
2289    use std::io::Write;
2290
2291    // Ensure parent directory exists
2292    if let Some(parent) = path.parent() {
2293        fs::create_dir_all(parent)
2294            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
2295    }
2296
2297    // Create temp file in same directory
2298    let temp_path = path.with_extension("tmp");
2299
2300    // Write to temp file
2301    let mut file = fs::File::create(&temp_path)
2302        .with_context(|| format!("failed to create temp file: {}", temp_path.display()))?;
2303    file.write_all(contents.as_bytes())
2304        .with_context(|| format!("failed to write to temp file: {}", temp_path.display()))?;
2305    file.sync_all()
2306        .with_context(|| format!("failed to sync temp file: {}", temp_path.display()))?;
2307    drop(file);
2308
2309    // Atomic rename
2310    fs::rename(&temp_path, path)
2311        .with_context(|| format!("failed to rename temp file to: {}", path.display()))?;
2312
2313    Ok(())
2314}
2315
2316/// Write binary data to file atomically using temp file + rename
2317pub fn atomic_write_bytes(path: &Path, contents: &[u8]) -> Result<()> {
2318    use std::fs;
2319    use std::io::Write;
2320
2321    if let Some(parent) = path.parent() {
2322        fs::create_dir_all(parent)
2323            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
2324    }
2325
2326    let temp_path = path.with_extension("tmp");
2327
2328    let mut file = fs::File::create(&temp_path)
2329        .with_context(|| format!("failed to create temp file: {}", temp_path.display()))?;
2330    file.write_all(contents)
2331        .with_context(|| format!("failed to write to temp file: {}", temp_path.display()))?;
2332    file.sync_all()
2333        .with_context(|| format!("failed to sync temp file: {}", temp_path.display()))?;
2334    drop(file);
2335
2336    fs::rename(&temp_path, path)
2337        .with_context(|| format!("failed to rename temp file to: {}", path.display()))?;
2338
2339    Ok(())
2340}
2341
2342/// Persist a snapshot to disk
2343///
2344/// # Atomic Writes
2345///
2346/// Uses temp file + rename pattern for atomic writes.
2347/// Persists a snapshot to disk.
2348///
2349/// When `force` is false, never overwrites an existing snapshot (fails if one already exists
2350/// and differs). When `force` is true, overwrites any existing snapshot.
2351///
2352/// # Errors
2353///
2354/// Returns error if:
2355/// - `force` is false and snapshot file already exists with different content
2356/// - Schema version mismatch (if reading existing file)
2357/// - I/O errors during write
2358pub fn persist_snapshot(repo_root: &Path, snapshot: &Snapshot, force: bool) -> Result<()> {
2359    let snapshot_path = snapshot_path(repo_root, snapshot.commit_sha());
2360
2361    // Normalize through a parse-reserialize cycle to produce a canonical form.
2362    // This handles float serialization quirks where serde_json may parse a float
2363    // string to a slightly different f64 than what was computed (e.g. a 1-ULP
2364    // difference due to the float parser's rounding). Both the on-disk snapshot
2365    // (already round-tripped once) and the freshly-computed snapshot are brought
2366    // to the same canonical representation before comparing.
2367    let canonical_json = Snapshot::from_json(&snapshot.to_json()?)
2368        .context("failed to normalize snapshot for canonical form")?
2369        .to_json()?;
2370
2371    if !force {
2372        if let Some(existing) = load_snapshot(repo_root, snapshot.commit_sha())? {
2373            // Compare canonical forms (both normalized through one parse-reserialize cycle)
2374            if existing.to_json()? == canonical_json {
2375                return Ok(());
2376            }
2377            anyhow::bail!(
2378                "snapshot already exists and differs: {} (snapshots are immutable; use --force to overwrite)",
2379                snapshot_path.display()
2380            );
2381        }
2382    }
2383
2384    // Compress and write atomically (zstd level 3 — fast with good ratio)
2385    let compressed =
2386        zstd::encode_all(canonical_json.as_bytes(), 3).context("failed to compress snapshot")?;
2387    atomic_write_bytes(&snapshot_path, &compressed)
2388        .with_context(|| format!("failed to persist snapshot: {}", snapshot_path.display()))?;
2389
2390    Ok(())
2391}
2392
2393/// Append snapshot entry to index
2394///
2395/// Loads existing index, adds entry, and persists atomically.
2396pub fn append_to_index(repo_root: &Path, snapshot: &Snapshot) -> Result<()> {
2397    let index_path = index_path(repo_root);
2398
2399    // Load existing index or create new
2400    let mut index = Index::load_or_new(&index_path)?;
2401
2402    // Add commit entry
2403    index.add_commit(IndexEntry {
2404        sha: snapshot.commit.sha.clone(),
2405        parents: snapshot.commit.parents.clone(),
2406        timestamp: snapshot.commit.timestamp,
2407    });
2408
2409    // Serialize and write atomically
2410    let json = index.to_json()?;
2411    atomic_write(&index_path, &json)
2412        .with_context(|| format!("failed to update index: {}", index_path.display()))?;
2413
2414    Ok(())
2415}
2416
2417/// Rebuild index from snapshots directory
2418///
2419/// Scans `.hotspots/snapshots/` and rebuilds `index.json` with deterministic ordering.
2420/// Useful for recovery if index is corrupted or missing.
2421///
2422/// # Ordering
2423///
2424/// Index entries are sorted by timestamp (ascending), then SHA (ASCII ascending),
2425/// ensuring byte-for-byte deterministic output.
2426pub fn rebuild_index(repo_root: &Path) -> Result<Index> {
2427    let snapshots_dir = snapshots_dir(repo_root);
2428
2429    if !snapshots_dir.exists() {
2430        return Ok(Index::new());
2431    }
2432
2433    let mut index = Index::new();
2434
2435    // Read all snapshot files
2436    let entries = std::fs::read_dir(&snapshots_dir).with_context(|| {
2437        format!(
2438            "failed to read snapshots directory: {}",
2439            snapshots_dir.display()
2440        )
2441    })?;
2442
2443    for entry_result in entries {
2444        let entry = entry_result?;
2445        let path = entry.path();
2446
2447        // Only process snapshot files (.json.zst or legacy .json)
2448        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2449        if !file_name.ends_with(".json.zst") && !file_name.ends_with(".json") {
2450            continue;
2451        }
2452
2453        // Read and parse snapshot (auto-detects compression)
2454        let snapshot = match read_snapshot_file(&path) {
2455            Ok(s) => s,
2456            Err(e) => {
2457                // Log error but continue (some snapshots may be corrupted)
2458                eprintln!(
2459                    "Warning: failed to parse snapshot {}: {}",
2460                    path.display(),
2461                    e
2462                );
2463                continue;
2464            }
2465        };
2466
2467        // Add to index
2468        index.add_commit(IndexEntry {
2469            sha: snapshot.commit.sha,
2470            parents: snapshot.commit.parents,
2471            timestamp: snapshot.commit.timestamp,
2472        });
2473    }
2474
2475    Ok(index)
2476}
2477
2478#[cfg(test)]
2479mod tests {
2480    use super::*;
2481    use crate::report::MetricsReport;
2482
2483    fn create_test_snapshot() -> Snapshot {
2484        let git_context = GitContext {
2485            head_sha: "abc123".to_string(),
2486            parent_shas: vec!["def456".to_string()],
2487            timestamp: 1705600000,
2488            branch: Some("main".to_string()),
2489            is_detached: false,
2490            message: Some("test commit".to_string()),
2491            author: Some("Test Author".to_string()),
2492            is_fix_commit: Some(false),
2493            is_revert_commit: Some(false),
2494            ticket_ids: vec![],
2495        };
2496
2497        let report = FunctionRiskReport {
2498            file: "src/foo.ts".to_string(),
2499            function: "handler".to_string(),
2500            line: 42,
2501            language: Language::TypeScript,
2502            metrics: MetricsReport {
2503                cc: 5,
2504                nd: 2,
2505                fo: 3,
2506                ns: 1,
2507                loc: 10,
2508            },
2509            risk: RiskReport {
2510                r_cc: 2.0,
2511                r_nd: 1.0,
2512                r_fo: 1.0,
2513                r_ns: 1.0,
2514            },
2515            lrs: 4.8,
2516            band: RiskBand::Moderate,
2517            suppression_reason: None,
2518            patterns: vec![],
2519            pattern_details: None,
2520            callees: vec![],
2521            explanation: None,
2522        };
2523
2524        Snapshot::new(git_context, vec![report])
2525    }
2526
2527    #[test]
2528    fn test_snapshot_serialization() {
2529        let snapshot = create_test_snapshot();
2530
2531        // Serialize
2532        let json = snapshot.to_json().expect("should serialize");
2533        assert!(json.contains("\"schema_version\": 2"));
2534        assert!(json.contains("\"sha\": \"abc123\""));
2535        assert!(json.contains("\"function_id\""));
2536
2537        // Deserialize
2538        let deserialized = Snapshot::from_json(&json).expect("should deserialize");
2539        assert_eq!(deserialized.commit.sha, snapshot.commit.sha);
2540        assert_eq!(deserialized.functions.len(), snapshot.functions.len());
2541    }
2542
2543    #[test]
2544    fn test_function_id_format() {
2545        let snapshot = create_test_snapshot();
2546        assert_eq!(snapshot.functions[0].function_id, "src/foo.ts::handler");
2547    }
2548
2549    #[test]
2550    fn test_snapshot_enricher_with_churn() {
2551        use crate::git::FileChurn;
2552        let snapshot = create_test_snapshot();
2553        let mut churn_map = std::collections::HashMap::new();
2554        churn_map.insert(
2555            "src/foo.ts".to_string(),
2556            FileChurn {
2557                file: "src/foo.ts".to_string(),
2558                lines_added: 10,
2559                lines_deleted: 5,
2560            },
2561        );
2562        let snapshot = SnapshotEnricher::new(snapshot)
2563            .with_churn(&churn_map)
2564            .build();
2565        let churn = snapshot.functions[0]
2566            .churn
2567            .as_ref()
2568            .expect("churn should be set");
2569        assert_eq!(churn.lines_added, 10);
2570        assert_eq!(churn.lines_deleted, 5);
2571        assert_eq!(churn.net_change, 5);
2572    }
2573
2574    #[test]
2575    fn test_snapshot_enricher_enrich_computes_summary() {
2576        let snapshot = create_test_snapshot();
2577        let snapshot = SnapshotEnricher::new(snapshot).enrich(None, 75).build();
2578        let summary = snapshot.summary.as_ref().expect("summary should be set");
2579        assert_eq!(summary.total_functions, 1);
2580    }
2581
2582    #[test]
2583    fn test_snapshot_enricher_enrich_computes_percentiles() {
2584        let snapshot = create_test_snapshot();
2585        let snapshot = SnapshotEnricher::new(snapshot).enrich(None, 75).build();
2586        assert!(snapshot.functions[0].percentile.is_some());
2587    }
2588
2589    #[test]
2590    fn test_snapshot_enricher_build_passthrough() {
2591        let snapshot = create_test_snapshot();
2592        let built = SnapshotEnricher::new(snapshot.clone()).build();
2593        assert_eq!(
2594            built.functions[0].function_id,
2595            snapshot.functions[0].function_id
2596        );
2597        assert_eq!(built.commit.sha, snapshot.commit.sha);
2598    }
2599
2600    #[test]
2601    fn test_index_ordering() {
2602        let mut index = Index::new();
2603
2604        index.add_commit(IndexEntry {
2605            sha: "zzz".to_string(),
2606            parents: vec![],
2607            timestamp: 2000,
2608        });
2609
2610        index.add_commit(IndexEntry {
2611            sha: "aaa".to_string(),
2612            parents: vec![],
2613            timestamp: 1000,
2614        });
2615
2616        index.add_commit(IndexEntry {
2617            sha: "mmm".to_string(),
2618            parents: vec![],
2619            timestamp: 2000,
2620        });
2621
2622        // Should be sorted by timestamp, then SHA
2623        assert_eq!(index.commits[0].sha, "aaa");
2624        assert_eq!(index.commits[1].sha, "mmm");
2625        assert_eq!(index.commits[2].sha, "zzz");
2626    }
2627
2628    // Cache key for the test snapshot's single function:
2629    //   sha="abc123", file="src/foo.ts", line=42, loc=10 → end=51
2630    fn test_cache_key() -> String {
2631        crate::touch_cache::cache_key("abc123", "src/foo.ts", 42, 51)
2632    }
2633
2634    #[test]
2635    fn test_populate_touch_metrics_applies_cache_hits() {
2636        let snapshot = create_test_snapshot();
2637        let mut cache = crate::touch_cache::TouchCache::new();
2638        cache.insert(test_cache_key(), (7, Some(3)));
2639
2640        let dir = tempfile::tempdir().unwrap();
2641        crate::touch_cache::write_touch_cache(dir.path(), &cache).unwrap();
2642
2643        let mut snapshot = snapshot;
2644        snapshot
2645            .populate_touch_metrics(dir.path(), crate::snapshot::TouchMode::PerFunction, None)
2646            .unwrap();
2647
2648        assert_eq!(snapshot.functions[0].touch_count_30d, Some(7));
2649        assert_eq!(snapshot.functions[0].days_since_last_change, Some(3));
2650    }
2651
2652    #[test]
2653    fn test_populate_touch_metrics_progress_fires_on_all_cache_hits() {
2654        use std::sync::{Arc, Mutex};
2655
2656        let snapshot = create_test_snapshot();
2657        let mut cache = crate::touch_cache::TouchCache::new();
2658        cache.insert(test_cache_key(), (3, Some(1)));
2659
2660        let dir = tempfile::tempdir().unwrap();
2661        crate::touch_cache::write_touch_cache(dir.path(), &cache).unwrap();
2662
2663        let calls: Arc<Mutex<Vec<(usize, usize)>>> = Arc::new(Mutex::new(Vec::new()));
2664        let calls_ref = calls.clone();
2665
2666        let mut snapshot = snapshot;
2667        snapshot
2668            .populate_touch_metrics(
2669                dir.path(),
2670                crate::snapshot::TouchMode::PerFunction,
2671                Some(&|i, n| {
2672                    calls_ref.lock().unwrap().push((i, n));
2673                }),
2674            )
2675            .unwrap();
2676
2677        let calls = calls.lock().unwrap();
2678        // Phase 1 fires once; misses=0 so we return early with (total, total)
2679        assert!(!calls.is_empty(), "progress should have been called");
2680        assert_eq!(*calls.last().unwrap(), (1, 1));
2681    }
2682
2683    #[test]
2684    fn test_populate_touch_metrics_progress_fires_per_miss() {
2685        // When there are cache misses, progress_fn should be called once per
2686        // resolved miss in the phase-3 drain loop, not just at the end.
2687        use std::sync::{Arc, Mutex};
2688
2689        let snapshot = create_test_snapshot();
2690        // Empty cache → 1 function = 1 miss
2691        let dir = tempfile::tempdir().unwrap();
2692
2693        let calls: Arc<Mutex<Vec<(usize, usize)>>> = Arc::new(Mutex::new(Vec::new()));
2694        let calls_ref = calls.clone();
2695
2696        let mut snapshot = snapshot;
2697        // populate_touch_metrics will shell out to git for the miss; in a temp
2698        // dir with no git repo it will return (0, None) via the error path.
2699        let _ = snapshot.populate_touch_metrics(
2700            dir.path(),
2701            crate::snapshot::TouchMode::PerFunction,
2702            Some(&|i, n| {
2703                calls_ref.lock().unwrap().push((i, n));
2704            }),
2705        );
2706
2707        let calls = calls.lock().unwrap();
2708        // Phase 1: (0, 1) — 0 hits out of 1 total
2709        // Phase 3: (1, 1) — 1 resolved miss
2710        assert!(
2711            calls.len() >= 2,
2712            "expected at least 2 progress calls, got {}",
2713            calls.len()
2714        );
2715        assert_eq!(calls[0], (0, 1));
2716        assert_eq!(*calls.last().unwrap(), (1, 1));
2717    }
2718}