1use 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#[derive(Debug, Clone, PartialEq)]
27pub enum TouchMode {
28 File,
32 PerFunction,
35 Hybrid { threshold: usize },
39}
40
41pub const SNAPSHOT_SCHEMA_VERSION: u32 = 2;
45const SNAPSHOT_SCHEMA_MIN_VERSION: u32 = 1;
46
47const INDEX_SCHEMA_VERSION: u32 = 1;
49
50#[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#[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#[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#[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
162 pub driver: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
167 pub driver_detail: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
175 pub quadrant: Option<String>,
176 #[serde(skip_serializing_if = "Vec::is_empty", default)]
179 pub patterns: Vec<String>,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub pattern_details: Option<Vec<crate::patterns::PatternDetail>>,
183 #[serde(skip_serializing_if = "Option::is_none")]
191 pub subsystem: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
199 pub authors_90d: Option<u32>,
200 #[serde(skip_serializing_if = "Option::is_none")]
207 pub directed_coupling: Option<f64>,
208 #[serde(skip_serializing_if = "Option::is_none")]
212 pub jaccard_label_stability: Option<f64>,
213 #[serde(skip_serializing_if = "Option::is_none")]
219 pub convention_bug_fix_count: Option<u32>,
220 #[serde(skip_serializing_if = "Option::is_none")]
225 pub burst_score: Option<f64>,
226 #[serde(skip_serializing_if = "Option::is_none")]
230 pub commit_count: Option<u32>,
231 #[serde(skip_serializing_if = "Option::is_none")]
235 pub author_count: Option<u32>,
236 #[serde(skip_serializing_if = "Option::is_none")]
240 pub author_entropy: Option<f64>,
241 #[serde(skip_serializing_if = "Option::is_none")]
245 pub isolation_rate: Option<f64>,
246 #[serde(skip_serializing_if = "Option::is_none")]
250 pub age_days: Option<f64>,
251 #[serde(skip_serializing_if = "Option::is_none")]
255 pub last_touch_days: Option<f64>,
256 #[serde(skip_serializing_if = "Option::is_none")]
260 pub explanation: Option<String>,
261}
262
263#[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#[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 #[serde(default)]
282 pub betweenness_approximate: bool,
283}
284
285#[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#[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
332 pub compaction_level: Option<u32>,
333 pub commits: Vec<IndexEntry>,
334}
335
336const 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
350const SKIP_DIRS: &[&str] = &[
352 "node_modules",
353 ".git",
354 "__pycache__",
355 ".venv",
356 "venv",
357 "dist",
358 "build",
359 "out",
360 "target",
361];
362
363fn 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 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 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
410fn 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 pub fn new(git_context: GitContext, reports: Vec<FunctionRiskReport>) -> Self {
456 let mut functions: Vec<FunctionSnapshot> = reports
458 .into_iter()
459 .map(|report| {
460 let normalized_file = report.file.replace('\\', "/");
462
463 let function_symbol = if report.function.starts_with("<anonymous>") {
466 "<anonymous>"
467 } else {
468 &report.function
469 };
470
471 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, touch_count_30d: None, days_since_last_change: None, callgraph: None, 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 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, }
526 }
527
528 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 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 pub fn populate_authors_90d(&mut self, repo_root: &Path) {
565 use std::collections::{HashMap, HashSet};
566 use std::process::Command;
567
568 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, };
596
597 for &i in indices {
598 self.functions[i].authors_90d = Some(count);
599 }
600 }
601 }
602
603 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 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 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 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 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 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 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 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 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 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 const CHUNK_SIZE: usize = 512;
837 let mut completed = 0usize;
838
839 for chunk in indices.chunks(CHUNK_SIZE) {
840 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let fan_in_map = call_graph.build_fan_in_map();
1120
1121 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 for function in &mut self.functions {
1132 let function_id = &function.function_id;
1133
1134 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 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 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 let churn = function
1186 .churn
1187 .as_ref()
1188 .map(|c| (c.lines_added, c.lines_deleted));
1189
1190 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 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 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 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 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 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 pub fn compute_percentiles(&mut self) {
1312 let n = self.functions.len();
1313 if n == 0 {
1314 return;
1315 }
1316
1317 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 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 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 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 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 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 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 pub fn to_json(&self) -> Result<String> {
1506 serde_json::to_string_pretty(self).context("failed to serialize snapshot to JSON")
1507 }
1508
1509 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 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 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 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 pub fn commit_sha(&self) -> &str {
1560 &self.commit.sha
1561 }
1562}
1563
1564fn 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
1579fn 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
1598fn 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
1634pub struct DimensionThresholds {
1637 pub cc_high: usize, pub cc_med: usize, pub cc_low: usize, pub nd_high: usize, pub fan_out_high: usize, pub fan_in_high: usize, pub touch_high: usize, pub touch_med: usize, }
1646
1647pub 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
1715pub 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
1728pub 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
1756pub fn driver_action(label: &str) -> &'static str {
1758 driver_action_for_quadrant(label, "")
1759}
1760
1761pub 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
1799fn 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
1859pub struct SnapshotEnricher {
1861 snapshot: Snapshot,
1862 betweenness_approximate: bool,
1863}
1864
1865impl SnapshotEnricher {
1866 pub fn new(snapshot: Snapshot) -> Self {
1868 SnapshotEnricher {
1869 snapshot,
1870 betweenness_approximate: false,
1871 }
1872 }
1873
1874 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 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 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 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 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 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 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 pub fn build(self) -> Snapshot {
1971 self.snapshot
1972 }
1973}
1974
1975impl Index {
1976 pub fn new() -> Self {
1978 Index {
1979 schema_version: INDEX_SCHEMA_VERSION,
1980 compaction_level: Some(0), commits: Vec::new(),
1982 }
1983 }
1984
1985 pub fn compaction_level(&self) -> u32 {
1987 self.compaction_level.unwrap_or(0)
1988 }
1989
1990 pub fn set_compaction_level(&mut self, level: u32) {
1992 self.compaction_level = Some(level);
1993 }
1994
1995 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 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 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 pub fn to_json(&self) -> Result<String> {
2025 serde_json::to_string_pretty(self).context("failed to serialize index to JSON")
2026 }
2027
2028 pub fn add_commit(&mut self, entry: IndexEntry) {
2033 if !self.commits.iter().any(|c| c.sha == entry.sha) {
2035 self.commits.push(entry);
2036
2037 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 pub fn remove_commit(&mut self, sha: &str) {
2048 self.commits.retain(|c| c.sha != sha);
2049 }
2050
2051 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
2063const DELTA_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
2065
2066#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2071#[serde(rename_all = "snake_case")]
2072pub struct DeltaSnapshot {
2073 pub schema_version: u32,
2074 pub snapshot_schema_version: u32,
2076 pub commit: CommitInfo,
2077 pub analysis: AnalysisInfo,
2078 pub base_sha: String,
2080 pub added: Vec<FunctionSnapshot>,
2081 pub modified: Vec<FunctionSnapshot>,
2082 pub removed: Vec<String>,
2083 #[serde(skip_serializing_if = "Option::is_none")]
2086 pub summary: Option<SnapshotSummary>,
2087}
2088
2089pub 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
2146pub 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
2179pub 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
2189pub fn hotspots_dir(repo_root: &Path) -> PathBuf {
2191 repo_root.join(".hotspots")
2192}
2193
2194pub fn snapshots_dir(repo_root: &Path) -> PathBuf {
2196 hotspots_dir(repo_root).join("snapshots")
2197}
2198
2199pub fn index_path(repo_root: &Path) -> PathBuf {
2201 hotspots_dir(repo_root).join("index.json")
2202}
2203
2204pub fn snapshot_path(repo_root: &Path, commit_sha: &str) -> PathBuf {
2206 snapshots_dir(repo_root).join(format!("{}.json.zst", commit_sha))
2207}
2208
2209pub 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
2214pub 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
2229pub fn load_snapshot(repo_root: &Path, commit_sha: &str) -> Result<Option<Snapshot>> {
2235 if let Some(path) = snapshot_path_existing(repo_root, commit_sha) {
2237 return Ok(Some(read_snapshot_file(&path)?));
2238 }
2239
2240 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
2263fn 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
2286pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
2288 use std::fs;
2289 use std::io::Write;
2290
2291 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 let temp_path = path.with_extension("tmp");
2299
2300 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 fs::rename(&temp_path, path)
2311 .with_context(|| format!("failed to rename temp file to: {}", path.display()))?;
2312
2313 Ok(())
2314}
2315
2316pub 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
2342pub fn persist_snapshot(repo_root: &Path, snapshot: &Snapshot, force: bool) -> Result<()> {
2359 let snapshot_path = snapshot_path(repo_root, snapshot.commit_sha());
2360
2361 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 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 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
2393pub fn append_to_index(repo_root: &Path, snapshot: &Snapshot) -> Result<()> {
2397 let index_path = index_path(repo_root);
2398
2399 let mut index = Index::load_or_new(&index_path)?;
2401
2402 index.add_commit(IndexEntry {
2404 sha: snapshot.commit.sha.clone(),
2405 parents: snapshot.commit.parents.clone(),
2406 timestamp: snapshot.commit.timestamp,
2407 });
2408
2409 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
2417pub 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 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 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 let snapshot = match read_snapshot_file(&path) {
2455 Ok(s) => s,
2456 Err(e) => {
2457 eprintln!(
2459 "Warning: failed to parse snapshot {}: {}",
2460 path.display(),
2461 e
2462 );
2463 continue;
2464 }
2465 };
2466
2467 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 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 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 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 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 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 use std::sync::{Arc, Mutex};
2688
2689 let snapshot = create_test_snapshot();
2690 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 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 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}