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                line: id + 1,
1320                col: 0,
1321                cyclomatic,
1322                cognitive: 0,
1323                line_count: 10,
1324                param_count: 0,
1325                react_hook_count: 0,
1326                react_jsx_max_depth: 0,
1327                react_prop_count: 0,
1328                source_hash: None,
1329                contributions: Vec::new(),
1330            }],
1331            ..crate::source::ModuleInfo::empty(crate::discover::FileId(id))
1332        }
1333    }
1334
1335    #[expect(
1336        clippy::cast_possible_truncation,
1337        reason = "test values are trivially small"
1338    )]
1339    fn make_modules() -> Vec<crate::source::ModuleInfo> {
1340        (0..10)
1341            .map(|i| make_module(i, (i as u16 + 1) * 2))
1342            .collect()
1343    }
1344
1345    fn assert_close(actual: f64, expected: f64) {
1346        assert!(
1347            (actual - expected).abs() < f64::EPSILON,
1348            "expected {expected}, got {actual}"
1349        );
1350    }
1351
1352    fn assert_some_close(actual: Option<f64>, expected: f64) {
1353        assert_close(actual.expect("expected metric to be present"), expected);
1354    }
1355
1356    #[test]
1357    fn compute_cyclomatic_stats() {
1358        let modules = make_modules();
1359        let input = VitalSignsInput {
1360            modules: &modules,
1361            module_filter: None,
1362            file_scores: None,
1363            hotspots: None,
1364            total_files: 10,
1365            analysis_counts: None,
1366        };
1367        let vs = compute_vital_signs(&input);
1368        assert!((vs.avg_cyclomatic - 11.0).abs() < f64::EPSILON);
1369        assert_eq!(vs.p90_cyclomatic, 18);
1370    }
1371
1372    #[test]
1373    fn cyclomatic_population_partitions_units_and_respects_module_filter() {
1374        let mut mixed = make_module(0, 1);
1375        let mut module_unit = mixed.complexity[0].clone();
1376        module_unit.name = "<module>".into();
1377        module_unit.cyclomatic = 31;
1378        let mut template_unit = module_unit.clone();
1379        template_unit.name = "<template>".into();
1380        template_unit.cyclomatic = 4;
1381        mixed.complexity.extend([module_unit, template_unit]);
1382        let modules = [mixed, make_module(1, 100)];
1383        let filter = rustc_hash::FxHashSet::from_iter([crate::discover::FileId(0)]);
1384        let input = VitalSignsInput {
1385            modules: &modules,
1386            module_filter: Some(&filter),
1387            file_scores: None,
1388            hotspots: None,
1389            total_files: 1,
1390            analysis_counts: None,
1391        };
1392        let vs = compute_vital_signs(&input);
1393        let population = vs.cyclomatic_population.unwrap();
1394        assert_eq!(population.functions.count, 1);
1395        assert_eq!(population.modules.count, 1);
1396        assert_eq!(population.templates.count, 1);
1397        assert_eq!(population.functions.sum, 1);
1398        assert_eq!(population.modules.sum, 31);
1399        assert_eq!(population.templates.sum, 4);
1400        assert_close(vs.avg_cyclomatic, 12.0);
1401        assert_eq!(vs.p90_cyclomatic, 31);
1402    }
1403
1404    #[test]
1405    fn compute_with_analysis_counts() {
1406        let modules = make_modules();
1407        let input = VitalSignsInput {
1408            modules: &modules,
1409            module_filter: None,
1410            file_scores: None,
1411            hotspots: None,
1412            total_files: 100,
1413            analysis_counts: Some(AnalysisCounts {
1414                total_exports: 500,
1415                dead_files: 5,
1416                dead_exports: 50,
1417                unused_deps: 3,
1418                circular_deps: 2,
1419                total_deps: 40,
1420            }),
1421        };
1422        let vs = compute_vital_signs(&input);
1423        assert_eq!(vs.dead_file_pct, Some(5.0)); // 5/100 * 100
1424        assert_eq!(vs.dead_export_pct, Some(10.0)); // 50/500 * 100
1425        assert_eq!(vs.unused_dep_count, Some(3));
1426        assert_eq!(vs.circular_dep_count, Some(2));
1427    }
1428
1429    #[test]
1430    fn compute_hotspot_count_with_threshold() {
1431        let hotspots = vec![
1432            HotspotEntry {
1433                path: PathBuf::from("a.ts"),
1434                score: 80.0,
1435                commits: 10,
1436                weighted_commits: 8.0,
1437                lines_added: 100,
1438                lines_deleted: 50,
1439                complexity_density: 0.5,
1440                fan_in: 5,
1441                trend: crate::churn::ChurnTrend::Stable,
1442                ownership: None,
1443                is_test_path: false,
1444            },
1445            HotspotEntry {
1446                path: PathBuf::from("b.ts"),
1447                score: 30.0, // Below threshold
1448                commits: 5,
1449                weighted_commits: 3.0,
1450                lines_added: 40,
1451                lines_deleted: 20,
1452                complexity_density: 0.2,
1453                fan_in: 2,
1454                trend: crate::churn::ChurnTrend::Cooling,
1455                ownership: None,
1456                is_test_path: false,
1457            },
1458            HotspotEntry {
1459                path: PathBuf::from("c.ts"),
1460                score: 50.0, // At threshold
1461                commits: 8,
1462                weighted_commits: 6.0,
1463                lines_added: 80,
1464                lines_deleted: 30,
1465                complexity_density: 0.4,
1466                fan_in: 3,
1467                trend: crate::churn::ChurnTrend::Accelerating,
1468                ownership: None,
1469                is_test_path: false,
1470            },
1471        ];
1472        let modules = Vec::new();
1473        let input = VitalSignsInput {
1474            modules: &modules,
1475            module_filter: None,
1476            file_scores: None,
1477            hotspots: Some(&hotspots),
1478            total_files: 10,
1479            analysis_counts: None,
1480        };
1481        let vs = compute_vital_signs(&input);
1482        assert_eq!(vs.hotspot_count, Some(2)); // 80.0 and 50.0 meet threshold
1483        assert_eq!(vs.hotspot_top_pct_count, Some(1)); // top 1% bucket rounds up to one file
1484    }
1485
1486    #[test]
1487    fn hotspot_penalty_ignores_subthreshold_ranked_entries() {
1488        let hotspots: Vec<HotspotEntry> = [49.9, 0.1]
1489            .into_iter()
1490            .enumerate()
1491            .map(|(index, score)| HotspotEntry {
1492                path: PathBuf::from(format!("src/active-{index}.ts")),
1493                score,
1494                commits: 3,
1495                weighted_commits: 3.0,
1496                lines_added: 10,
1497                lines_deleted: 5,
1498                complexity_density: 0.1,
1499                fan_in: 0,
1500                trend: crate::churn::ChurnTrend::Stable,
1501                ownership: None,
1502                is_test_path: false,
1503            })
1504            .collect();
1505        let input = VitalSignsInput {
1506            modules: &[],
1507            module_filter: None,
1508            file_scores: None,
1509            hotspots: Some(&hotspots),
1510            total_files: 200,
1511            analysis_counts: None,
1512        };
1513
1514        let vitals = compute_vital_signs(&input);
1515        let health = compute_health_score(&vitals, input.total_files);
1516
1517        assert_eq!(vitals.hotspot_count, Some(0));
1518        assert_eq!(vitals.hotspot_top_pct_count, Some(2));
1519        assert_some_close(health.penalties.hotspots, 0.0);
1520    }
1521
1522    #[test]
1523    fn empty_cyclomatic_population_is_measured_not_unknown() {
1524        let vs = compute_vital_signs(&VitalSignsInput {
1525            modules: &[],
1526            module_filter: None,
1527            file_scores: None,
1528            hotspots: None,
1529            total_files: 0,
1530            analysis_counts: None,
1531        });
1532        let population = vs.cyclomatic_population.unwrap();
1533        for group in [
1534            population.functions,
1535            population.modules,
1536            population.templates,
1537        ] {
1538            assert_eq!(group.count, 0);
1539            assert_eq!(group.sum, 0);
1540            assert_eq!(group.max, None);
1541        }
1542        assert_close(vs.avg_cyclomatic, 0.0);
1543        assert_eq!(vs.p90_cyclomatic, 0);
1544        assert_eq!(vs.critical_complexity_pct, None);
1545    }
1546
1547    #[test]
1548    fn compute_without_hotspots_gives_none() {
1549        let modules = Vec::new();
1550        let input = VitalSignsInput {
1551            modules: &modules,
1552            module_filter: None,
1553            file_scores: None,
1554            hotspots: None,
1555            total_files: 0,
1556            analysis_counts: None,
1557        };
1558        let vs = compute_vital_signs(&input);
1559        assert!(vs.hotspot_count.is_none());
1560    }
1561
1562    #[test]
1563    fn snapshot_save_and_load() {
1564        let dir = tempfile::tempdir().unwrap();
1565        let root = dir.path();
1566        let vs = VitalSigns {
1567            dead_file_pct: Some(3.2),
1568            dead_export_pct: Some(8.1),
1569            avg_cyclomatic: 4.7,
1570            p90_cyclomatic: 12,
1571            hotspot_count: Some(5),
1572            maintainability_avg: Some(72.4),
1573            unused_dep_count: Some(4),
1574            circular_dep_count: Some(2),
1575            ..Default::default()
1576        };
1577        let counts = VitalSignsCounts {
1578            total_files: 1200,
1579            total_exports: 5400,
1580            dead_files: 38,
1581            dead_exports: 437,
1582            files_scored: Some(1150),
1583            total_deps: 42,
1584            ..Default::default()
1585        };
1586        let health_score = compute_health_score(&vs, 1200);
1587        let snapshot = build_snapshot(
1588            vs,
1589            counts,
1590            root,
1591            false,
1592            Some(&health_score),
1593            None,
1594            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1595        );
1596        let saved_path = save_snapshot(&snapshot, root, None).unwrap();
1597
1598        assert!(saved_path.exists());
1599        assert!(saved_path.starts_with(root.join(".fallow/snapshots")));
1600
1601        let content = std::fs::read_to_string(&saved_path).unwrap();
1602        let loaded: VitalSignsSnapshot = serde_json::from_str(&content).unwrap();
1603        assert_eq!(loaded.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
1604        assert!((loaded.vital_signs.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
1605        assert_eq!(loaded.counts.total_files, 1200);
1606        assert!(loaded.score.is_some());
1607        assert!(loaded.grade.is_some());
1608    }
1609
1610    #[test]
1611    fn snapshot_save_explicit_path() {
1612        let dir = tempfile::tempdir().unwrap();
1613        let root = dir.path();
1614        let explicit = root.join("my-snapshot.json");
1615        let vs = VitalSigns {
1616            avg_cyclomatic: 1.0,
1617            p90_cyclomatic: 2,
1618            ..Default::default()
1619        };
1620        let counts = VitalSignsCounts::default();
1621        let snapshot = build_snapshot(
1622            vs,
1623            counts,
1624            root,
1625            false,
1626            None,
1627            None,
1628            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1629        );
1630        let saved = save_snapshot(&snapshot, root, Some(&explicit)).unwrap();
1631        assert_eq!(saved, explicit);
1632        assert!(explicit.exists());
1633    }
1634
1635    #[test]
1636    fn snapshot_save_creates_nested_dirs() {
1637        let dir = tempfile::tempdir().unwrap();
1638        let root = dir.path();
1639        let nested = root.join("a/b/c/snapshot.json");
1640        let vs = VitalSigns {
1641            avg_cyclomatic: 1.0,
1642            p90_cyclomatic: 2,
1643            ..Default::default()
1644        };
1645        let counts = VitalSignsCounts::default();
1646        let snapshot = build_snapshot(
1647            vs,
1648            counts,
1649            root,
1650            false,
1651            None,
1652            None,
1653            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1654        );
1655        let saved = save_snapshot(&snapshot, root, Some(&nested)).unwrap();
1656        assert_eq!(saved, nested);
1657        assert!(nested.exists());
1658    }
1659
1660    #[test]
1661    fn days_to_ymd_epoch() {
1662        assert_eq!(days_to_ymd(0), (1970, 1, 1));
1663    }
1664
1665    #[test]
1666    fn days_to_ymd_known_date() {
1667        assert_eq!(days_to_ymd(20_537), (2026, 3, 25));
1668    }
1669
1670    #[test]
1671    fn health_score_perfect() {
1672        let vs = VitalSigns {
1673            dead_file_pct: Some(0.0),
1674            dead_export_pct: Some(0.0),
1675            avg_cyclomatic: 1.0,
1676            p90_cyclomatic: 2,
1677            hotspot_count: Some(0),
1678            maintainability_avg: Some(90.0),
1679            unused_dep_count: Some(0),
1680            circular_dep_count: Some(0),
1681            ..Default::default()
1682        };
1683        let score = compute_health_score(&vs, 100);
1684        assert!((score.score - 100.0).abs() < f64::EPSILON);
1685        assert_eq!(score.grade, "A");
1686    }
1687
1688    #[test]
1689    fn health_score_no_optional_metrics() {
1690        let vs = VitalSigns {
1691            avg_cyclomatic: 1.0,
1692            p90_cyclomatic: 2,
1693            ..Default::default()
1694        };
1695        let score = compute_health_score(&vs, 0);
1696        assert!((score.score - 100.0).abs() < f64::EPSILON);
1697        assert_eq!(score.grade, "A");
1698        assert!(score.penalties.dead_files.is_none());
1699        assert!(score.penalties.unused_deps.is_none());
1700        assert!(score.penalties.duplication.is_none());
1701    }
1702
1703    #[test]
1704    fn health_score_dead_code_penalty() {
1705        let vs = VitalSigns {
1706            dead_file_pct: Some(50.0),
1707            dead_export_pct: Some(30.0),
1708            avg_cyclomatic: 1.0,
1709            p90_cyclomatic: 2,
1710            ..Default::default()
1711        };
1712        let score = compute_health_score(&vs, 100);
1713        assert!((score.score - 84.0).abs() < 0.1);
1714        assert_eq!(score.grade, "B");
1715    }
1716
1717    #[test]
1718    fn health_score_complexity_penalty() {
1719        let vs = VitalSigns {
1720            avg_cyclomatic: 5.5,
1721            p90_cyclomatic: 15,
1722            ..Default::default()
1723        };
1724        let score = compute_health_score(&vs, 100);
1725        assert!((score.score - 75.0).abs() < 0.1);
1726        assert_eq!(score.grade, "B");
1727    }
1728
1729    #[test]
1730    fn health_score_prop_drilling_penalty_opt_in() {
1731        let base = || VitalSigns {
1732            avg_cyclomatic: 1.0,
1733            p90_cyclomatic: 2,
1734            ..Default::default()
1735        };
1736        let base_score = compute_health_score(&base(), 100).score;
1737
1738        // Each located chain costs 1pt (depth is descriptive, not a multiplier).
1739        let three = VitalSigns {
1740            prop_drilling_chain_count: Some(3),
1741            prop_drilling_max_depth: Some(5),
1742            ..base()
1743        };
1744        assert!((base_score - compute_health_score(&three, 100).score - 3.0).abs() < 0.1);
1745
1746        // Capped at 5pt regardless of chain count.
1747        let many = VitalSigns {
1748            prop_drilling_chain_count: Some(20),
1749            ..base()
1750        };
1751        assert!((base_score - compute_health_score(&many, 100).score - 5.0).abs() < 0.1);
1752
1753        // Dormant by default: the opt-in rule is off, so the count is `None` and
1754        // the score is unchanged.
1755        let off = VitalSigns {
1756            prop_drilling_chain_count: None,
1757            ..base()
1758        };
1759        assert!((base_score - compute_health_score(&off, 100).score).abs() < f64::EPSILON);
1760    }
1761
1762    #[test]
1763    fn health_score_clamped_at_zero() {
1764        let vs = VitalSigns {
1765            dead_file_pct: Some(100.0),
1766            dead_export_pct: Some(100.0),
1767            avg_cyclomatic: 10.0,
1768            p90_cyclomatic: 30,
1769            hotspot_count: Some(50),
1770            maintainability_avg: Some(20.0),
1771            unused_dep_count: Some(100),
1772            circular_dep_count: Some(50),
1773            ..Default::default()
1774        };
1775        let score = compute_health_score(&vs, 100);
1776        assert!((score.score).abs() < f64::EPSILON);
1777        assert_eq!(score.grade, "F");
1778    }
1779
1780    #[test]
1781    fn health_score_hotspot_normalized_by_files() {
1782        let vs = VitalSigns {
1783            avg_cyclomatic: 1.0,
1784            p90_cyclomatic: 2,
1785            hotspot_count: Some(5),
1786            ..Default::default()
1787        };
1788        let score_100 = compute_health_score(&vs, 100);
1789        let score_1000 = compute_health_score(&vs, 1000);
1790        assert!(score_1000.score > score_100.score);
1791    }
1792
1793    #[test]
1794    fn health_score_thresholded_hotspots_can_use_full_budget() {
1795        let vs = VitalSigns {
1796            avg_cyclomatic: 1.0,
1797            p90_cyclomatic: 2,
1798            hotspot_count: Some(250),
1799            hotspot_top_pct_count: Some(250),
1800            ..Default::default()
1801        };
1802
1803        let score = compute_health_score(&vs, 25_000);
1804
1805        assert_some_close(score.penalties.hotspots, 10.0);
1806        assert_close(score.score, 90.0);
1807    }
1808
1809    #[test]
1810    fn health_score_duplication_penalty() {
1811        let vs = VitalSigns {
1812            cyclomatic_population: None,
1813            dead_file_pct: None,
1814            dead_export_pct: None,
1815            avg_cyclomatic: 1.0,
1816            critical_complexity_pct: None,
1817            p90_cyclomatic: 2,
1818            duplication_pct: Some(10.0), // 10% - 5% = 5 points
1819            hotspot_count: None,
1820            hotspot_top_pct_count: None,
1821            maintainability_avg: None,
1822            maintainability_low_pct: None,
1823            unused_dep_count: None,
1824            unused_deps_per_k_files: None,
1825            circular_dep_count: None,
1826            circular_deps_per_k_files: None,
1827            counts: None,
1828            unit_size_profile: None,
1829            functions_over_60_loc_per_k: None,
1830            unit_interfacing_profile: None,
1831            p95_fan_in: None,
1832            coupling_high_pct: None,
1833            prop_drilling_chain_count: None,
1834            prop_drilling_max_depth: None,
1835            p95_render_fan_in: None,
1836            render_fan_in_high_pct: None,
1837            max_render_fan_in: None,
1838            top_render_fan_in: Vec::new(),
1839            total_loc: 0,
1840        };
1841        let score = compute_health_score(&vs, 100);
1842        assert_eq!(score.penalties.duplication, Some(5.0));
1843
1844        let vs_low = VitalSigns {
1845            duplication_pct: Some(4.0),
1846            ..vs.clone()
1847        };
1848        let score_low = compute_health_score(&vs_low, 100);
1849        assert_eq!(score_low.penalties.duplication, Some(0.0));
1850
1851        let vs_high = VitalSigns {
1852            duplication_pct: Some(20.0),
1853            ..vs
1854        };
1855        let score_high = compute_health_score(&vs_high, 100);
1856        assert_eq!(score_high.penalties.duplication, Some(10.0));
1857    }
1858
1859    #[test]
1860    fn health_score_uses_scale_invariant_monorepo_signals() {
1861        let vs = VitalSigns {
1862            dead_file_pct: Some(4.0),
1863            dead_export_pct: Some(9.0),
1864            avg_cyclomatic: 2.3,
1865            critical_complexity_pct: Some(2.3),
1866            p90_cyclomatic: 4,
1867            duplication_pct: Some(6.0),
1868            hotspot_count: Some(250),
1869            hotspot_top_pct_count: Some(250),
1870            maintainability_avg: Some(91.0),
1871            maintainability_low_pct: Some(8.0),
1872            unused_dep_count: Some(180),
1873            unused_deps_per_k_files: Some(7.2),
1874            circular_dep_count: Some(450),
1875            circular_deps_per_k_files: Some(18.0),
1876            unit_size_profile: Some(RiskProfile {
1877                low_risk: 80.0,
1878                medium_risk: 12.7,
1879                high_risk: 5.0,
1880                very_high_risk: 2.3,
1881            }),
1882            functions_over_60_loc_per_k: Some(23.0),
1883            p95_fan_in: Some(7),
1884            coupling_high_pct: Some(4.0),
1885            ..Default::default()
1886        };
1887        let score = compute_health_score(&vs, 25_000);
1888        let penalties = &score.penalties;
1889
1890        assert_some_close(penalties.dead_files, 0.8);
1891        assert_some_close(penalties.dead_exports, 1.8);
1892        assert_close(penalties.complexity, 9.2);
1893        assert!((penalties.p90_complexity).abs() < f64::EPSILON);
1894        assert_some_close(penalties.maintainability, 12.0);
1895        assert_some_close(penalties.hotspots, 10.0);
1896        assert_some_close(penalties.unused_deps, 3.6);
1897        assert_some_close(penalties.circular_deps, 9.0);
1898        assert_some_close(penalties.unit_size, 10.0);
1899        assert_some_close(penalties.coupling, 2.0);
1900        assert_some_close(penalties.duplication, 1.0);
1901        assert_close(score.score, 40.6);
1902        assert_eq!(score.grade, "D");
1903    }
1904
1905    #[test]
1906    fn load_snapshots_empty_dir() {
1907        let dir = tempfile::tempdir().unwrap();
1908        let snaps = load_snapshots(dir.path());
1909        assert!(snaps.is_empty());
1910    }
1911
1912    #[test]
1913    fn load_snapshots_returns_sorted() {
1914        let dir = tempfile::tempdir().unwrap();
1915        let root = dir.path();
1916        let snap_dir = root.join(".fallow/snapshots");
1917        std::fs::create_dir_all(&snap_dir).unwrap();
1918
1919        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1920        let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(78.0));
1921
1922        std::fs::write(
1923            snap_dir.join("2026-03-01T00-00-00Z.json"),
1924            serde_json::to_string(&newer).unwrap(),
1925        )
1926        .unwrap();
1927        std::fs::write(
1928            snap_dir.join("2026-01-01T00-00-00Z.json"),
1929            serde_json::to_string(&older).unwrap(),
1930        )
1931        .unwrap();
1932
1933        let loaded = load_snapshots(root);
1934        assert_eq!(loaded.len(), 2);
1935        assert_eq!(loaded[0].timestamp, "2026-01-01T00:00:00Z");
1936        assert_eq!(loaded[1].timestamp, "2026-03-01T00:00:00Z");
1937    }
1938
1939    #[test]
1940    fn load_snapshots_skips_corrupt_files() {
1941        let dir = tempfile::tempdir().unwrap();
1942        let root = dir.path();
1943        let snap_dir = root.join(".fallow/snapshots");
1944        std::fs::create_dir_all(&snap_dir).unwrap();
1945
1946        std::fs::write(snap_dir.join("corrupt.json"), "not valid json").unwrap();
1947        let good = make_test_snapshot("2026-02-01T00:00:00Z", Some(80.0));
1948        std::fs::write(
1949            snap_dir.join("good.json"),
1950            serde_json::to_string(&good).unwrap(),
1951        )
1952        .unwrap();
1953
1954        let loaded = load_snapshots(root);
1955        assert_eq!(loaded.len(), 1);
1956        assert_eq!(loaded[0].timestamp, "2026-02-01T00:00:00Z");
1957    }
1958
1959    #[test]
1960    fn load_snapshots_ignores_non_json() {
1961        let dir = tempfile::tempdir().unwrap();
1962        let root = dir.path();
1963        let snap_dir = root.join(".fallow/snapshots");
1964        std::fs::create_dir_all(&snap_dir).unwrap();
1965
1966        std::fs::write(snap_dir.join("readme.txt"), "not a snapshot").unwrap();
1967
1968        let loaded = load_snapshots(root);
1969        assert!(loaded.is_empty());
1970    }
1971
1972    #[test]
1973    fn hotspot_penalty_respects_score_threshold_and_scoped_population() {
1974        for (scores, total_files, expected) in [
1975            (vec![49.9, 0.1], 563, 0.0),
1976            (vec![50.0, 49.9, 0.1], 563, 1.7),
1977            (vec![50.0; 6], 563, 10.0),
1978            (vec![50.0; 7], 563, 10.0),
1979            (vec![50.0], 1, 10.0),
1980            (vec![50.0], 101, 5.0),
1981            (vec![50.0], 0, 0.0),
1982            (vec![], 563, 0.0),
1983        ] {
1984            let hotspots: Vec<HotspotEntry> = scores
1985                .into_iter()
1986                .enumerate()
1987                .map(|(index, score)| HotspotEntry {
1988                    path: PathBuf::from(format!("src/scoped-{index}.ts")),
1989                    score,
1990                    commits: 3,
1991                    weighted_commits: 3.0,
1992                    lines_added: 10,
1993                    lines_deleted: 5,
1994                    complexity_density: 0.1,
1995                    fan_in: 0,
1996                    trend: crate::churn::ChurnTrend::Stable,
1997                    ownership: None,
1998                    is_test_path: false,
1999                })
2000                .collect();
2001            let input = VitalSignsInput {
2002                modules: &[],
2003                module_filter: None,
2004                file_scores: None,
2005                hotspots: Some(&hotspots),
2006                total_files,
2007                analysis_counts: None,
2008            };
2009            let vitals = compute_vital_signs(&input);
2010            let score = compute_health_score(&vitals, total_files);
2011            assert_some_close(score.penalties.hotspots, expected);
2012            assert_eq!(score.formula_version, HEALTH_SCORE_FORMULA_VERSION);
2013        }
2014    }
2015
2016    #[test]
2017    fn hotspot_penalty_requires_thresholded_measurement_and_ignores_rank_diagnostic() {
2018        let mut vitals = VitalSigns {
2019            hotspot_top_pct_count: Some(6),
2020            ..Default::default()
2021        };
2022        assert!(
2023            compute_health_score(&vitals, 563)
2024                .penalties
2025                .hotspots
2026                .is_none()
2027        );
2028
2029        vitals.hotspot_count = Some(1);
2030        for diagnostic in [None, Some(0), Some(6)] {
2031            vitals.hotspot_top_pct_count = diagnostic;
2032            assert_some_close(compute_health_score(&vitals, 563).penalties.hotspots, 1.7);
2033        }
2034    }
2035
2036    #[test]
2037    fn snapshot_persists_the_computed_score_formula() {
2038        let dir = tempfile::tempdir().unwrap();
2039        let vitals = make_test_vital_signs();
2040        let counts = make_test_counts();
2041        let mut health = compute_health_score(&vitals, counts.total_files);
2042        health.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2043        let snapshot = build_snapshot(
2044            vitals,
2045            counts,
2046            dir.path(),
2047            false,
2048            Some(&health),
2049            None,
2050            fallow_types::semantic::SemanticAnalysisIdentity::default(),
2051        );
2052        assert_eq!(snapshot.score_formula_version, Some(health.formula_version));
2053        assert_eq!(snapshot.score, Some(health.score));
2054        assert_eq!(snapshot.grade.as_deref(), Some(health.grade));
2055
2056        let unscored = build_snapshot(
2057            snapshot.vital_signs,
2058            snapshot.counts,
2059            dir.path(),
2060            false,
2061            None,
2062            None,
2063            fallow_types::semantic::SemanticAnalysisIdentity::default(),
2064        );
2065        assert!(unscored.score_formula_version.is_none());
2066    }
2067
2068    #[test]
2069    fn trend_requires_both_scores_even_with_matching_formula_identity() {
2070        let current_vs = make_test_vital_signs();
2071        let counts = make_test_counts();
2072        let health = compute_health_score(&current_vs, counts.total_files);
2073        for (previous_score, current_score) in [(None, Some(&health)), (Some(72.0), None)] {
2074            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", previous_score);
2075            previous.score_formula_version = Some(health.formula_version);
2076            let trend = compute_trend_against(&previous, 1, &current_vs, &counts, current_score);
2077            assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2078            assert!(
2079                trend
2080                    .metrics
2081                    .iter()
2082                    .any(|metric| metric.name == "avg_cyclomatic")
2083            );
2084            assert!(
2085                fallow_output::health_score_comparison_note(
2086                    previous.score_formula_version,
2087                    Some(health.formula_version),
2088                )
2089                .is_none()
2090            );
2091        }
2092    }
2093
2094    #[test]
2095    fn trend_compares_only_known_matching_score_formulas() {
2096        let current_vs = make_test_vital_signs();
2097        let counts = make_test_counts();
2098        let current_score = compute_health_score(&current_vs, counts.total_files);
2099        for previous_formula in [
2100            None,
2101            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2102            Some(HEALTH_SCORE_FORMULA_VERSION),
2103            Some(HEALTH_SCORE_FORMULA_VERSION + 1),
2104        ] {
2105            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2106            previous.score_formula_version = previous_formula;
2107            let trend =
2108                compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2109            assert_eq!(
2110                trend.metrics.iter().any(|metric| metric.name == "score"),
2111                previous_formula == Some(current_score.formula_version),
2112                "previous formula: {previous_formula:?}"
2113            );
2114            assert!(
2115                trend
2116                    .metrics
2117                    .iter()
2118                    .any(|metric| metric.name == "avg_cyclomatic")
2119            );
2120            assert_eq!(trend.compared_to.score, previous.score);
2121            assert_eq!(trend.compared_to.grade, previous.grade);
2122            assert_eq!(trend.compared_to.score_formula_version, previous_formula);
2123        }
2124    }
2125
2126    #[test]
2127    fn trend_uses_actual_current_formula_and_keeps_raw_direction_on_mismatch() {
2128        let current_vs = make_test_vital_signs();
2129        let counts = make_test_counts();
2130        let mut current_score = compute_health_score(&current_vs, counts.total_files);
2131        current_score.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2132        current_score.score = 90.0;
2133        let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2134        previous.vital_signs = current_vs.clone();
2135        previous.counts = counts.clone();
2136        previous.vital_signs.avg_cyclomatic = current_vs.avg_cyclomatic - 2.0;
2137
2138        let incompatible =
2139            compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2140        assert!(
2141            !incompatible
2142                .metrics
2143                .iter()
2144                .any(|metric| metric.name == "score")
2145        );
2146        assert_eq!(incompatible.overall_direction, TrendDirection::Declining);
2147
2148        previous.score_formula_version = Some(current_score.formula_version);
2149        let compatible =
2150            compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2151        assert!(
2152            compatible
2153                .metrics
2154                .iter()
2155                .any(|metric| metric.name == "score")
2156        );
2157    }
2158
2159    #[test]
2160    fn legacy_snapshot_retains_unknown_formula_without_tool_version_inference() {
2161        let mut value =
2162            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2163        value["snapshot_schema_version"] = serde_json::json!(11);
2164        value["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
2165        value
2166            .as_object_mut()
2167            .unwrap()
2168            .remove("score_formula_version");
2169        let previous: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2170        assert!(previous.score_formula_version.is_none());
2171        let current_vs = make_test_vital_signs();
2172        let counts = make_test_counts();
2173        let health = compute_health_score(&current_vs, counts.total_files);
2174        let trend = compute_trend_against(&previous, 1, &current_vs, &counts, Some(&health));
2175        assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2176        assert!(!trend.metrics.is_empty());
2177    }
2178
2179    #[test]
2180    fn group_trend_uses_snapshot_level_formula_identity() {
2181        let current_vs = make_test_vital_signs();
2182        let counts = make_test_counts();
2183        let health = compute_health_score(&current_vs, counts.total_files);
2184        let group = group_snapshot("@team/a", 70.0);
2185        for formula in [
2186            None,
2187            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2188            Some(health.formula_version),
2189        ] {
2190            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2191            previous.score_formula_version = formula;
2192            let trend =
2193                compute_group_trend(&previous, 1, &group, &current_vs, &counts, Some(&health));
2194            assert_eq!(
2195                trend.metrics.iter().any(|metric| metric.name == "score"),
2196                formula == Some(health.formula_version),
2197            );
2198            assert_eq!(trend.compared_to.score, group.score);
2199            assert_eq!(trend.compared_to.grade, group.grade);
2200            assert_eq!(trend.compared_to.score_formula_version, formula);
2201            assert!(!trend.metrics.is_empty());
2202        }
2203    }
2204
2205    #[test]
2206    fn compute_trend_no_snapshots() {
2207        let vs = make_test_vital_signs();
2208        let counts = make_test_counts();
2209        assert!(compute_trend(&vs, &counts, Some(78.0), &[]).is_none());
2210    }
2211
2212    #[test]
2213    fn compute_trend_improving() {
2214        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2215        let vs = VitalSigns {
2216            dead_file_pct: Some(2.8),
2217            dead_export_pct: Some(7.5),
2218            avg_cyclomatic: 4.1,
2219            p90_cyclomatic: 12,
2220            hotspot_count: Some(3),
2221            maintainability_avg: Some(75.0),
2222            unused_dep_count: Some(3),
2223            circular_dep_count: Some(1),
2224            ..Default::default()
2225        };
2226        let counts = VitalSignsCounts {
2227            total_files: 100,
2228            total_exports: 500,
2229            dead_files: 3,
2230            dead_exports: 38,
2231            files_scored: Some(95),
2232            total_deps: 40,
2233            ..Default::default()
2234        };
2235
2236        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2237        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2238        assert_eq!(trend.snapshots_loaded, 1);
2239        assert_eq!(trend.overall_direction, TrendDirection::Improving);
2240
2241        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2242        assert_eq!(score_metric.direction, TrendDirection::Improving);
2243        assert!((score_metric.delta - 6.0).abs() < f64::EPSILON);
2244    }
2245
2246    #[test]
2247    fn compute_trend_stable_within_tolerance() {
2248        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(78.0));
2249        let vs = make_test_vital_signs();
2250        let counts = make_test_counts();
2251
2252        let trend = compute_trend(&vs, &counts, Some(78.3), &[prev]).unwrap();
2253        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2254        assert_eq!(score_metric.direction, TrendDirection::Stable);
2255    }
2256
2257    #[test]
2258    fn compute_trend_uses_most_recent_snapshot() {
2259        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0));
2260        let vs = make_test_vital_signs();
2261        let counts = make_test_counts();
2262
2263        for newest_formula in [
2264            None,
2265            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2266            Some(HEALTH_SCORE_FORMULA_VERSION),
2267        ] {
2268            let mut newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(72.0));
2269            newer.score_formula_version = newest_formula;
2270            let trend = compute_trend(&vs, &counts, Some(78.0), &[older.clone(), newer]).unwrap();
2271            assert_eq!(trend.compared_to.score, Some(72.0));
2272            assert_eq!(trend.snapshots_loaded, 2);
2273            assert_eq!(
2274                trend.metrics.iter().any(|metric| metric.name == "score"),
2275                newest_formula == Some(HEALTH_SCORE_FORMULA_VERSION)
2276            );
2277        }
2278    }
2279
2280    #[test]
2281    fn compute_trend_includes_raw_counts() {
2282        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2283        let vs = make_test_vital_signs();
2284        let counts = make_test_counts();
2285
2286        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2287        let dead_files = trend
2288            .metrics
2289            .iter()
2290            .find(|m| m.name == "dead_file_pct")
2291            .unwrap();
2292        assert!(dead_files.previous_count.is_some());
2293        assert!(dead_files.current_count.is_some());
2294    }
2295
2296    fn make_test_vital_signs() -> VitalSigns {
2297        VitalSigns {
2298            dead_file_pct: Some(3.2),
2299            dead_export_pct: Some(8.1),
2300            avg_cyclomatic: 4.2,
2301            p90_cyclomatic: 12,
2302            hotspot_count: Some(5),
2303            maintainability_avg: Some(72.4),
2304            unused_dep_count: Some(4),
2305            circular_dep_count: Some(2),
2306            ..Default::default()
2307        }
2308    }
2309
2310    fn make_test_counts() -> VitalSignsCounts {
2311        VitalSignsCounts {
2312            total_files: 100,
2313            total_exports: 500,
2314            dead_files: 3,
2315            dead_exports: 40,
2316            files_scored: Some(95),
2317            total_deps: 42,
2318            ..Default::default()
2319        }
2320    }
2321
2322    fn make_test_snapshot(timestamp: &str, score: Option<f64>) -> VitalSignsSnapshot {
2323        VitalSignsSnapshot {
2324            groups: None,
2325            snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
2326            version: "2.5.5".into(),
2327            timestamp: timestamp.into(),
2328            git_sha: Some("abc1234".into()),
2329            git_branch: Some("main".into()),
2330            shallow_clone: false,
2331            vital_signs: VitalSigns {
2332                dead_file_pct: Some(3.2),
2333                dead_export_pct: Some(8.1),
2334                avg_cyclomatic: 4.7,
2335                p90_cyclomatic: 12,
2336                hotspot_count: Some(5),
2337                maintainability_avg: Some(72.4),
2338                unused_dep_count: Some(4),
2339                circular_dep_count: Some(2),
2340                ..Default::default()
2341            },
2342            counts: VitalSignsCounts {
2343                total_files: 100,
2344                total_exports: 500,
2345                dead_files: 3,
2346                dead_exports: 40,
2347                files_scored: Some(95),
2348                total_deps: 42,
2349                ..Default::default()
2350            },
2351            score,
2352            grade: score.map(|s| letter_grade(s).to_string()),
2353            score_formula_version: score.map(|_| HEALTH_SCORE_FORMULA_VERSION),
2354            coverage_model: None,
2355            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2356        }
2357    }
2358
2359    fn group_snapshot(key: &str, score: f64) -> fallow_output::GroupSnapshot {
2360        fallow_output::GroupSnapshot {
2361            key: key.to_owned(),
2362            files_analyzed: 10,
2363            vital_signs: VitalSigns {
2364                avg_cyclomatic: 3.0,
2365                p90_cyclomatic: 8,
2366                ..Default::default()
2367            },
2368            counts: VitalSignsCounts {
2369                total_files: 10,
2370                ..Default::default()
2371            },
2372            score: Some(score),
2373            grade: Some(letter_grade(score).to_string()),
2374            severity_critical_count: 2,
2375            hotspot_count: 1,
2376        }
2377    }
2378
2379    #[test]
2380    fn v10_snapshot_without_groups_still_deserializes() {
2381        let mut value =
2382            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2383        value["snapshot_schema_version"] = serde_json::json!(10);
2384        value.as_object_mut().unwrap().remove("groups");
2385        let snapshot: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2386        assert_eq!(snapshot.snapshot_schema_version, 10);
2387        assert!(snapshot.groups.is_none());
2388    }
2389
2390    #[test]
2391    fn v11_snapshot_round_trips_group_data() {
2392        let mut snapshot = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2393        snapshot.groups = Some(fallow_output::SnapshotGrouping {
2394            grouped_by: "owner".to_owned(),
2395            group_filter: Some(vec!["@team/*".to_owned()]),
2396            groups: vec![group_snapshot("@team/a", 81.5)],
2397        });
2398        let json = serde_json::to_string(&snapshot).unwrap();
2399        let loaded: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
2400        let groups = loaded.groups.expect("groups survive the round trip");
2401        assert_eq!(groups.grouped_by, "owner");
2402        assert_eq!(groups.group_filter, Some(vec!["@team/*".to_owned()]));
2403        assert_eq!(groups.groups[0].key, "@team/a");
2404        assert_eq!(groups.groups[0].score, Some(81.5));
2405        assert_eq!(groups.groups[0].severity_critical_count, 2);
2406        let ungrouped =
2407            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", None)).unwrap();
2408        assert!(
2409            ungrouped.get("groups").is_none(),
2410            "ungrouped snapshot omits groups"
2411        );
2412    }
2413
2414    #[test]
2415    fn explicit_trend_baseline_reads_that_file_only() {
2416        let dir = tempfile::tempdir().unwrap();
2417        let root = dir.path();
2418        let snap_dir = root.join(".fallow/snapshots");
2419        std::fs::create_dir_all(&snap_dir).unwrap();
2420        std::fs::write(
2421            snap_dir.join("newer.json"),
2422            serde_json::to_string(&make_test_snapshot("2026-09-01T00:00:00Z", Some(90.0))).unwrap(),
2423        )
2424        .unwrap();
2425        let explicit = root.join("restored.json");
2426        std::fs::write(
2427            &explicit,
2428            serde_json::to_string(&make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0))).unwrap(),
2429        )
2430        .unwrap();
2431
2432        let baseline = load_trend_baseline(root, Some(&explicit))
2433            .unwrap()
2434            .expect("explicit baseline");
2435        assert_eq!(baseline.snapshot.timestamp, "2026-01-01T00:00:00Z");
2436        assert_eq!(baseline.snapshots_loaded, 1);
2437        assert_eq!(baseline.path, explicit);
2438
2439        let directory = load_trend_baseline(root, None).unwrap().expect("newest");
2440        assert_eq!(directory.snapshot.timestamp, "2026-09-01T00:00:00Z");
2441        assert_eq!(directory.snapshots_loaded, 1);
2442    }
2443
2444    #[test]
2445    fn explicit_trend_baseline_fails_on_missing_or_invalid_file() {
2446        let dir = tempfile::tempdir().unwrap();
2447        let root = dir.path();
2448        let missing = load_trend_baseline(root, Some(&root.join("absent.json"))).unwrap_err();
2449        assert!(
2450            missing.contains("failed to read --trend-from snapshot"),
2451            "{missing}"
2452        );
2453        let invalid_path = root.join("invalid.json");
2454        std::fs::write(&invalid_path, "{\"not\": \"a snapshot\"}").unwrap();
2455        let invalid = load_trend_baseline(root, Some(&invalid_path)).unwrap_err();
2456        assert!(
2457            invalid.contains("is not a fallow health snapshot"),
2458            "{invalid}"
2459        );
2460        assert!(load_trend_baseline(root, None).unwrap().is_none());
2461    }
2462
2463    #[test]
2464    fn explicit_trend_baseline_error_does_not_echo_file_content() {
2465        let dir = tempfile::tempdir().unwrap();
2466        let root = dir.path();
2467        for (index, content) in [r#"{"timestamp": "private-value"}"#, r#"["private-value"]"#]
2468            .into_iter()
2469            .enumerate()
2470        {
2471            let path = root.join(format!("leak-{index}.json"));
2472            std::fs::write(&path, content).unwrap();
2473            let error = load_trend_baseline(root, Some(&path)).unwrap_err();
2474            assert!(error.contains("is not a fallow health snapshot"), "{error}");
2475            assert!(error.contains("line 1, column"), "{error}");
2476            assert!(!error.contains("private-value"), "{error}");
2477        }
2478    }
2479
2480    #[test]
2481    fn group_trend_compares_against_the_stored_group() {
2482        let baseline = TrendBaseline {
2483            snapshot: make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0)),
2484            path: PathBuf::from("baseline.json"),
2485            snapshots_loaded: 1,
2486        };
2487        let current = VitalSigns {
2488            avg_cyclomatic: 3.0,
2489            p90_cyclomatic: 8,
2490            ..Default::default()
2491        };
2492        let counts = VitalSignsCounts {
2493            total_files: 10,
2494            ..Default::default()
2495        };
2496        let current_score = HealthScore {
2497            score: 84.0,
2498            ..compute_health_score(&current, counts.total_files)
2499        };
2500        let trend = compute_group_trend(
2501            &baseline.without_groups(),
2502            baseline.snapshots_loaded,
2503            &group_snapshot("@team/a", 80.0),
2504            &current,
2505            &counts,
2506            Some(&current_score),
2507        );
2508        let score = trend
2509            .metrics
2510            .iter()
2511            .find(|metric| metric.name == "score")
2512            .expect("score metric");
2513        assert!((score.previous - 80.0).abs() < f64::EPSILON);
2514        assert!((score.delta - 4.0).abs() < 1e-9);
2515        assert_eq!(score.direction, TrendDirection::Improving);
2516        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2517        assert_eq!(trend.compared_to.score, Some(80.0));
2518    }
2519}