Skip to main content

fallow_engine/
vital_signs.rs

1//! Vital signs computation and snapshot persistence.
2//!
3//! Vital signs are a fixed set of project-wide metrics computed from available
4//! health data. They are always shown as a summary in the health report and can
5//! be persisted to `.fallow/snapshots/` for Phase 2b trend tracking.
6
7use std::path::{Path, PathBuf};
8
9use crate::git_env::clear_ambient_git_env;
10
11/// Number of seconds in one day.
12const SECS_PER_DAY: u64 = 86_400;
13
14/// Fraction of the file population used for hotspot concentration.
15const HOTSPOT_TOP_FILE_FRACTION: f64 = 0.01;
16
17/// Maximum project health penalty for thresholded hotspots.
18const HOTSPOT_MAX_PENALTY: f64 = 10.0;
19
20use fallow_output::{
21    DEFAULT_CYCLOMATIC_CRITICAL, FileHealthScore, HEALTH_SCORE_FORMULA_VERSION,
22    HOTSPOT_SCORE_THRESHOLD, HealthScore, HealthScorePenalties, HealthTrend, HotspotEntry,
23    RiskProfile, SNAPSHOT_SCHEMA_VERSION, TrendCount, TrendDirection, TrendMetric, TrendPoint,
24    VitalSigns, VitalSignsCounts, VitalSignsSnapshot, letter_grade,
25};
26
27/// Data sources for computing vital signs.
28///
29/// Fields are `Option` because not all pipelines run in every health invocation.
30pub(crate) struct VitalSignsInput<'a> {
31    /// All parsed modules (always available).
32    pub(crate) modules: &'a [crate::source::ModuleInfo],
33    /// Optional file-id allowlist used to restrict per-module aggregates
34    /// (cyclomatic distribution, total LOC, unit profiles) to a subset.
35    /// Used by `--workspace` and `--group-by` to scope project-wide metrics
36    /// to a single workspace package without re-parsing.
37    /// `None` includes every module in `modules`.
38    pub(crate) module_filter: Option<&'a rustc_hash::FxHashSet<crate::discover::FileId>>,
39    /// File health scores (available when file_scores/hotspots/targets are computed).
40    pub(crate) file_scores: Option<&'a [FileHealthScore]>,
41    /// Hotspot entries (available when hotspots are computed).
42    pub(crate) hotspots: Option<&'a [HotspotEntry]>,
43    /// Total discovered files (already scoped to the workspace when `--workspace` is set).
44    pub(crate) total_files: usize,
45    /// Analysis results (available when file_scores pipeline ran). When a
46    /// `module_filter` is also set, callers should pass workspace-scoped
47    /// counts here so `dead_*_pct` denominators line up with the rest of the
48    /// metrics.
49    pub(crate) analysis_counts: Option<AnalysisCounts>,
50}
51
52impl<'a> VitalSignsInput<'a> {
53    /// Iterate the modules selected by `module_filter`.
54    fn selected_modules(&self) -> impl Iterator<Item = &'a crate::source::ModuleInfo> + '_ {
55        let filter = self.module_filter;
56        self.modules
57            .iter()
58            .filter(move |m| filter.is_none_or(|set| set.contains(&m.file_id)))
59    }
60}
61
62/// Aggregate counts from the analysis pipeline.
63#[derive(Clone, Copy)]
64pub struct AnalysisCounts {
65    pub(crate) total_exports: usize,
66    pub(crate) dead_files: usize,
67    pub(crate) dead_exports: usize,
68    pub(crate) unused_deps: usize,
69    pub(crate) circular_deps: usize,
70    pub(crate) total_deps: usize,
71}
72
73/// Every unit's cyclomatic score, sorted, feeding the average, the critical
74/// share, and p90. The synthetic module-scope unit is included: its decision
75/// points are real branching, and leaving them out is what let a file's average
76/// read low while its top level was a guard ladder.
77fn collect_sorted_cyclomatic(input: &VitalSignsInput<'_>) -> Vec<u16> {
78    let mut values: Vec<u16> = input
79        .selected_modules()
80        .flat_map(|m| m.complexity.iter().map(|c| c.cyclomatic))
81        .collect();
82    values.sort_unstable();
83    values
84}
85
86fn average_cyclomatic(all_cyclomatic: &[u16]) -> f64 {
87    if all_cyclomatic.is_empty() {
88        return 0.0;
89    }
90
91    let sum: u64 = all_cyclomatic.iter().map(|&c| u64::from(c)).sum();
92    (sum as f64 / all_cyclomatic.len() as f64 * 10.0).round() / 10.0
93}
94
95fn critical_complexity_pct(all_cyclomatic: &[u16]) -> Option<f64> {
96    if all_cyclomatic.is_empty() {
97        return None;
98    }
99
100    let critical_count = all_cyclomatic
101        .iter()
102        .filter(|&&c| c >= DEFAULT_CYCLOMATIC_CRITICAL)
103        .count();
104    Some((critical_count as f64 / all_cyclomatic.len() as f64 * 1000.0).round() / 10.0)
105}
106
107#[expect(
108    clippy::cast_sign_loss,
109    reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
110)]
111#[expect(
112    clippy::cast_possible_truncation,
113    reason = "percentile index is bounded by the cyclomatic collection length"
114)]
115fn p90_cyclomatic(all_cyclomatic: &[u16]) -> u32 {
116    if all_cyclomatic.is_empty() {
117        return 0;
118    }
119
120    let idx = (all_cyclomatic.len() as f64 * 0.9).ceil() as usize;
121    let idx = idx.min(all_cyclomatic.len()) - 1;
122    u32::from(all_cyclomatic[idx])
123}
124
125#[expect(
126    clippy::cast_possible_truncation,
127    reason = "analysis counts are bounded by project size and emitted as compact u32 metrics"
128)]
129fn analysis_count_vitals(
130    counts: Option<&AnalysisCounts>,
131    total_files: usize,
132) -> (Option<f64>, Option<f64>, Option<u32>, Option<u32>) {
133    let Some(counts) = counts else {
134        return (None, None, None, None);
135    };
136
137    let dead_file_pct = if total_files > 0 {
138        Some((counts.dead_files as f64 / total_files as f64 * 1000.0).round() / 10.0)
139    } else {
140        Some(0.0)
141    };
142    let dead_export_pct = if counts.total_exports > 0 {
143        Some((counts.dead_exports as f64 / counts.total_exports as f64 * 1000.0).round() / 10.0)
144    } else {
145        Some(0.0)
146    };
147
148    (
149        dead_file_pct,
150        dead_export_pct,
151        Some(counts.unused_deps as u32),
152        Some(counts.circular_deps as u32),
153    )
154}
155
156struct SelectedModuleMetrics {
157    total_loc: u64,
158    line_counts: Vec<u32>,
159    param_counts: Vec<u8>,
160    cyclomatic_population: fallow_output::CyclomaticPopulation,
161}
162
163fn selected_module_metrics(input: &VitalSignsInput<'_>) -> SelectedModuleMetrics {
164    let mut total_loc = 0;
165    let mut line_counts = Vec::new();
166    let mut param_counts = Vec::new();
167    let mut cyclomatic_population = fallow_output::CyclomaticPopulation::default();
168
169    for module in input.selected_modules() {
170        total_loc += module.line_offsets.len() as u64;
171        // The synthetic module-scope unit is excluded from both distributions.
172        // It has no parameter list, and its size is the distance between its
173        // first and last decision point rather than a body anyone can shorten,
174        // so it is not a refactoring signal in the unit-size profile.
175        for unit in &module.complexity {
176            let is_module = fallow_types::extract::is_synthetic_module_unit(&unit.name);
177            let population = if is_module {
178                &mut cyclomatic_population.modules
179            } else if fallow_types::extract::is_synthetic_template_unit(&unit.name) {
180                &mut cyclomatic_population.templates
181            } else {
182                &mut cyclomatic_population.functions
183            };
184            population.count += 1;
185            population.sum += u64::from(unit.cyclomatic);
186            population.max = Some(population.max.unwrap_or_default().max(unit.cyclomatic));
187            if is_module {
188                continue;
189            }
190            line_counts.push(unit.line_count);
191            param_counts.push(unit.param_count);
192        }
193    }
194
195    SelectedModuleMetrics {
196        total_loc,
197        line_counts,
198        param_counts,
199        cyclomatic_population,
200    }
201}
202
203fn vital_sign_counts(input: &VitalSignsInput<'_>, total_loc: u64) -> Option<VitalSignsCounts> {
204    input.analysis_counts.as_ref().map(|ac| VitalSignsCounts {
205        total_files: input.total_files,
206        total_exports: ac.total_exports,
207        dead_files: ac.dead_files,
208        dead_exports: ac.dead_exports,
209        duplicated_lines: None,
210        total_lines: Some(total_loc as usize),
211        files_scored: input.file_scores.map(<[_]>::len),
212        total_deps: ac.total_deps,
213    })
214}
215
216/// Compute vital signs from available health data.
217pub(crate) fn compute_vital_signs(input: &VitalSignsInput<'_>) -> VitalSigns {
218    let all_cyclomatic = collect_sorted_cyclomatic(input);
219    let avg_cyclomatic = average_cyclomatic(&all_cyclomatic);
220    let critical_complexity_pct = critical_complexity_pct(&all_cyclomatic);
221    let p90_cyclomatic = p90_cyclomatic(&all_cyclomatic);
222
223    let (dead_file_pct, dead_export_pct, unused_dep_count, circular_dep_count) =
224        analysis_count_vitals(input.analysis_counts.as_ref(), input.total_files);
225    let unused_deps_per_k_files =
226        unused_dep_count.map(|count| per_k_files(count, input.total_files));
227    let circular_deps_per_k_files =
228        circular_dep_count.map(|count| per_k_files(count, input.total_files));
229
230    let (maintainability_avg, maintainability_low_pct) = maintainability_vitals(input.file_scores);
231
232    let (hotspot_count, hotspot_top_pct_count) = hotspot_vitals(input.hotspots, input.total_files);
233
234    let module_metrics = selected_module_metrics(input);
235    let counts = vital_sign_counts(input, module_metrics.total_loc);
236    let functions_over_60_loc_per_k = functions_over_60_loc_per_k(&module_metrics.line_counts);
237    let unit_size_profile = unit_size_profile(&module_metrics.line_counts);
238
239    let unit_interfacing_profile =
240        unit_interfacing_profile(&module_metrics.param_counts, &all_cyclomatic);
241
242    let (p95_fan_in, coupling_high_pct) = if let Some(scores) = input.file_scores {
243        compute_coupling_concentration(scores)
244    } else {
245        (None, None)
246    };
247
248    VitalSigns {
249        dead_file_pct,
250        dead_export_pct,
251        avg_cyclomatic,
252        critical_complexity_pct,
253        p90_cyclomatic,
254        cyclomatic_population: Some(module_metrics.cyclomatic_population),
255        duplication_pct: None, // Lazy: only set if duplication pipeline was run
256        hotspot_count,
257        hotspot_top_pct_count,
258        maintainability_avg,
259        maintainability_low_pct,
260        unused_dep_count,
261        unused_deps_per_k_files,
262        circular_dep_count,
263        circular_deps_per_k_files,
264        counts,
265        unit_size_profile,
266        functions_over_60_loc_per_k,
267        unit_interfacing_profile,
268        p95_fan_in,
269        coupling_high_pct,
270        // Set post-construction from the whole-project analysis results (only
271        // when the opt-in prop-drilling rule is enabled); see health/mod.rs.
272        prop_drilling_chain_count: None,
273        prop_drilling_max_depth: None,
274        // Set post-construction from the whole-project render fan-in metric
275        // (whenever React is declared, the core metric carries the aggregates);
276        // see health/mod.rs.
277        p95_render_fan_in: None,
278        render_fan_in_high_pct: None,
279        max_render_fan_in: None,
280        top_render_fan_in: Vec::new(),
281        total_loc: module_metrics.total_loc,
282    }
283}
284
285fn per_k_files(count: u32, total_files: usize) -> f64 {
286    if total_files == 0 {
287        0.0
288    } else {
289        (f64::from(count) / total_files as f64 * 10_000.0).round() / 10.0
290    }
291}
292
293fn maintainability_vitals(scores: Option<&[FileHealthScore]>) -> (Option<f64>, Option<f64>) {
294    let Some(scores) = scores.filter(|scores| !scores.is_empty()) else {
295        return (None, None);
296    };
297    let sum: f64 = scores.iter().map(|s| s.maintainability_index).sum();
298    let low_count = scores
299        .iter()
300        .filter(|s| s.maintainability_index < 70.0)
301        .count();
302    (
303        Some((sum / scores.len() as f64 * 10.0).round() / 10.0),
304        Some((low_count as f64 / scores.len() as f64 * 1000.0).round() / 10.0),
305    )
306}
307
308#[expect(
309    clippy::cast_sign_loss,
310    reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
311)]
312fn hotspot_vitals(
313    hotspots: Option<&[HotspotEntry]>,
314    total_files: usize,
315) -> (Option<u32>, Option<u32>) {
316    let hotspot_count = hotspots.map(|entries| {
317        entries
318            .iter()
319            .filter(|e| e.score >= HOTSPOT_SCORE_THRESHOLD)
320            .count() as u32
321    });
322    let hotspot_top_pct_count = hotspots.map(|entries| {
323        if total_files == 0 || entries.is_empty() {
324            return 0;
325        }
326        let top_count = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION).ceil() as usize;
327        entries
328            .iter()
329            .take(top_count.max(1))
330            .filter(|entry| entry.score > 0.0)
331            .count() as u32
332    });
333    (hotspot_count, hotspot_top_pct_count)
334}
335
336fn functions_over_60_loc_per_k(line_counts: &[u32]) -> Option<f64> {
337    if line_counts.is_empty() {
338        return None;
339    }
340    let over_60 = line_counts
341        .iter()
342        .filter(|&&line_count| line_count > 60)
343        .count();
344    Some((over_60 as f64 / line_counts.len() as f64 * 10_000.0).round() / 10.0)
345}
346
347fn unit_size_profile(line_counts: &[u32]) -> Option<RiskProfile> {
348    (!line_counts.is_empty()).then(|| compute_size_risk_profile(line_counts))
349}
350
351fn unit_interfacing_profile(param_counts: &[u8], all_cyclomatic: &[u16]) -> Option<RiskProfile> {
352    if all_cyclomatic.is_empty() {
353        return None;
354    }
355    Some(compute_interfacing_risk_profile(param_counts))
356}
357
358/// Compute unit size risk profile from function line counts.
359///
360/// Bins: low risk (1-15 LOC), medium risk (16-30), high risk (31-60), very high risk (>60).
361fn compute_size_risk_profile(line_counts: &[u32]) -> RiskProfile {
362    if line_counts.is_empty() {
363        return RiskProfile {
364            low_risk: 0.0,
365            medium_risk: 0.0,
366            high_risk: 0.0,
367            very_high_risk: 0.0,
368        };
369    }
370    let total = line_counts.len() as f64;
371    let low = line_counts.iter().filter(|&&lc| lc <= 15).count() as f64;
372    let medium = line_counts
373        .iter()
374        .filter(|&&lc| (16..=30).contains(&lc))
375        .count() as f64;
376    let high = line_counts
377        .iter()
378        .filter(|&&lc| (31..=60).contains(&lc))
379        .count() as f64;
380    let very_high = line_counts.iter().filter(|&&lc| lc > 60).count() as f64;
381    RiskProfile {
382        low_risk: (low / total * 1000.0).round() / 10.0,
383        medium_risk: (medium / total * 1000.0).round() / 10.0,
384        high_risk: (high / total * 1000.0).round() / 10.0,
385        very_high_risk: (very_high / total * 1000.0).round() / 10.0,
386    }
387}
388
389/// Compute unit interfacing risk profile from function parameter counts.
390///
391/// Bins: low risk (0-2 params), medium risk (3-4), high risk (5-6), very high risk (>=7).
392fn compute_interfacing_risk_profile(param_counts: &[u8]) -> RiskProfile {
393    if param_counts.is_empty() {
394        return RiskProfile {
395            low_risk: 0.0,
396            medium_risk: 0.0,
397            high_risk: 0.0,
398            very_high_risk: 0.0,
399        };
400    }
401    let total = param_counts.len() as f64;
402    let low = param_counts.iter().filter(|&&pc| pc <= 2).count() as f64;
403    let medium = param_counts
404        .iter()
405        .filter(|&&pc| (3..=4).contains(&pc))
406        .count() as f64;
407    let high = param_counts
408        .iter()
409        .filter(|&&pc| (5..=6).contains(&pc))
410        .count() as f64;
411    let very_high = param_counts.iter().filter(|&&pc| pc >= 7).count() as f64;
412    RiskProfile {
413        low_risk: (low / total * 1000.0).round() / 10.0,
414        medium_risk: (medium / total * 1000.0).round() / 10.0,
415        high_risk: (high / total * 1000.0).round() / 10.0,
416        very_high_risk: (very_high / total * 1000.0).round() / 10.0,
417    }
418}
419
420/// Compute coupling concentration from file health scores.
421///
422/// Returns (p95_fan_in, coupling_high_pct) where coupling_high_pct is the
423/// percentage of files with fan-in above the effective threshold (max(p95_fan_in, 10)).
424///
425/// The component-graph analogue (render fan-in concentration:
426/// `p95_render_fan_in` / `render_fan_in_high_pct` / `max_render_fan_in`) is
427/// computed in the `analyze::render_fan_in` module of `fallow_core`, which has
428/// the resolved-module graph. It uses the same p95 and floor math over the
429/// per-component distinct-parents distribution; change both together. The
430/// result is assigned onto `VitalSigns` in
431/// `health/vital_data.rs::prepare_health_vital_data`.
432#[expect(
433    clippy::cast_sign_loss,
434    reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
435)]
436#[expect(
437    clippy::cast_possible_truncation,
438    reason = "fan-in values are bounded by project size"
439)]
440fn compute_coupling_concentration(scores: &[FileHealthScore]) -> (Option<u32>, Option<f64>) {
441    if scores.is_empty() {
442        return (None, None);
443    }
444    let mut fan_ins: Vec<usize> = scores.iter().map(|s| s.fan_in).collect();
445    fan_ins.sort_unstable();
446    let idx = (fan_ins.len() as f64 * 0.95).ceil() as usize;
447    let idx = idx.min(fan_ins.len()) - 1;
448    let p95 = fan_ins[idx] as u32;
449
450    let threshold = (p95 as usize).max(10);
451    let high_count = fan_ins.iter().filter(|&&fi| fi > threshold).count();
452    let high_pct = (high_count as f64 / fan_ins.len() as f64 * 1000.0).round() / 10.0;
453
454    (Some(p95), Some(high_pct))
455}
456
457/// Compute a project-level health score from vital signs.
458///
459/// The score starts at 100 and subtracts penalties for each metric.
460/// Missing metrics (from pipelines that didn't run) don't penalize.
461/// `total_files` is used to normalize the hotspot count penalty.
462pub(crate) fn compute_health_score(vs: &VitalSigns, total_files: usize) -> HealthScore {
463    let penalties = compute_health_score_penalties(vs, total_files);
464    let score = apply_health_score_penalties(&penalties);
465    let grade = letter_grade(score);
466
467    HealthScore {
468        formula_version: HEALTH_SCORE_FORMULA_VERSION,
469        score,
470        grade,
471        penalties,
472    }
473}
474
475fn compute_health_score_penalties(vs: &VitalSigns, total_files: usize) -> HealthScorePenalties {
476    HealthScorePenalties {
477        dead_files: vs.dead_file_pct.map(|pct| round1((pct * 0.2).min(15.0))),
478        dead_exports: vs.dead_export_pct.map(|pct| round1((pct * 0.2).min(15.0))),
479        complexity: complexity_penalty(vs),
480        p90_complexity: p90_complexity_penalty(vs),
481        maintainability: maintainability_penalty(vs),
482        hotspots: hotspot_penalty(vs, total_files),
483        unused_deps: dependency_count_penalty(
484            vs.unused_deps_per_k_files,
485            vs.unused_dep_count,
486            25.0,
487            10.0,
488        ),
489        circular_deps: dependency_count_penalty(
490            vs.circular_deps_per_k_files,
491            vs.circular_dep_count,
492            25.0,
493            10.0,
494        ),
495        unit_size: unit_size_penalty(vs),
496        coupling: coupling_penalty(vs),
497        duplication: vs
498            .duplication_pct
499            .map(|dp| round1((dp - 5.0).clamp(0.0, 10.0))),
500        prop_drilling: prop_drilling_penalty(vs),
501    }
502}
503
504fn apply_health_score_penalties(penalties: &HealthScorePenalties) -> f64 {
505    let mut score = 100.0_f64;
506
507    subtract_optional_penalty(&mut score, penalties.dead_files);
508    subtract_optional_penalty(&mut score, penalties.dead_exports);
509    score -= penalties.complexity;
510    score -= penalties.p90_complexity;
511    subtract_optional_penalty(&mut score, penalties.maintainability);
512    subtract_optional_penalty(&mut score, penalties.hotspots);
513    subtract_optional_penalty(&mut score, penalties.unused_deps);
514    subtract_optional_penalty(&mut score, penalties.circular_deps);
515    subtract_optional_penalty(&mut score, penalties.unit_size);
516    subtract_optional_penalty(&mut score, penalties.coupling);
517    subtract_optional_penalty(&mut score, penalties.duplication);
518    subtract_optional_penalty(&mut score, penalties.prop_drilling);
519
520    round1(score).clamp(0.0, 100.0)
521}
522
523/// Small capped penalty for prop-drilling chains, sized like the coupling
524/// penalty (~5pt cap). Each located chain costs 1pt up to the cap; a deeper
525/// chain does not cost more (depth is descriptive, not a tunable threshold).
526/// `None` (no penalty) unless the opt-in `prop-drilling` rule populated the
527/// count, so the score is unchanged by default.
528fn prop_drilling_penalty(vs: &VitalSigns) -> Option<f64> {
529    vs.prop_drilling_chain_count
530        .map(|count| round1((f64::from(count) * 1.0).min(5.0)))
531}
532
533fn round1(value: f64) -> f64 {
534    (value * 10.0).round() / 10.0
535}
536
537fn subtract_optional_penalty(score: &mut f64, penalty: Option<f64>) {
538    if let Some(penalty) = penalty {
539        *score -= penalty;
540    }
541}
542
543fn complexity_penalty(vs: &VitalSigns) -> f64 {
544    if let Some(critical_pct) = vs.critical_complexity_pct {
545        round1((critical_pct * 4.0).min(20.0))
546    } else {
547        round1(((vs.avg_cyclomatic - 1.5).max(0.0) * 5.0).min(20.0))
548    }
549}
550
551fn p90_complexity_penalty(vs: &VitalSigns) -> f64 {
552    if vs.critical_complexity_pct.is_some() {
553        0.0
554    } else {
555        round1((f64::from(vs.p90_cyclomatic) - 10.0).clamp(0.0, 10.0))
556    }
557}
558
559fn maintainability_penalty(vs: &VitalSigns) -> Option<f64> {
560    if let Some(low_pct) = vs.maintainability_low_pct {
561        Some(round1((low_pct * 1.5).min(15.0)))
562    } else {
563        vs.maintainability_avg
564            .map(|mi| round1(((70.0 - mi).max(0.0) * 0.5).min(15.0)))
565    }
566}
567
568fn hotspot_penalty(vs: &VitalSigns, total_files: usize) -> Option<f64> {
569    vs.hotspot_count.map(|count| {
570        if total_files == 0 {
571            return 0.0;
572        }
573        let top_pct_bucket = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION)
574            .ceil()
575            .max(1.0);
576        round1((f64::from(count) / top_pct_bucket * HOTSPOT_MAX_PENALTY).min(HOTSPOT_MAX_PENALTY))
577    })
578}
579
580fn dependency_count_penalty(
581    per_k: Option<f64>,
582    count: Option<u32>,
583    per_k_cap: f64,
584    count_cap: f64,
585) -> Option<f64> {
586    if let Some(per_k) = per_k {
587        Some(round1((per_k * 0.5).min(per_k_cap)))
588    } else {
589        count.map(|count| round1(f64::from(count).min(count_cap)))
590    }
591}
592
593fn unit_size_penalty(vs: &VitalSigns) -> Option<f64> {
594    if let Some(per_k) = vs.functions_over_60_loc_per_k {
595        Some(round1((per_k * 0.5).min(10.0)))
596    } else {
597        vs.unit_size_profile
598            .as_ref()
599            .map(|profile| round1(((profile.very_high_risk - 5.0).max(0.0) * 0.5).min(10.0)))
600    }
601}
602
603fn coupling_penalty(vs: &VitalSigns) -> Option<f64> {
604    if let Some(high_pct) = vs.coupling_high_pct {
605        Some(round1((high_pct * 0.5).min(5.0)))
606    } else {
607        vs.p95_fan_in
608            .map(|p95| round1(((f64::from(p95) - 30.0).max(0.0) * 0.25).min(5.0)))
609    }
610}
611
612/// Build the raw counts for a snapshot.
613pub(crate) fn build_counts(input: &VitalSignsInput<'_>) -> VitalSignsCounts {
614    let (total_exports, dead_files, dead_exports, total_deps) = input
615        .analysis_counts
616        .as_ref()
617        .map_or((0, 0, 0, 0), |counts| {
618            (
619                counts.total_exports,
620                counts.dead_files,
621                counts.dead_exports,
622                counts.total_deps,
623            )
624        });
625
626    let total_lines: usize = input.selected_modules().map(|m| m.line_offsets.len()).sum();
627
628    VitalSignsCounts {
629        total_files: input.total_files,
630        total_exports,
631        dead_files,
632        dead_exports,
633        duplicated_lines: None,
634        total_lines: Some(total_lines),
635        files_scored: input.file_scores.map(<[_]>::len),
636        total_deps,
637    }
638}
639
640/// Get the current git branch name.
641#[expect(
642    clippy::disallowed_methods,
643    reason = "trusted git spawn with ambient repo-state env stripped, matching the core git spawn policy"
644)]
645fn git_branch(root: &Path) -> Option<String> {
646    let mut command = std::process::Command::new("git");
647    command
648        .args(["rev-parse", "--abbrev-ref", "HEAD"])
649        .current_dir(root);
650    clear_ambient_git_env(&mut command);
651    command
652        .output()
653        .ok()
654        .filter(|o| o.status.success())
655        .and_then(|o| {
656            let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
657            if name == "HEAD" { None } else { Some(name) }
658        })
659}
660
661/// Build a snapshot from vital signs and input data.
662#[expect(
663    clippy::too_many_arguments,
664    reason = "snapshot construction keeps every persisted compatibility input explicit"
665)]
666pub(crate) fn build_snapshot(
667    vital_signs: VitalSigns,
668    counts: VitalSignsCounts,
669    root: &Path,
670    shallow_clone: bool,
671    health_score: Option<&HealthScore>,
672    coverage_model: Option<fallow_output::CoverageModel>,
673    analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
674) -> VitalSignsSnapshot {
675    let now = chrono_timestamp();
676
677    VitalSignsSnapshot {
678        snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
679        version: env!("CARGO_PKG_VERSION").to_string(),
680        timestamp: now,
681        git_sha: crate::repo_refs::short_head_sha(root),
682        git_branch: git_branch(root),
683        shallow_clone,
684        vital_signs,
685        counts,
686        score: health_score.map(|s| s.score),
687        grade: health_score.map(|s| s.grade.to_string()),
688        score_formula_version: health_score.map(|s| s.formula_version),
689        coverage_model,
690        analysis_identity,
691        groups: None,
692    }
693}
694
695/// ISO 8601 UTC timestamp without external chrono dependency.
696pub fn chrono_timestamp() -> String {
697    use std::time::SystemTime;
698    let now = SystemTime::now()
699        .duration_since(SystemTime::UNIX_EPOCH)
700        .unwrap_or_default();
701    let secs = now.as_secs();
702
703    let days = secs / SECS_PER_DAY;
704    let time_secs = secs % SECS_PER_DAY;
705    let hours = time_secs / 3600;
706    let minutes = (time_secs % 3600) / 60;
707    let seconds = time_secs % 60;
708
709    let (year, month, day) = days_to_ymd(days);
710
711    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
712}
713
714/// Convert days since Unix epoch to (year, month, day).
715const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
716    let z = days + 719_468;
717    let era = z / 146_097;
718    let doe = z - era * 146_097;
719    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
720    let y = yoe + era * 400;
721    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
722    let mp = (5 * doy + 2) / 153;
723    let d = doy - (153 * mp + 2) / 5 + 1;
724    let m = if mp < 10 { mp + 3 } else { mp - 9 };
725    let y = if m <= 2 { y + 1 } else { y };
726    (y, m, d)
727}
728
729/// Save a snapshot to disk.
730///
731/// If `path` is `None`, writes to `.fallow/snapshots/{timestamp}.json`.
732/// Creates parent directories as needed.
733pub(crate) fn save_snapshot(
734    snapshot: &VitalSignsSnapshot,
735    root: &Path,
736    explicit_path: Option<&Path>,
737) -> Result<PathBuf, String> {
738    let path = explicit_path.map_or_else(
739        || {
740            let dir = root.join(".fallow").join("snapshots");
741            let filename = snapshot.timestamp.replace(':', "-");
742            dir.join(format!("{filename}.json"))
743        },
744        Path::to_path_buf,
745    );
746
747    let json =
748        serde_json::to_string_pretty(snapshot).map_err(|e| format!("failed to serialize: {e}"))?;
749    crate::write_guard::write_file(
750        &path,
751        json.as_bytes(),
752        crate::write_guard::WriteTarget::Path,
753    )
754    .map_err(|e| {
755        if e.is_directory() {
756            format!("failed to create snapshot directory: {e}")
757        } else {
758            format!("failed to write snapshot: {e}")
759        }
760    })?;
761
762    Ok(path)
763}
764
765/// Load all snapshots from the default snapshot directory, sorted by timestamp ascending.
766///
767/// Corrupt or unreadable files are skipped with a warning to stderr.
768/// Returns an empty vec if the directory does not exist.
769#[cfg(test)]
770pub(crate) fn load_snapshots(root: &Path) -> Vec<VitalSignsSnapshot> {
771    load_snapshots_with_paths(root)
772        .into_iter()
773        .map(|(_, snapshot)| snapshot)
774        .collect()
775}
776
777/// Load all snapshots from the default snapshot directory with their file
778/// paths, sorted by timestamp ascending. Corrupt or unreadable files are
779/// skipped with a warning to stderr.
780#[expect(
781    clippy::print_stderr,
782    reason = "corrupt-snapshot warnings to stderr, preserved verbatim from the CLI health path"
783)]
784fn load_snapshots_with_paths(root: &Path) -> Vec<(PathBuf, VitalSignsSnapshot)> {
785    let dir = root.join(".fallow").join("snapshots");
786    let Ok(entries) = std::fs::read_dir(&dir) else {
787        return Vec::new();
788    };
789
790    let mut snapshots = Vec::new();
791    for entry in entries {
792        let Ok(entry) = entry else { continue };
793        let path = entry.path();
794        if path.extension().is_some_and(|ext| ext == "json") {
795            match std::fs::read_to_string(&path) {
796                Ok(content) => match serde_json::from_str::<VitalSignsSnapshot>(&content) {
797                    Ok(snap) => snapshots.push((path, snap)),
798                    Err(e) => {
799                        eprintln!("warning: skipping corrupt snapshot {}: {e}", path.display());
800                        record_unreadable_snapshot(root, &path, &e.to_string());
801                    }
802                },
803                Err(e) => {
804                    eprintln!("warning: could not read snapshot {}: {e}", path.display());
805                    record_unreadable_snapshot(root, &path, &e.to_string());
806                }
807            }
808        }
809    }
810
811    snapshots.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
812    snapshots
813}
814
815/// The snapshot a trend compares against, with where it came from.
816#[derive(Debug, Clone)]
817pub(crate) struct TrendBaseline {
818    /// The baseline snapshot.
819    pub(crate) snapshot: VitalSignsSnapshot,
820    /// The file the snapshot was read from.
821    pub(crate) path: PathBuf,
822    /// Number of snapshots the trend source held: the snapshot directory
823    /// count, or `1` for an explicit `--trend-from` file.
824    pub(crate) snapshots_loaded: usize,
825}
826
827impl TrendBaseline {
828    /// The baseline snapshot without its group data.
829    ///
830    /// Each group trend copies this snapshot, so the group data is removed
831    /// once here and not copied again for every group.
832    pub(crate) fn without_groups(&self) -> VitalSignsSnapshot {
833        let mut snapshot = self.snapshot.clone();
834        snapshot.groups = None;
835        snapshot
836    }
837}
838
839/// Load the trend baseline for the project trend and the group trend.
840///
841/// With `explicit`, read exactly that file. It must exist and parse, because
842/// the user named it. Without it, use the newest snapshot of
843/// `.fallow/snapshots/`, and return `Ok(None)` when there is none.
844///
845/// # Errors
846///
847/// Returns a message when the explicit file cannot be read or parsed.
848pub(crate) fn load_trend_baseline(
849    root: &Path,
850    explicit: Option<&Path>,
851) -> Result<Option<TrendBaseline>, String> {
852    if let Some(path) = explicit {
853        let content = std::fs::read_to_string(path).map_err(|e| {
854            format!(
855                "failed to read --trend-from snapshot {}: {e}",
856                path.display()
857            )
858        })?;
859        // Report only the position. The serde message can quote values from
860        // the file, and MCP `trend_from` returns this text to the caller.
861        let snapshot = serde_json::from_str::<VitalSignsSnapshot>(&content).map_err(|e| {
862            format!(
863                "--trend-from file {} is not a fallow health snapshot (parse error at line {}, column {})",
864                path.display(),
865                e.line(),
866                e.column()
867            )
868        })?;
869        return Ok(Some(TrendBaseline {
870            snapshot,
871            path: path.to_path_buf(),
872            snapshots_loaded: 1,
873        }));
874    }
875    let mut snapshots = load_snapshots_with_paths(root);
876    let snapshots_loaded = snapshots.len();
877    Ok(snapshots.pop().map(|(path, snapshot)| TrendBaseline {
878        snapshot,
879        path,
880        snapshots_loaded,
881    }))
882}
883
884/// Record a snapshot this run could not use, so the thinner trend is visible to
885/// a consumer that never sees the warning above (issue #2689).
886fn record_unreadable_snapshot(root: &Path, path: &Path, error: &str) {
887    crate::health::diagnostics::record_health_diagnostic(
888        root,
889        Some(path),
890        fallow_types::workspace::WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
891            error: error.to_owned(),
892        },
893    );
894}
895
896/// Tolerance for treating a metric delta as "stable" rather than improving/declining.
897const TREND_TOLERANCE: f64 = 0.5;
898
899fn trend_point_from_snapshot(prev: &VitalSignsSnapshot) -> TrendPoint {
900    TrendPoint {
901        timestamp: prev.timestamp.clone(),
902        git_sha: prev.git_sha.clone(),
903        score: prev.score,
904        grade: prev.grade.clone(),
905        score_formula_version: prev.score_formula_version,
906        coverage_model: prev.coverage_model.clone(),
907        snapshot_schema_version: Some(prev.snapshot_schema_version),
908    }
909}
910
911fn overall_trend_direction(metrics: &[TrendMetric]) -> TrendDirection {
912    let (improving, declining) = metrics.iter().fold((0usize, 0usize), |(imp, dec), metric| {
913        match metric.direction {
914            TrendDirection::Improving => (imp + 1, dec),
915            TrendDirection::Declining => (imp, dec + 1),
916            TrendDirection::Stable => (imp, dec),
917        }
918    });
919
920    match improving.cmp(&declining) {
921        std::cmp::Ordering::Greater => TrendDirection::Improving,
922        std::cmp::Ordering::Less => TrendDirection::Declining,
923        std::cmp::Ordering::Equal => TrendDirection::Stable,
924    }
925}
926
927/// Compute a trend comparison between the current run and the most recent snapshot.
928///
929/// Uses the stored `score` field from the snapshot (never re-derives it).
930/// Returns `None` if no snapshots are available.
931#[cfg(test)]
932pub(crate) fn compute_trend(
933    current_vs: &VitalSigns,
934    current_counts: &VitalSignsCounts,
935    current_score: Option<f64>,
936    snapshots: &[VitalSignsSnapshot],
937) -> Option<HealthTrend> {
938    let prev = snapshots.last()?;
939    let current_score = current_score.map(|score| HealthScore {
940        score,
941        ..compute_health_score(current_vs, current_counts.total_files)
942    });
943    Some(compute_trend_against(
944        prev,
945        snapshots.len(),
946        current_vs,
947        current_counts,
948        current_score.as_ref(),
949    ))
950}
951
952/// Compare the current metrics against one baseline snapshot.
953///
954/// Uses the stored `score` of the snapshot (never re-derives it).
955pub(crate) fn compute_trend_against(
956    prev: &VitalSignsSnapshot,
957    snapshots_loaded: usize,
958    current_vs: &VitalSigns,
959    current_counts: &VitalSignsCounts,
960    current_score: Option<&HealthScore>,
961) -> HealthTrend {
962    let compared_to = trend_point_from_snapshot(prev);
963
964    let metrics = TrendBuilder::new(prev, current_vs, current_counts, current_score).build();
965
966    let overall_direction = overall_trend_direction(&metrics);
967
968    HealthTrend {
969        compared_to,
970        metrics,
971        snapshots_loaded,
972        overall_direction,
973    }
974}
975
976/// Compare the current metrics of one group against the same group in the
977/// baseline snapshot.
978///
979/// `compared_to` names the baseline snapshot (timestamp, commit), with the
980/// stored score and grade of the group. `base` is the baseline snapshot
981/// without its `groups`, built once for all groups by
982/// [`TrendBaseline::without_groups`].
983pub(crate) fn compute_group_trend(
984    base: &VitalSignsSnapshot,
985    snapshots_loaded: usize,
986    previous: &fallow_output::GroupSnapshot,
987    current_vs: &VitalSigns,
988    current_counts: &VitalSignsCounts,
989    current_score: Option<&HealthScore>,
990) -> HealthTrend {
991    let prev = VitalSignsSnapshot {
992        vital_signs: previous.vital_signs.clone(),
993        counts: previous.counts.clone(),
994        score: previous.score,
995        grade: previous.grade.clone(),
996        ..base.clone()
997    };
998    compute_trend_against(
999        &prev,
1000        snapshots_loaded,
1001        current_vs,
1002        current_counts,
1003        current_score,
1004    )
1005}
1006
1007struct TrendBuilder<'a> {
1008    prev: &'a VitalSignsSnapshot,
1009    current_vs: &'a VitalSigns,
1010    current_counts: &'a VitalSignsCounts,
1011    current_score: Option<&'a HealthScore>,
1012    metrics: Vec<TrendMetric>,
1013}
1014
1015impl TrendBuilder<'_> {
1016    fn new<'a>(
1017        prev: &'a VitalSignsSnapshot,
1018        current_vs: &'a VitalSigns,
1019        current_counts: &'a VitalSignsCounts,
1020        current_score: Option<&'a HealthScore>,
1021    ) -> TrendBuilder<'a> {
1022        TrendBuilder {
1023            prev,
1024            current_vs,
1025            current_counts,
1026            current_score,
1027            metrics: Vec::new(),
1028        }
1029    }
1030
1031    fn build(mut self) -> Vec<TrendMetric> {
1032        self.add_score_metric();
1033        self.add_dead_code_metrics();
1034        self.add_complexity_metrics();
1035        self.add_dependency_metrics();
1036        self.add_structure_metrics();
1037        self.metrics
1038    }
1039
1040    fn push(&mut self, input: TrendMetricInput) {
1041        self.metrics.push(make_metric(input));
1042    }
1043
1044    fn add_score_metric(&mut self) {
1045        let Some(current) = self.current_score else {
1046            return;
1047        };
1048        if self.prev.score_formula_version != Some(current.formula_version) {
1049            return;
1050        }
1051        if let Some(prev_score) = self.prev.score {
1052            self.push(TrendMetricInput {
1053                name: "score",
1054                label: "Health Score",
1055                previous: prev_score,
1056                current: current.score,
1057                unit: "",
1058                higher_is_better: true,
1059                previous_count: None,
1060                current_count: None,
1061            });
1062        }
1063    }
1064
1065    fn add_dead_code_metrics(&mut self) {
1066        if let (Some(prev_val), Some(cur_val)) = (
1067            self.prev.vital_signs.dead_file_pct,
1068            self.current_vs.dead_file_pct,
1069        ) {
1070            self.push(TrendMetricInput {
1071                name: "dead_file_pct",
1072                label: "Dead Files",
1073                previous: prev_val,
1074                current: cur_val,
1075                unit: "%",
1076                higher_is_better: false,
1077                previous_count: Some(TrendCount {
1078                    value: self.prev.counts.dead_files,
1079                    total: self.prev.counts.total_files,
1080                }),
1081                current_count: Some(TrendCount {
1082                    value: self.current_counts.dead_files,
1083                    total: self.current_counts.total_files,
1084                }),
1085            });
1086        }
1087
1088        if let (Some(prev_val), Some(cur_val)) = (
1089            self.prev.vital_signs.dead_export_pct,
1090            self.current_vs.dead_export_pct,
1091        ) {
1092            self.push(TrendMetricInput {
1093                name: "dead_export_pct",
1094                label: "Dead Exports",
1095                previous: prev_val,
1096                current: cur_val,
1097                unit: "%",
1098                higher_is_better: false,
1099                previous_count: Some(TrendCount {
1100                    value: self.prev.counts.dead_exports,
1101                    total: self.prev.counts.total_exports,
1102                }),
1103                current_count: Some(TrendCount {
1104                    value: self.current_counts.dead_exports,
1105                    total: self.current_counts.total_exports,
1106                }),
1107            });
1108        }
1109    }
1110
1111    fn add_complexity_metrics(&mut self) {
1112        self.push(TrendMetricInput {
1113            name: "avg_cyclomatic",
1114            label: "Avg Cyclomatic",
1115            previous: self.prev.vital_signs.avg_cyclomatic,
1116            current: self.current_vs.avg_cyclomatic,
1117            unit: "",
1118            higher_is_better: false,
1119            previous_count: None,
1120            current_count: None,
1121        });
1122
1123        if let (Some(prev_val), Some(cur_val)) = (
1124            self.prev.vital_signs.maintainability_avg,
1125            self.current_vs.maintainability_avg,
1126        ) {
1127            self.push(TrendMetricInput {
1128                name: "maintainability_avg",
1129                label: "Maintainability",
1130                previous: prev_val,
1131                current: cur_val,
1132                unit: "",
1133                higher_is_better: true,
1134                previous_count: None,
1135                current_count: None,
1136            });
1137        }
1138
1139        if let (Some(prev_profile), Some(cur_profile)) = (
1140            &self.prev.vital_signs.unit_size_profile,
1141            &self.current_vs.unit_size_profile,
1142        ) {
1143            self.push(TrendMetricInput {
1144                name: "unit_size_very_high_pct",
1145                label: "Oversized Fns",
1146                previous: prev_profile.very_high_risk,
1147                current: cur_profile.very_high_risk,
1148                unit: "%",
1149                higher_is_better: false,
1150                previous_count: None,
1151                current_count: None,
1152            });
1153        }
1154
1155        self.add_duplication_metric();
1156    }
1157
1158    fn add_duplication_metric(&mut self) {
1159        if let (Some(prev_val), Some(cur_val)) = (
1160            self.prev.vital_signs.duplication_pct,
1161            self.current_vs.duplication_pct,
1162        ) {
1163            self.push(TrendMetricInput {
1164                name: "duplication_pct",
1165                label: "Duplication",
1166                previous: prev_val,
1167                current: cur_val,
1168                unit: "%",
1169                higher_is_better: false,
1170                previous_count: self
1171                    .prev
1172                    .counts
1173                    .duplicated_lines
1174                    .zip(self.prev.counts.total_lines)
1175                    .map(|(d, t)| TrendCount { value: d, total: t }),
1176                current_count: self
1177                    .current_counts
1178                    .duplicated_lines
1179                    .zip(self.current_counts.total_lines)
1180                    .map(|(d, t)| TrendCount { value: d, total: t }),
1181            });
1182        }
1183    }
1184
1185    fn add_dependency_metrics(&mut self) {
1186        if let (Some(prev_val), Some(cur_val)) = (
1187            self.prev.vital_signs.unused_dep_count,
1188            self.current_vs.unused_dep_count,
1189        ) {
1190            self.push(TrendMetricInput {
1191                name: "unused_dep_count",
1192                label: "Unused Deps",
1193                previous: f64::from(prev_val),
1194                current: f64::from(cur_val),
1195                unit: "",
1196                higher_is_better: false,
1197                previous_count: None,
1198                current_count: None,
1199            });
1200        }
1201    }
1202
1203    fn add_structure_metrics(&mut self) {
1204        if let (Some(prev_val), Some(cur_val)) = (
1205            self.prev.vital_signs.circular_dep_count,
1206            self.current_vs.circular_dep_count,
1207        ) {
1208            self.push(TrendMetricInput {
1209                name: "circular_dep_count",
1210                label: "Circular Deps",
1211                previous: f64::from(prev_val),
1212                current: f64::from(cur_val),
1213                unit: "",
1214                higher_is_better: false,
1215                previous_count: None,
1216                current_count: None,
1217            });
1218        }
1219
1220        if let (Some(prev_val), Some(cur_val)) = (
1221            self.prev.vital_signs.hotspot_count,
1222            self.current_vs.hotspot_count,
1223        ) {
1224            self.push(TrendMetricInput {
1225                name: "hotspot_count",
1226                label: "Hotspots",
1227                previous: f64::from(prev_val),
1228                current: f64::from(cur_val),
1229                unit: "",
1230                higher_is_better: false,
1231                previous_count: None,
1232                current_count: None,
1233            });
1234        }
1235
1236        if let (Some(prev_val), Some(cur_val)) =
1237            (self.prev.vital_signs.p95_fan_in, self.current_vs.p95_fan_in)
1238        {
1239            self.push(TrendMetricInput {
1240                name: "p95_fan_in",
1241                label: "P95 Fan-in",
1242                previous: f64::from(prev_val),
1243                current: f64::from(cur_val),
1244                unit: "",
1245                higher_is_better: false,
1246                previous_count: None,
1247                current_count: None,
1248            });
1249        }
1250    }
1251}
1252
1253/// Build a single trend metric.
1254struct TrendMetricInput {
1255    name: &'static str,
1256    label: &'static str,
1257    previous: f64,
1258    current: f64,
1259    unit: &'static str,
1260    higher_is_better: bool,
1261    previous_count: Option<TrendCount>,
1262    current_count: Option<TrendCount>,
1263}
1264
1265fn make_metric(input: TrendMetricInput) -> TrendMetric {
1266    let TrendMetricInput {
1267        name,
1268        label,
1269        previous,
1270        current,
1271        unit,
1272        higher_is_better,
1273        previous_count,
1274        current_count,
1275    } = input;
1276    let delta = (current - previous).round_to(1);
1277    let direction = if delta.abs() < TREND_TOLERANCE {
1278        TrendDirection::Stable
1279    } else if (higher_is_better && delta > 0.0) || (!higher_is_better && delta < 0.0) {
1280        TrendDirection::Improving
1281    } else {
1282        TrendDirection::Declining
1283    };
1284
1285    TrendMetric {
1286        name,
1287        label,
1288        previous,
1289        current,
1290        delta,
1291        direction,
1292        unit,
1293        previous_count,
1294        current_count,
1295    }
1296}
1297
1298/// Extension trait for rounding floats to N decimal places.
1299trait RoundTo {
1300    fn round_to(self, decimals: u32) -> Self;
1301}
1302
1303impl RoundTo for f64 {
1304    fn round_to(self, decimals: u32) -> Self {
1305        let factor = 10_f64.powi(i32::try_from(decimals).unwrap_or(i32::MAX));
1306        (self * factor).round() / factor
1307    }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313
1314    fn make_module(id: u32, cyclomatic: u16) -> crate::source::ModuleInfo {
1315        crate::source::ModuleInfo {
1316            complexity: vec![fallow_types::extract::FunctionComplexity {
1317                name: format!("fn_{id}"),
1318                is_private_member: false,
1319                context_named: None,
1320                line: id + 1,
1321                col: 0,
1322                cyclomatic,
1323                cognitive: 0,
1324                line_count: 10,
1325                param_count: 0,
1326                react_hook_count: 0,
1327                react_jsx_max_depth: 0,
1328                react_prop_count: 0,
1329                source_hash: None,
1330                contributions: Vec::new(),
1331            }],
1332            ..crate::source::ModuleInfo::empty(crate::discover::FileId(id))
1333        }
1334    }
1335
1336    #[expect(
1337        clippy::cast_possible_truncation,
1338        reason = "test values are trivially small"
1339    )]
1340    fn make_modules() -> Vec<crate::source::ModuleInfo> {
1341        (0..10)
1342            .map(|i| make_module(i, (i as u16 + 1) * 2))
1343            .collect()
1344    }
1345
1346    fn assert_close(actual: f64, expected: f64) {
1347        assert!(
1348            (actual - expected).abs() < f64::EPSILON,
1349            "expected {expected}, got {actual}"
1350        );
1351    }
1352
1353    fn assert_some_close(actual: Option<f64>, expected: f64) {
1354        assert_close(actual.expect("expected metric to be present"), expected);
1355    }
1356
1357    #[test]
1358    fn compute_cyclomatic_stats() {
1359        let modules = make_modules();
1360        let input = VitalSignsInput {
1361            modules: &modules,
1362            module_filter: None,
1363            file_scores: None,
1364            hotspots: None,
1365            total_files: 10,
1366            analysis_counts: None,
1367        };
1368        let vs = compute_vital_signs(&input);
1369        assert!((vs.avg_cyclomatic - 11.0).abs() < f64::EPSILON);
1370        assert_eq!(vs.p90_cyclomatic, 18);
1371    }
1372
1373    #[test]
1374    fn cyclomatic_population_partitions_units_and_respects_module_filter() {
1375        let mut mixed = make_module(0, 1);
1376        let mut module_unit = mixed.complexity[0].clone();
1377        module_unit.name = "<module>".into();
1378        module_unit.cyclomatic = 31;
1379        let mut template_unit = module_unit.clone();
1380        template_unit.name = "<template>".into();
1381        template_unit.cyclomatic = 4;
1382        mixed.complexity.extend([module_unit, template_unit]);
1383        let modules = [mixed, make_module(1, 100)];
1384        let filter = rustc_hash::FxHashSet::from_iter([crate::discover::FileId(0)]);
1385        let input = VitalSignsInput {
1386            modules: &modules,
1387            module_filter: Some(&filter),
1388            file_scores: None,
1389            hotspots: None,
1390            total_files: 1,
1391            analysis_counts: None,
1392        };
1393        let vs = compute_vital_signs(&input);
1394        let population = vs.cyclomatic_population.unwrap();
1395        assert_eq!(population.functions.count, 1);
1396        assert_eq!(population.modules.count, 1);
1397        assert_eq!(population.templates.count, 1);
1398        assert_eq!(population.functions.sum, 1);
1399        assert_eq!(population.modules.sum, 31);
1400        assert_eq!(population.templates.sum, 4);
1401        assert_close(vs.avg_cyclomatic, 12.0);
1402        assert_eq!(vs.p90_cyclomatic, 31);
1403    }
1404
1405    #[test]
1406    fn compute_with_analysis_counts() {
1407        let modules = make_modules();
1408        let input = VitalSignsInput {
1409            modules: &modules,
1410            module_filter: None,
1411            file_scores: None,
1412            hotspots: None,
1413            total_files: 100,
1414            analysis_counts: Some(AnalysisCounts {
1415                total_exports: 500,
1416                dead_files: 5,
1417                dead_exports: 50,
1418                unused_deps: 3,
1419                circular_deps: 2,
1420                total_deps: 40,
1421            }),
1422        };
1423        let vs = compute_vital_signs(&input);
1424        assert_eq!(vs.dead_file_pct, Some(5.0)); // 5/100 * 100
1425        assert_eq!(vs.dead_export_pct, Some(10.0)); // 50/500 * 100
1426        assert_eq!(vs.unused_dep_count, Some(3));
1427        assert_eq!(vs.circular_dep_count, Some(2));
1428    }
1429
1430    #[test]
1431    fn compute_hotspot_count_with_threshold() {
1432        let hotspots = vec![
1433            HotspotEntry {
1434                path: PathBuf::from("a.ts"),
1435                score: 80.0,
1436                commits: 10,
1437                weighted_commits: 8.0,
1438                lines_added: 100,
1439                lines_deleted: 50,
1440                complexity_density: 0.5,
1441                fan_in: 5,
1442                trend: crate::churn::ChurnTrend::Stable,
1443                median_files_per_commit: None,
1444                median_lines_per_commit: None,
1445                ownership: None,
1446                is_test_path: false,
1447            },
1448            HotspotEntry {
1449                path: PathBuf::from("b.ts"),
1450                score: 30.0, // Below threshold
1451                commits: 5,
1452                weighted_commits: 3.0,
1453                lines_added: 40,
1454                lines_deleted: 20,
1455                complexity_density: 0.2,
1456                fan_in: 2,
1457                trend: crate::churn::ChurnTrend::Cooling,
1458                median_files_per_commit: None,
1459                median_lines_per_commit: None,
1460                ownership: None,
1461                is_test_path: false,
1462            },
1463            HotspotEntry {
1464                path: PathBuf::from("c.ts"),
1465                score: 50.0, // At threshold
1466                commits: 8,
1467                weighted_commits: 6.0,
1468                lines_added: 80,
1469                lines_deleted: 30,
1470                complexity_density: 0.4,
1471                fan_in: 3,
1472                trend: crate::churn::ChurnTrend::Accelerating,
1473                median_files_per_commit: None,
1474                median_lines_per_commit: None,
1475                ownership: None,
1476                is_test_path: false,
1477            },
1478        ];
1479        let modules = Vec::new();
1480        let input = VitalSignsInput {
1481            modules: &modules,
1482            module_filter: None,
1483            file_scores: None,
1484            hotspots: Some(&hotspots),
1485            total_files: 10,
1486            analysis_counts: None,
1487        };
1488        let vs = compute_vital_signs(&input);
1489        assert_eq!(vs.hotspot_count, Some(2)); // 80.0 and 50.0 meet threshold
1490        assert_eq!(vs.hotspot_top_pct_count, Some(1)); // top 1% bucket rounds up to one file
1491    }
1492
1493    #[test]
1494    fn hotspot_penalty_ignores_subthreshold_ranked_entries() {
1495        let hotspots: Vec<HotspotEntry> = [49.9, 0.1]
1496            .into_iter()
1497            .enumerate()
1498            .map(|(index, score)| HotspotEntry {
1499                path: PathBuf::from(format!("src/active-{index}.ts")),
1500                score,
1501                commits: 3,
1502                weighted_commits: 3.0,
1503                lines_added: 10,
1504                lines_deleted: 5,
1505                complexity_density: 0.1,
1506                fan_in: 0,
1507                trend: crate::churn::ChurnTrend::Stable,
1508                median_files_per_commit: None,
1509                median_lines_per_commit: None,
1510                ownership: None,
1511                is_test_path: false,
1512            })
1513            .collect();
1514        let input = VitalSignsInput {
1515            modules: &[],
1516            module_filter: None,
1517            file_scores: None,
1518            hotspots: Some(&hotspots),
1519            total_files: 200,
1520            analysis_counts: None,
1521        };
1522
1523        let vitals = compute_vital_signs(&input);
1524        let health = compute_health_score(&vitals, input.total_files);
1525
1526        assert_eq!(vitals.hotspot_count, Some(0));
1527        assert_eq!(vitals.hotspot_top_pct_count, Some(2));
1528        assert_some_close(health.penalties.hotspots, 0.0);
1529    }
1530
1531    #[test]
1532    fn empty_cyclomatic_population_is_measured_not_unknown() {
1533        let vs = compute_vital_signs(&VitalSignsInput {
1534            modules: &[],
1535            module_filter: None,
1536            file_scores: None,
1537            hotspots: None,
1538            total_files: 0,
1539            analysis_counts: None,
1540        });
1541        let population = vs.cyclomatic_population.unwrap();
1542        for group in [
1543            population.functions,
1544            population.modules,
1545            population.templates,
1546        ] {
1547            assert_eq!(group.count, 0);
1548            assert_eq!(group.sum, 0);
1549            assert_eq!(group.max, None);
1550        }
1551        assert_close(vs.avg_cyclomatic, 0.0);
1552        assert_eq!(vs.p90_cyclomatic, 0);
1553        assert_eq!(vs.critical_complexity_pct, None);
1554    }
1555
1556    #[test]
1557    fn compute_without_hotspots_gives_none() {
1558        let modules = Vec::new();
1559        let input = VitalSignsInput {
1560            modules: &modules,
1561            module_filter: None,
1562            file_scores: None,
1563            hotspots: None,
1564            total_files: 0,
1565            analysis_counts: None,
1566        };
1567        let vs = compute_vital_signs(&input);
1568        assert!(vs.hotspot_count.is_none());
1569    }
1570
1571    #[test]
1572    fn snapshot_save_and_load() {
1573        let dir = tempfile::tempdir().unwrap();
1574        let root = dir.path();
1575        let vs = VitalSigns {
1576            dead_file_pct: Some(3.2),
1577            dead_export_pct: Some(8.1),
1578            avg_cyclomatic: 4.7,
1579            p90_cyclomatic: 12,
1580            hotspot_count: Some(5),
1581            maintainability_avg: Some(72.4),
1582            unused_dep_count: Some(4),
1583            circular_dep_count: Some(2),
1584            ..Default::default()
1585        };
1586        let counts = VitalSignsCounts {
1587            total_files: 1200,
1588            total_exports: 5400,
1589            dead_files: 38,
1590            dead_exports: 437,
1591            files_scored: Some(1150),
1592            total_deps: 42,
1593            ..Default::default()
1594        };
1595        let health_score = compute_health_score(&vs, 1200);
1596        let snapshot = build_snapshot(
1597            vs,
1598            counts,
1599            root,
1600            false,
1601            Some(&health_score),
1602            None,
1603            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1604        );
1605        let saved_path = save_snapshot(&snapshot, root, None).unwrap();
1606
1607        assert!(saved_path.exists());
1608        assert!(saved_path.starts_with(root.join(".fallow/snapshots")));
1609
1610        let content = std::fs::read_to_string(&saved_path).unwrap();
1611        let loaded: VitalSignsSnapshot = serde_json::from_str(&content).unwrap();
1612        assert_eq!(loaded.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
1613        assert!((loaded.vital_signs.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
1614        assert_eq!(loaded.counts.total_files, 1200);
1615        assert!(loaded.score.is_some());
1616        assert!(loaded.grade.is_some());
1617    }
1618
1619    #[test]
1620    fn snapshot_save_explicit_path() {
1621        let dir = tempfile::tempdir().unwrap();
1622        let root = dir.path();
1623        let explicit = root.join("my-snapshot.json");
1624        let vs = VitalSigns {
1625            avg_cyclomatic: 1.0,
1626            p90_cyclomatic: 2,
1627            ..Default::default()
1628        };
1629        let counts = VitalSignsCounts::default();
1630        let snapshot = build_snapshot(
1631            vs,
1632            counts,
1633            root,
1634            false,
1635            None,
1636            None,
1637            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1638        );
1639        let saved = save_snapshot(&snapshot, root, Some(&explicit)).unwrap();
1640        assert_eq!(saved, explicit);
1641        assert!(explicit.exists());
1642    }
1643
1644    #[test]
1645    fn snapshot_save_creates_nested_dirs() {
1646        let dir = tempfile::tempdir().unwrap();
1647        let root = dir.path();
1648        let nested = root.join("a/b/c/snapshot.json");
1649        let vs = VitalSigns {
1650            avg_cyclomatic: 1.0,
1651            p90_cyclomatic: 2,
1652            ..Default::default()
1653        };
1654        let counts = VitalSignsCounts::default();
1655        let snapshot = build_snapshot(
1656            vs,
1657            counts,
1658            root,
1659            false,
1660            None,
1661            None,
1662            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1663        );
1664        let saved = save_snapshot(&snapshot, root, Some(&nested)).unwrap();
1665        assert_eq!(saved, nested);
1666        assert!(nested.exists());
1667    }
1668
1669    #[test]
1670    fn days_to_ymd_epoch() {
1671        assert_eq!(days_to_ymd(0), (1970, 1, 1));
1672    }
1673
1674    #[test]
1675    fn days_to_ymd_known_date() {
1676        assert_eq!(days_to_ymd(20_537), (2026, 3, 25));
1677    }
1678
1679    #[test]
1680    fn health_score_perfect() {
1681        let vs = VitalSigns {
1682            dead_file_pct: Some(0.0),
1683            dead_export_pct: Some(0.0),
1684            avg_cyclomatic: 1.0,
1685            p90_cyclomatic: 2,
1686            hotspot_count: Some(0),
1687            maintainability_avg: Some(90.0),
1688            unused_dep_count: Some(0),
1689            circular_dep_count: Some(0),
1690            ..Default::default()
1691        };
1692        let score = compute_health_score(&vs, 100);
1693        assert!((score.score - 100.0).abs() < f64::EPSILON);
1694        assert_eq!(score.grade, "A");
1695    }
1696
1697    #[test]
1698    fn health_score_no_optional_metrics() {
1699        let vs = VitalSigns {
1700            avg_cyclomatic: 1.0,
1701            p90_cyclomatic: 2,
1702            ..Default::default()
1703        };
1704        let score = compute_health_score(&vs, 0);
1705        assert!((score.score - 100.0).abs() < f64::EPSILON);
1706        assert_eq!(score.grade, "A");
1707        assert!(score.penalties.dead_files.is_none());
1708        assert!(score.penalties.unused_deps.is_none());
1709        assert!(score.penalties.duplication.is_none());
1710    }
1711
1712    #[test]
1713    fn health_score_dead_code_penalty() {
1714        let vs = VitalSigns {
1715            dead_file_pct: Some(50.0),
1716            dead_export_pct: Some(30.0),
1717            avg_cyclomatic: 1.0,
1718            p90_cyclomatic: 2,
1719            ..Default::default()
1720        };
1721        let score = compute_health_score(&vs, 100);
1722        assert!((score.score - 84.0).abs() < 0.1);
1723        assert_eq!(score.grade, "B");
1724    }
1725
1726    #[test]
1727    fn health_score_complexity_penalty() {
1728        let vs = VitalSigns {
1729            avg_cyclomatic: 5.5,
1730            p90_cyclomatic: 15,
1731            ..Default::default()
1732        };
1733        let score = compute_health_score(&vs, 100);
1734        assert!((score.score - 75.0).abs() < 0.1);
1735        assert_eq!(score.grade, "B");
1736    }
1737
1738    #[test]
1739    fn health_score_prop_drilling_penalty_opt_in() {
1740        let base = || VitalSigns {
1741            avg_cyclomatic: 1.0,
1742            p90_cyclomatic: 2,
1743            ..Default::default()
1744        };
1745        let base_score = compute_health_score(&base(), 100).score;
1746
1747        // Each located chain costs 1pt (depth is descriptive, not a multiplier).
1748        let three = VitalSigns {
1749            prop_drilling_chain_count: Some(3),
1750            prop_drilling_max_depth: Some(5),
1751            ..base()
1752        };
1753        assert!((base_score - compute_health_score(&three, 100).score - 3.0).abs() < 0.1);
1754
1755        // Capped at 5pt regardless of chain count.
1756        let many = VitalSigns {
1757            prop_drilling_chain_count: Some(20),
1758            ..base()
1759        };
1760        assert!((base_score - compute_health_score(&many, 100).score - 5.0).abs() < 0.1);
1761
1762        // Dormant by default: the opt-in rule is off, so the count is `None` and
1763        // the score is unchanged.
1764        let off = VitalSigns {
1765            prop_drilling_chain_count: None,
1766            ..base()
1767        };
1768        assert!((base_score - compute_health_score(&off, 100).score).abs() < f64::EPSILON);
1769    }
1770
1771    #[test]
1772    fn health_score_clamped_at_zero() {
1773        let vs = VitalSigns {
1774            dead_file_pct: Some(100.0),
1775            dead_export_pct: Some(100.0),
1776            avg_cyclomatic: 10.0,
1777            p90_cyclomatic: 30,
1778            hotspot_count: Some(50),
1779            maintainability_avg: Some(20.0),
1780            unused_dep_count: Some(100),
1781            circular_dep_count: Some(50),
1782            ..Default::default()
1783        };
1784        let score = compute_health_score(&vs, 100);
1785        assert!((score.score).abs() < f64::EPSILON);
1786        assert_eq!(score.grade, "F");
1787    }
1788
1789    #[test]
1790    fn health_score_hotspot_normalized_by_files() {
1791        let vs = VitalSigns {
1792            avg_cyclomatic: 1.0,
1793            p90_cyclomatic: 2,
1794            hotspot_count: Some(5),
1795            ..Default::default()
1796        };
1797        let score_100 = compute_health_score(&vs, 100);
1798        let score_1000 = compute_health_score(&vs, 1000);
1799        assert!(score_1000.score > score_100.score);
1800    }
1801
1802    #[test]
1803    fn health_score_thresholded_hotspots_can_use_full_budget() {
1804        let vs = VitalSigns {
1805            avg_cyclomatic: 1.0,
1806            p90_cyclomatic: 2,
1807            hotspot_count: Some(250),
1808            hotspot_top_pct_count: Some(250),
1809            ..Default::default()
1810        };
1811
1812        let score = compute_health_score(&vs, 25_000);
1813
1814        assert_some_close(score.penalties.hotspots, 10.0);
1815        assert_close(score.score, 90.0);
1816    }
1817
1818    #[test]
1819    fn health_score_duplication_penalty() {
1820        let vs = VitalSigns {
1821            cyclomatic_population: None,
1822            dead_file_pct: None,
1823            dead_export_pct: None,
1824            avg_cyclomatic: 1.0,
1825            critical_complexity_pct: None,
1826            p90_cyclomatic: 2,
1827            duplication_pct: Some(10.0), // 10% - 5% = 5 points
1828            hotspot_count: None,
1829            hotspot_top_pct_count: None,
1830            maintainability_avg: None,
1831            maintainability_low_pct: None,
1832            unused_dep_count: None,
1833            unused_deps_per_k_files: None,
1834            circular_dep_count: None,
1835            circular_deps_per_k_files: None,
1836            counts: None,
1837            unit_size_profile: None,
1838            functions_over_60_loc_per_k: None,
1839            unit_interfacing_profile: None,
1840            p95_fan_in: None,
1841            coupling_high_pct: None,
1842            prop_drilling_chain_count: None,
1843            prop_drilling_max_depth: None,
1844            p95_render_fan_in: None,
1845            render_fan_in_high_pct: None,
1846            max_render_fan_in: None,
1847            top_render_fan_in: Vec::new(),
1848            total_loc: 0,
1849        };
1850        let score = compute_health_score(&vs, 100);
1851        assert_eq!(score.penalties.duplication, Some(5.0));
1852
1853        let vs_low = VitalSigns {
1854            duplication_pct: Some(4.0),
1855            ..vs.clone()
1856        };
1857        let score_low = compute_health_score(&vs_low, 100);
1858        assert_eq!(score_low.penalties.duplication, Some(0.0));
1859
1860        let vs_high = VitalSigns {
1861            duplication_pct: Some(20.0),
1862            ..vs
1863        };
1864        let score_high = compute_health_score(&vs_high, 100);
1865        assert_eq!(score_high.penalties.duplication, Some(10.0));
1866    }
1867
1868    #[test]
1869    fn health_score_uses_scale_invariant_monorepo_signals() {
1870        let vs = VitalSigns {
1871            dead_file_pct: Some(4.0),
1872            dead_export_pct: Some(9.0),
1873            avg_cyclomatic: 2.3,
1874            critical_complexity_pct: Some(2.3),
1875            p90_cyclomatic: 4,
1876            duplication_pct: Some(6.0),
1877            hotspot_count: Some(250),
1878            hotspot_top_pct_count: Some(250),
1879            maintainability_avg: Some(91.0),
1880            maintainability_low_pct: Some(8.0),
1881            unused_dep_count: Some(180),
1882            unused_deps_per_k_files: Some(7.2),
1883            circular_dep_count: Some(450),
1884            circular_deps_per_k_files: Some(18.0),
1885            unit_size_profile: Some(RiskProfile {
1886                low_risk: 80.0,
1887                medium_risk: 12.7,
1888                high_risk: 5.0,
1889                very_high_risk: 2.3,
1890            }),
1891            functions_over_60_loc_per_k: Some(23.0),
1892            p95_fan_in: Some(7),
1893            coupling_high_pct: Some(4.0),
1894            ..Default::default()
1895        };
1896        let score = compute_health_score(&vs, 25_000);
1897        let penalties = &score.penalties;
1898
1899        assert_some_close(penalties.dead_files, 0.8);
1900        assert_some_close(penalties.dead_exports, 1.8);
1901        assert_close(penalties.complexity, 9.2);
1902        assert!((penalties.p90_complexity).abs() < f64::EPSILON);
1903        assert_some_close(penalties.maintainability, 12.0);
1904        assert_some_close(penalties.hotspots, 10.0);
1905        assert_some_close(penalties.unused_deps, 3.6);
1906        assert_some_close(penalties.circular_deps, 9.0);
1907        assert_some_close(penalties.unit_size, 10.0);
1908        assert_some_close(penalties.coupling, 2.0);
1909        assert_some_close(penalties.duplication, 1.0);
1910        assert_close(score.score, 40.6);
1911        assert_eq!(score.grade, "D");
1912    }
1913
1914    #[test]
1915    fn load_snapshots_empty_dir() {
1916        let dir = tempfile::tempdir().unwrap();
1917        let snaps = load_snapshots(dir.path());
1918        assert!(snaps.is_empty());
1919    }
1920
1921    #[test]
1922    fn load_snapshots_returns_sorted() {
1923        let dir = tempfile::tempdir().unwrap();
1924        let root = dir.path();
1925        let snap_dir = root.join(".fallow/snapshots");
1926        std::fs::create_dir_all(&snap_dir).unwrap();
1927
1928        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1929        let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(78.0));
1930
1931        std::fs::write(
1932            snap_dir.join("2026-03-01T00-00-00Z.json"),
1933            serde_json::to_string(&newer).unwrap(),
1934        )
1935        .unwrap();
1936        std::fs::write(
1937            snap_dir.join("2026-01-01T00-00-00Z.json"),
1938            serde_json::to_string(&older).unwrap(),
1939        )
1940        .unwrap();
1941
1942        let loaded = load_snapshots(root);
1943        assert_eq!(loaded.len(), 2);
1944        assert_eq!(loaded[0].timestamp, "2026-01-01T00:00:00Z");
1945        assert_eq!(loaded[1].timestamp, "2026-03-01T00:00:00Z");
1946    }
1947
1948    #[test]
1949    fn load_snapshots_skips_corrupt_files() {
1950        let dir = tempfile::tempdir().unwrap();
1951        let root = dir.path();
1952        let snap_dir = root.join(".fallow/snapshots");
1953        std::fs::create_dir_all(&snap_dir).unwrap();
1954
1955        std::fs::write(snap_dir.join("corrupt.json"), "not valid json").unwrap();
1956        let good = make_test_snapshot("2026-02-01T00:00:00Z", Some(80.0));
1957        std::fs::write(
1958            snap_dir.join("good.json"),
1959            serde_json::to_string(&good).unwrap(),
1960        )
1961        .unwrap();
1962
1963        let loaded = load_snapshots(root);
1964        assert_eq!(loaded.len(), 1);
1965        assert_eq!(loaded[0].timestamp, "2026-02-01T00:00:00Z");
1966    }
1967
1968    #[test]
1969    fn load_snapshots_ignores_non_json() {
1970        let dir = tempfile::tempdir().unwrap();
1971        let root = dir.path();
1972        let snap_dir = root.join(".fallow/snapshots");
1973        std::fs::create_dir_all(&snap_dir).unwrap();
1974
1975        std::fs::write(snap_dir.join("readme.txt"), "not a snapshot").unwrap();
1976
1977        let loaded = load_snapshots(root);
1978        assert!(loaded.is_empty());
1979    }
1980
1981    #[test]
1982    fn hotspot_penalty_respects_score_threshold_and_scoped_population() {
1983        for (scores, total_files, expected) in [
1984            (vec![49.9, 0.1], 563, 0.0),
1985            (vec![50.0, 49.9, 0.1], 563, 1.7),
1986            (vec![50.0; 6], 563, 10.0),
1987            (vec![50.0; 7], 563, 10.0),
1988            (vec![50.0], 1, 10.0),
1989            (vec![50.0], 101, 5.0),
1990            (vec![50.0], 0, 0.0),
1991            (vec![], 563, 0.0),
1992        ] {
1993            let hotspots: Vec<HotspotEntry> = scores
1994                .into_iter()
1995                .enumerate()
1996                .map(|(index, score)| HotspotEntry {
1997                    path: PathBuf::from(format!("src/scoped-{index}.ts")),
1998                    score,
1999                    commits: 3,
2000                    weighted_commits: 3.0,
2001                    lines_added: 10,
2002                    lines_deleted: 5,
2003                    complexity_density: 0.1,
2004                    fan_in: 0,
2005                    trend: crate::churn::ChurnTrend::Stable,
2006                    median_files_per_commit: None,
2007                    median_lines_per_commit: None,
2008                    ownership: None,
2009                    is_test_path: false,
2010                })
2011                .collect();
2012            let input = VitalSignsInput {
2013                modules: &[],
2014                module_filter: None,
2015                file_scores: None,
2016                hotspots: Some(&hotspots),
2017                total_files,
2018                analysis_counts: None,
2019            };
2020            let vitals = compute_vital_signs(&input);
2021            let score = compute_health_score(&vitals, total_files);
2022            assert_some_close(score.penalties.hotspots, expected);
2023            assert_eq!(score.formula_version, HEALTH_SCORE_FORMULA_VERSION);
2024        }
2025    }
2026
2027    #[test]
2028    fn hotspot_penalty_requires_thresholded_measurement_and_ignores_rank_diagnostic() {
2029        let mut vitals = VitalSigns {
2030            hotspot_top_pct_count: Some(6),
2031            ..Default::default()
2032        };
2033        assert!(
2034            compute_health_score(&vitals, 563)
2035                .penalties
2036                .hotspots
2037                .is_none()
2038        );
2039
2040        vitals.hotspot_count = Some(1);
2041        for diagnostic in [None, Some(0), Some(6)] {
2042            vitals.hotspot_top_pct_count = diagnostic;
2043            assert_some_close(compute_health_score(&vitals, 563).penalties.hotspots, 1.7);
2044        }
2045    }
2046
2047    #[test]
2048    fn snapshot_persists_the_computed_score_formula() {
2049        let dir = tempfile::tempdir().unwrap();
2050        let vitals = make_test_vital_signs();
2051        let counts = make_test_counts();
2052        let mut health = compute_health_score(&vitals, counts.total_files);
2053        health.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2054        let snapshot = build_snapshot(
2055            vitals,
2056            counts,
2057            dir.path(),
2058            false,
2059            Some(&health),
2060            None,
2061            fallow_types::semantic::SemanticAnalysisIdentity::default(),
2062        );
2063        assert_eq!(snapshot.score_formula_version, Some(health.formula_version));
2064        assert_eq!(snapshot.score, Some(health.score));
2065        assert_eq!(snapshot.grade.as_deref(), Some(health.grade));
2066
2067        let unscored = build_snapshot(
2068            snapshot.vital_signs,
2069            snapshot.counts,
2070            dir.path(),
2071            false,
2072            None,
2073            None,
2074            fallow_types::semantic::SemanticAnalysisIdentity::default(),
2075        );
2076        assert!(unscored.score_formula_version.is_none());
2077    }
2078
2079    #[test]
2080    fn trend_requires_both_scores_even_with_matching_formula_identity() {
2081        let current_vs = make_test_vital_signs();
2082        let counts = make_test_counts();
2083        let health = compute_health_score(&current_vs, counts.total_files);
2084        for (previous_score, current_score) in [(None, Some(&health)), (Some(72.0), None)] {
2085            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", previous_score);
2086            previous.score_formula_version = Some(health.formula_version);
2087            let trend = compute_trend_against(&previous, 1, &current_vs, &counts, current_score);
2088            assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2089            assert!(
2090                trend
2091                    .metrics
2092                    .iter()
2093                    .any(|metric| metric.name == "avg_cyclomatic")
2094            );
2095            assert!(
2096                fallow_output::health_score_comparison_note(
2097                    previous.score_formula_version,
2098                    Some(health.formula_version),
2099                )
2100                .is_none()
2101            );
2102        }
2103    }
2104
2105    #[test]
2106    fn trend_compares_only_known_matching_score_formulas() {
2107        let current_vs = make_test_vital_signs();
2108        let counts = make_test_counts();
2109        let current_score = compute_health_score(&current_vs, counts.total_files);
2110        for previous_formula in [
2111            None,
2112            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2113            Some(HEALTH_SCORE_FORMULA_VERSION),
2114            Some(HEALTH_SCORE_FORMULA_VERSION + 1),
2115        ] {
2116            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2117            previous.score_formula_version = previous_formula;
2118            let trend =
2119                compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2120            assert_eq!(
2121                trend.metrics.iter().any(|metric| metric.name == "score"),
2122                previous_formula == Some(current_score.formula_version),
2123                "previous formula: {previous_formula:?}"
2124            );
2125            assert!(
2126                trend
2127                    .metrics
2128                    .iter()
2129                    .any(|metric| metric.name == "avg_cyclomatic")
2130            );
2131            assert_eq!(trend.compared_to.score, previous.score);
2132            assert_eq!(trend.compared_to.grade, previous.grade);
2133            assert_eq!(trend.compared_to.score_formula_version, previous_formula);
2134        }
2135    }
2136
2137    #[test]
2138    fn trend_uses_actual_current_formula_and_keeps_raw_direction_on_mismatch() {
2139        let current_vs = make_test_vital_signs();
2140        let counts = make_test_counts();
2141        let mut current_score = compute_health_score(&current_vs, counts.total_files);
2142        current_score.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2143        current_score.score = 90.0;
2144        let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2145        previous.vital_signs = current_vs.clone();
2146        previous.counts = counts.clone();
2147        previous.vital_signs.avg_cyclomatic = current_vs.avg_cyclomatic - 2.0;
2148
2149        let incompatible =
2150            compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2151        assert!(
2152            !incompatible
2153                .metrics
2154                .iter()
2155                .any(|metric| metric.name == "score")
2156        );
2157        assert_eq!(incompatible.overall_direction, TrendDirection::Declining);
2158
2159        previous.score_formula_version = Some(current_score.formula_version);
2160        let compatible =
2161            compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2162        assert!(
2163            compatible
2164                .metrics
2165                .iter()
2166                .any(|metric| metric.name == "score")
2167        );
2168    }
2169
2170    #[test]
2171    fn legacy_snapshot_retains_unknown_formula_without_tool_version_inference() {
2172        let mut value =
2173            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2174        value["snapshot_schema_version"] = serde_json::json!(11);
2175        value["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
2176        value
2177            .as_object_mut()
2178            .unwrap()
2179            .remove("score_formula_version");
2180        let previous: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2181        assert!(previous.score_formula_version.is_none());
2182        let current_vs = make_test_vital_signs();
2183        let counts = make_test_counts();
2184        let health = compute_health_score(&current_vs, counts.total_files);
2185        let trend = compute_trend_against(&previous, 1, &current_vs, &counts, Some(&health));
2186        assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2187        assert!(!trend.metrics.is_empty());
2188    }
2189
2190    #[test]
2191    fn group_trend_uses_snapshot_level_formula_identity() {
2192        let current_vs = make_test_vital_signs();
2193        let counts = make_test_counts();
2194        let health = compute_health_score(&current_vs, counts.total_files);
2195        let group = group_snapshot("@team/a", 70.0);
2196        for formula in [
2197            None,
2198            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2199            Some(health.formula_version),
2200        ] {
2201            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2202            previous.score_formula_version = formula;
2203            let trend =
2204                compute_group_trend(&previous, 1, &group, &current_vs, &counts, Some(&health));
2205            assert_eq!(
2206                trend.metrics.iter().any(|metric| metric.name == "score"),
2207                formula == Some(health.formula_version),
2208            );
2209            assert_eq!(trend.compared_to.score, group.score);
2210            assert_eq!(trend.compared_to.grade, group.grade);
2211            assert_eq!(trend.compared_to.score_formula_version, formula);
2212            assert!(!trend.metrics.is_empty());
2213        }
2214    }
2215
2216    #[test]
2217    fn compute_trend_no_snapshots() {
2218        let vs = make_test_vital_signs();
2219        let counts = make_test_counts();
2220        assert!(compute_trend(&vs, &counts, Some(78.0), &[]).is_none());
2221    }
2222
2223    #[test]
2224    fn compute_trend_improving() {
2225        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2226        let vs = VitalSigns {
2227            dead_file_pct: Some(2.8),
2228            dead_export_pct: Some(7.5),
2229            avg_cyclomatic: 4.1,
2230            p90_cyclomatic: 12,
2231            hotspot_count: Some(3),
2232            maintainability_avg: Some(75.0),
2233            unused_dep_count: Some(3),
2234            circular_dep_count: Some(1),
2235            ..Default::default()
2236        };
2237        let counts = VitalSignsCounts {
2238            total_files: 100,
2239            total_exports: 500,
2240            dead_files: 3,
2241            dead_exports: 38,
2242            files_scored: Some(95),
2243            total_deps: 40,
2244            ..Default::default()
2245        };
2246
2247        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2248        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2249        assert_eq!(trend.snapshots_loaded, 1);
2250        assert_eq!(trend.overall_direction, TrendDirection::Improving);
2251
2252        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2253        assert_eq!(score_metric.direction, TrendDirection::Improving);
2254        assert!((score_metric.delta - 6.0).abs() < f64::EPSILON);
2255    }
2256
2257    #[test]
2258    fn compute_trend_stable_within_tolerance() {
2259        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(78.0));
2260        let vs = make_test_vital_signs();
2261        let counts = make_test_counts();
2262
2263        let trend = compute_trend(&vs, &counts, Some(78.3), &[prev]).unwrap();
2264        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2265        assert_eq!(score_metric.direction, TrendDirection::Stable);
2266    }
2267
2268    #[test]
2269    fn compute_trend_uses_most_recent_snapshot() {
2270        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0));
2271        let vs = make_test_vital_signs();
2272        let counts = make_test_counts();
2273
2274        for newest_formula in [
2275            None,
2276            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2277            Some(HEALTH_SCORE_FORMULA_VERSION),
2278        ] {
2279            let mut newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(72.0));
2280            newer.score_formula_version = newest_formula;
2281            let trend = compute_trend(&vs, &counts, Some(78.0), &[older.clone(), newer]).unwrap();
2282            assert_eq!(trend.compared_to.score, Some(72.0));
2283            assert_eq!(trend.snapshots_loaded, 2);
2284            assert_eq!(
2285                trend.metrics.iter().any(|metric| metric.name == "score"),
2286                newest_formula == Some(HEALTH_SCORE_FORMULA_VERSION)
2287            );
2288        }
2289    }
2290
2291    #[test]
2292    fn compute_trend_includes_raw_counts() {
2293        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2294        let vs = make_test_vital_signs();
2295        let counts = make_test_counts();
2296
2297        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2298        let dead_files = trend
2299            .metrics
2300            .iter()
2301            .find(|m| m.name == "dead_file_pct")
2302            .unwrap();
2303        assert!(dead_files.previous_count.is_some());
2304        assert!(dead_files.current_count.is_some());
2305    }
2306
2307    fn make_test_vital_signs() -> VitalSigns {
2308        VitalSigns {
2309            dead_file_pct: Some(3.2),
2310            dead_export_pct: Some(8.1),
2311            avg_cyclomatic: 4.2,
2312            p90_cyclomatic: 12,
2313            hotspot_count: Some(5),
2314            maintainability_avg: Some(72.4),
2315            unused_dep_count: Some(4),
2316            circular_dep_count: Some(2),
2317            ..Default::default()
2318        }
2319    }
2320
2321    fn make_test_counts() -> VitalSignsCounts {
2322        VitalSignsCounts {
2323            total_files: 100,
2324            total_exports: 500,
2325            dead_files: 3,
2326            dead_exports: 40,
2327            files_scored: Some(95),
2328            total_deps: 42,
2329            ..Default::default()
2330        }
2331    }
2332
2333    fn make_test_snapshot(timestamp: &str, score: Option<f64>) -> VitalSignsSnapshot {
2334        VitalSignsSnapshot {
2335            groups: None,
2336            snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
2337            version: "2.5.5".into(),
2338            timestamp: timestamp.into(),
2339            git_sha: Some("abc1234".into()),
2340            git_branch: Some("main".into()),
2341            shallow_clone: false,
2342            vital_signs: VitalSigns {
2343                dead_file_pct: Some(3.2),
2344                dead_export_pct: Some(8.1),
2345                avg_cyclomatic: 4.7,
2346                p90_cyclomatic: 12,
2347                hotspot_count: Some(5),
2348                maintainability_avg: Some(72.4),
2349                unused_dep_count: Some(4),
2350                circular_dep_count: Some(2),
2351                ..Default::default()
2352            },
2353            counts: VitalSignsCounts {
2354                total_files: 100,
2355                total_exports: 500,
2356                dead_files: 3,
2357                dead_exports: 40,
2358                files_scored: Some(95),
2359                total_deps: 42,
2360                ..Default::default()
2361            },
2362            score,
2363            grade: score.map(|s| letter_grade(s).to_string()),
2364            score_formula_version: score.map(|_| HEALTH_SCORE_FORMULA_VERSION),
2365            coverage_model: None,
2366            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2367        }
2368    }
2369
2370    fn group_snapshot(key: &str, score: f64) -> fallow_output::GroupSnapshot {
2371        fallow_output::GroupSnapshot {
2372            key: key.to_owned(),
2373            files_analyzed: 10,
2374            vital_signs: VitalSigns {
2375                avg_cyclomatic: 3.0,
2376                p90_cyclomatic: 8,
2377                ..Default::default()
2378            },
2379            counts: VitalSignsCounts {
2380                total_files: 10,
2381                ..Default::default()
2382            },
2383            score: Some(score),
2384            grade: Some(letter_grade(score).to_string()),
2385            severity_critical_count: 2,
2386            hotspot_count: 1,
2387        }
2388    }
2389
2390    #[test]
2391    fn v10_snapshot_without_groups_still_deserializes() {
2392        let mut value =
2393            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2394        value["snapshot_schema_version"] = serde_json::json!(10);
2395        value.as_object_mut().unwrap().remove("groups");
2396        let snapshot: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2397        assert_eq!(snapshot.snapshot_schema_version, 10);
2398        assert!(snapshot.groups.is_none());
2399    }
2400
2401    #[test]
2402    fn v11_snapshot_round_trips_group_data() {
2403        let mut snapshot = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2404        snapshot.groups = Some(fallow_output::SnapshotGrouping {
2405            grouped_by: "owner".to_owned(),
2406            group_filter: Some(vec!["@team/*".to_owned()]),
2407            groups: vec![group_snapshot("@team/a", 81.5)],
2408        });
2409        let json = serde_json::to_string(&snapshot).unwrap();
2410        let loaded: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
2411        let groups = loaded.groups.expect("groups survive the round trip");
2412        assert_eq!(groups.grouped_by, "owner");
2413        assert_eq!(groups.group_filter, Some(vec!["@team/*".to_owned()]));
2414        assert_eq!(groups.groups[0].key, "@team/a");
2415        assert_eq!(groups.groups[0].score, Some(81.5));
2416        assert_eq!(groups.groups[0].severity_critical_count, 2);
2417        let ungrouped =
2418            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", None)).unwrap();
2419        assert!(
2420            ungrouped.get("groups").is_none(),
2421            "ungrouped snapshot omits groups"
2422        );
2423    }
2424
2425    #[test]
2426    fn explicit_trend_baseline_reads_that_file_only() {
2427        let dir = tempfile::tempdir().unwrap();
2428        let root = dir.path();
2429        let snap_dir = root.join(".fallow/snapshots");
2430        std::fs::create_dir_all(&snap_dir).unwrap();
2431        std::fs::write(
2432            snap_dir.join("newer.json"),
2433            serde_json::to_string(&make_test_snapshot("2026-09-01T00:00:00Z", Some(90.0))).unwrap(),
2434        )
2435        .unwrap();
2436        let explicit = root.join("restored.json");
2437        std::fs::write(
2438            &explicit,
2439            serde_json::to_string(&make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0))).unwrap(),
2440        )
2441        .unwrap();
2442
2443        let baseline = load_trend_baseline(root, Some(&explicit))
2444            .unwrap()
2445            .expect("explicit baseline");
2446        assert_eq!(baseline.snapshot.timestamp, "2026-01-01T00:00:00Z");
2447        assert_eq!(baseline.snapshots_loaded, 1);
2448        assert_eq!(baseline.path, explicit);
2449
2450        let directory = load_trend_baseline(root, None).unwrap().expect("newest");
2451        assert_eq!(directory.snapshot.timestamp, "2026-09-01T00:00:00Z");
2452        assert_eq!(directory.snapshots_loaded, 1);
2453    }
2454
2455    #[test]
2456    fn explicit_trend_baseline_fails_on_missing_or_invalid_file() {
2457        let dir = tempfile::tempdir().unwrap();
2458        let root = dir.path();
2459        let missing = load_trend_baseline(root, Some(&root.join("absent.json"))).unwrap_err();
2460        assert!(
2461            missing.contains("failed to read --trend-from snapshot"),
2462            "{missing}"
2463        );
2464        let invalid_path = root.join("invalid.json");
2465        std::fs::write(&invalid_path, "{\"not\": \"a snapshot\"}").unwrap();
2466        let invalid = load_trend_baseline(root, Some(&invalid_path)).unwrap_err();
2467        assert!(
2468            invalid.contains("is not a fallow health snapshot"),
2469            "{invalid}"
2470        );
2471        assert!(load_trend_baseline(root, None).unwrap().is_none());
2472    }
2473
2474    #[test]
2475    fn explicit_trend_baseline_error_does_not_echo_file_content() {
2476        let dir = tempfile::tempdir().unwrap();
2477        let root = dir.path();
2478        for (index, content) in [r#"{"timestamp": "private-value"}"#, r#"["private-value"]"#]
2479            .into_iter()
2480            .enumerate()
2481        {
2482            let path = root.join(format!("leak-{index}.json"));
2483            std::fs::write(&path, content).unwrap();
2484            let error = load_trend_baseline(root, Some(&path)).unwrap_err();
2485            assert!(error.contains("is not a fallow health snapshot"), "{error}");
2486            assert!(error.contains("line 1, column"), "{error}");
2487            assert!(!error.contains("private-value"), "{error}");
2488        }
2489    }
2490
2491    #[test]
2492    fn group_trend_compares_against_the_stored_group() {
2493        let baseline = TrendBaseline {
2494            snapshot: make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0)),
2495            path: PathBuf::from("baseline.json"),
2496            snapshots_loaded: 1,
2497        };
2498        let current = VitalSigns {
2499            avg_cyclomatic: 3.0,
2500            p90_cyclomatic: 8,
2501            ..Default::default()
2502        };
2503        let counts = VitalSignsCounts {
2504            total_files: 10,
2505            ..Default::default()
2506        };
2507        let current_score = HealthScore {
2508            score: 84.0,
2509            ..compute_health_score(&current, counts.total_files)
2510        };
2511        let trend = compute_group_trend(
2512            &baseline.without_groups(),
2513            baseline.snapshots_loaded,
2514            &group_snapshot("@team/a", 80.0),
2515            &current,
2516            &counts,
2517            Some(&current_score),
2518        );
2519        let score = trend
2520            .metrics
2521            .iter()
2522            .find(|metric| metric.name == "score")
2523            .expect("score metric");
2524        assert!((score.previous - 80.0).abs() < f64::EPSILON);
2525        assert!((score.delta - 4.0).abs() < 1e-9);
2526        assert_eq!(score.direction, TrendDirection::Improving);
2527        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2528        assert_eq!(trend.compared_to.score, Some(80.0));
2529    }
2530}