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
14use fallow_output::{
15    DEFAULT_CYCLOMATIC_CRITICAL, FileHealthScore, HEALTH_SCORE_FORMULA_VERSION,
16    HOTSPOT_SCORE_THRESHOLD, HealthScore, HealthScorePenalties, HealthTrend, HotspotEntry,
17    RiskProfile, SNAPSHOT_SCHEMA_VERSION, TrendCount, TrendDirection, TrendMetric, TrendPoint,
18    VitalSigns, VitalSignsCounts, VitalSignsSnapshot, letter_grade,
19};
20
21/// Data sources for computing vital signs.
22///
23/// Fields are `Option` because not all pipelines run in every health invocation.
24pub(crate) struct VitalSignsInput<'a> {
25    /// All parsed modules (always available).
26    pub(crate) modules: &'a [crate::source::ModuleInfo],
27    /// Optional file-id allowlist used to restrict per-module aggregates
28    /// (cyclomatic distribution, total LOC, unit profiles) to a subset.
29    /// Used by `--workspace` and `--group-by` to scope project-wide metrics
30    /// to a single workspace package without re-parsing.
31    /// `None` includes every module in `modules`.
32    pub(crate) module_filter: Option<&'a rustc_hash::FxHashSet<crate::discover::FileId>>,
33    /// File health scores (available when file_scores/hotspots/targets are computed).
34    pub(crate) file_scores: Option<&'a [FileHealthScore]>,
35    /// Hotspot entries (available when hotspots are computed).
36    pub(crate) hotspots: Option<&'a [HotspotEntry]>,
37    /// Total discovered files (already scoped to the workspace when `--workspace` is set).
38    pub(crate) total_files: usize,
39    /// Analysis results (available when file_scores pipeline ran). When a
40    /// `module_filter` is also set, callers should pass workspace-scoped
41    /// counts here so `dead_*_pct` denominators line up with the rest of the
42    /// metrics.
43    pub(crate) analysis_counts: Option<AnalysisCounts>,
44}
45
46impl<'a> VitalSignsInput<'a> {
47    /// Iterate the modules selected by `module_filter`.
48    fn selected_modules(&self) -> impl Iterator<Item = &'a crate::source::ModuleInfo> + '_ {
49        let filter = self.module_filter;
50        self.modules
51            .iter()
52            .filter(move |m| filter.is_none_or(|set| set.contains(&m.file_id)))
53    }
54}
55
56/// Aggregate counts from the analysis pipeline.
57#[derive(Clone, Copy)]
58pub struct AnalysisCounts {
59    pub(crate) total_exports: usize,
60    pub(crate) dead_files: usize,
61    pub(crate) dead_exports: usize,
62    pub(crate) unused_deps: usize,
63    pub(crate) circular_deps: usize,
64    pub(crate) total_deps: usize,
65}
66
67/// Every unit's cyclomatic score, sorted, feeding the average, the critical
68/// share, and p90. The synthetic module-scope unit is included: its decision
69/// points are real branching, and leaving them out is what let a file's average
70/// read low while its top level was a guard ladder.
71fn collect_sorted_cyclomatic(input: &VitalSignsInput<'_>) -> Vec<u16> {
72    let mut values: Vec<u16> = input
73        .selected_modules()
74        .flat_map(|m| m.complexity.iter().map(|c| c.cyclomatic))
75        .collect();
76    values.sort_unstable();
77    values
78}
79
80fn average_cyclomatic(all_cyclomatic: &[u16]) -> f64 {
81    if all_cyclomatic.is_empty() {
82        return 0.0;
83    }
84
85    let sum: u64 = all_cyclomatic.iter().map(|&c| u64::from(c)).sum();
86    (sum as f64 / all_cyclomatic.len() as f64 * 10.0).round() / 10.0
87}
88
89fn critical_complexity_pct(all_cyclomatic: &[u16]) -> Option<f64> {
90    if all_cyclomatic.is_empty() {
91        return None;
92    }
93
94    let critical_count = all_cyclomatic
95        .iter()
96        .filter(|&&c| c >= DEFAULT_CYCLOMATIC_CRITICAL)
97        .count();
98    Some((critical_count as f64 / all_cyclomatic.len() as f64 * 1000.0).round() / 10.0)
99}
100
101#[expect(
102    clippy::cast_possible_truncation,
103    reason = "percentile index is bounded by the cyclomatic collection length"
104)]
105fn p90_cyclomatic(all_cyclomatic: &[u16]) -> u32 {
106    if all_cyclomatic.is_empty() {
107        return 0;
108    }
109
110    let idx = (all_cyclomatic.len() as f64 * 0.9).ceil() as usize;
111    let idx = idx.min(all_cyclomatic.len()) - 1;
112    u32::from(all_cyclomatic[idx])
113}
114
115#[expect(
116    clippy::cast_possible_truncation,
117    reason = "analysis counts are bounded by project size and emitted as compact u32 metrics"
118)]
119fn analysis_count_vitals(
120    counts: Option<&AnalysisCounts>,
121    total_files: usize,
122) -> (Option<f64>, Option<f64>, Option<u32>, Option<u32>) {
123    let Some(counts) = counts else {
124        return (None, None, None, None);
125    };
126
127    let dead_file_pct = if total_files > 0 {
128        Some((counts.dead_files as f64 / total_files as f64 * 1000.0).round() / 10.0)
129    } else {
130        Some(0.0)
131    };
132    let dead_export_pct = if counts.total_exports > 0 {
133        Some((counts.dead_exports as f64 / counts.total_exports as f64 * 1000.0).round() / 10.0)
134    } else {
135        Some(0.0)
136    };
137
138    (
139        dead_file_pct,
140        dead_export_pct,
141        Some(counts.unused_deps as u32),
142        Some(counts.circular_deps as u32),
143    )
144}
145
146struct SelectedModuleMetrics {
147    total_loc: u64,
148    line_counts: Vec<u32>,
149    param_counts: Vec<u8>,
150    cyclomatic_population: fallow_output::CyclomaticPopulation,
151}
152
153fn selected_module_metrics(input: &VitalSignsInput<'_>) -> SelectedModuleMetrics {
154    let mut total_loc = 0;
155    let mut line_counts = Vec::new();
156    let mut param_counts = Vec::new();
157    let mut cyclomatic_population = fallow_output::CyclomaticPopulation::default();
158
159    for module in input.selected_modules() {
160        total_loc += module.line_offsets.len() as u64;
161        // The synthetic module-scope unit is excluded from both distributions.
162        // It has no parameter list, and its size is the distance between its
163        // first and last decision point rather than a body anyone can shorten,
164        // so it is not a refactoring signal in the unit-size profile.
165        for unit in &module.complexity {
166            let is_module = fallow_types::extract::is_synthetic_module_unit(&unit.name);
167            let population = if is_module {
168                &mut cyclomatic_population.modules
169            } else if fallow_types::extract::is_synthetic_template_unit(&unit.name) {
170                &mut cyclomatic_population.templates
171            } else {
172                &mut cyclomatic_population.functions
173            };
174            population.count += 1;
175            population.sum += u64::from(unit.cyclomatic);
176            population.max = Some(population.max.unwrap_or_default().max(unit.cyclomatic));
177            if is_module {
178                continue;
179            }
180            line_counts.push(unit.line_count);
181            param_counts.push(unit.param_count);
182        }
183    }
184
185    SelectedModuleMetrics {
186        total_loc,
187        line_counts,
188        param_counts,
189        cyclomatic_population,
190    }
191}
192
193fn vital_sign_counts(input: &VitalSignsInput<'_>, total_loc: u64) -> Option<VitalSignsCounts> {
194    input.analysis_counts.as_ref().map(|ac| VitalSignsCounts {
195        total_files: input.total_files,
196        total_exports: ac.total_exports,
197        dead_files: ac.dead_files,
198        dead_exports: ac.dead_exports,
199        duplicated_lines: None,
200        total_lines: Some(total_loc as usize),
201        files_scored: input.file_scores.map(<[_]>::len),
202        total_deps: ac.total_deps,
203    })
204}
205
206/// Compute vital signs from available health data.
207pub(crate) fn compute_vital_signs(input: &VitalSignsInput<'_>) -> VitalSigns {
208    let all_cyclomatic = collect_sorted_cyclomatic(input);
209    let avg_cyclomatic = average_cyclomatic(&all_cyclomatic);
210    let critical_complexity_pct = critical_complexity_pct(&all_cyclomatic);
211    let p90_cyclomatic = p90_cyclomatic(&all_cyclomatic);
212
213    let (dead_file_pct, dead_export_pct, unused_dep_count, circular_dep_count) =
214        analysis_count_vitals(input.analysis_counts.as_ref(), input.total_files);
215    let unused_deps_per_k_files =
216        unused_dep_count.map(|count| per_k_files(count, input.total_files));
217    let circular_deps_per_k_files =
218        circular_dep_count.map(|count| per_k_files(count, input.total_files));
219
220    let (maintainability_avg, maintainability_low_pct) = maintainability_vitals(input.file_scores);
221
222    let (hotspot_count, hotspot_top_pct_count) = hotspot_vitals(input.hotspots, input.total_files);
223
224    let module_metrics = selected_module_metrics(input);
225    let counts = vital_sign_counts(input, module_metrics.total_loc);
226    let functions_over_60_loc_per_k = functions_over_60_loc_per_k(&module_metrics.line_counts);
227    let unit_size_profile = unit_size_profile(&module_metrics.line_counts);
228
229    let unit_interfacing_profile =
230        unit_interfacing_profile(&module_metrics.param_counts, &all_cyclomatic);
231
232    let (p95_fan_in, coupling_high_pct) = if let Some(scores) = input.file_scores {
233        compute_coupling_concentration(scores)
234    } else {
235        (None, None)
236    };
237
238    VitalSigns {
239        dead_file_pct,
240        dead_export_pct,
241        avg_cyclomatic,
242        critical_complexity_pct,
243        p90_cyclomatic,
244        cyclomatic_population: Some(module_metrics.cyclomatic_population),
245        duplication_pct: None, // Lazy: only set if duplication pipeline was run
246        hotspot_count,
247        hotspot_top_pct_count,
248        maintainability_avg,
249        maintainability_low_pct,
250        unused_dep_count,
251        unused_deps_per_k_files,
252        circular_dep_count,
253        circular_deps_per_k_files,
254        counts,
255        unit_size_profile,
256        functions_over_60_loc_per_k,
257        unit_interfacing_profile,
258        p95_fan_in,
259        coupling_high_pct,
260        // Set post-construction from the whole-project analysis results (only
261        // when the opt-in prop-drilling rule is enabled); see health/mod.rs.
262        prop_drilling_chain_count: None,
263        prop_drilling_max_depth: None,
264        // Set post-construction from the whole-project render fan-in metric
265        // (whenever React is declared, the core metric carries the aggregates);
266        // see health/mod.rs.
267        p95_render_fan_in: None,
268        render_fan_in_high_pct: None,
269        max_render_fan_in: None,
270        top_render_fan_in: Vec::new(),
271        total_loc: module_metrics.total_loc,
272    }
273}
274
275fn per_k_files(count: u32, total_files: usize) -> f64 {
276    if total_files == 0 {
277        0.0
278    } else {
279        (f64::from(count) / total_files as f64 * 10_000.0).round() / 10.0
280    }
281}
282
283fn maintainability_vitals(scores: Option<&[FileHealthScore]>) -> (Option<f64>, Option<f64>) {
284    let Some(scores) = scores.filter(|scores| !scores.is_empty()) else {
285        return (None, None);
286    };
287    let sum: f64 = scores.iter().map(|s| s.maintainability_index).sum();
288    let low_count = scores
289        .iter()
290        .filter(|s| s.maintainability_index < 70.0)
291        .count();
292    (
293        Some((sum / scores.len() as f64 * 10.0).round() / 10.0),
294        Some((low_count as f64 / scores.len() as f64 * 1000.0).round() / 10.0),
295    )
296}
297
298fn hotspot_vitals(
299    hotspots: Option<&[HotspotEntry]>,
300    total_files: usize,
301) -> (Option<u32>, Option<u32>) {
302    let hotspot_count = hotspots.map(|entries| {
303        entries
304            .iter()
305            .filter(|e| e.score >= HOTSPOT_SCORE_THRESHOLD)
306            .count() as u32
307    });
308    let hotspot_top_pct_count = hotspots.map(|entries| {
309        if total_files == 0 || entries.is_empty() {
310            return 0;
311        }
312        let top_count = (total_files as f64 * 0.01).ceil() as usize;
313        entries
314            .iter()
315            .take(top_count.max(1))
316            .filter(|entry| entry.score > 0.0)
317            .count() as u32
318    });
319    (hotspot_count, hotspot_top_pct_count)
320}
321
322fn functions_over_60_loc_per_k(line_counts: &[u32]) -> Option<f64> {
323    if line_counts.is_empty() {
324        return None;
325    }
326    let over_60 = line_counts
327        .iter()
328        .filter(|&&line_count| line_count > 60)
329        .count();
330    Some((over_60 as f64 / line_counts.len() as f64 * 10_000.0).round() / 10.0)
331}
332
333fn unit_size_profile(line_counts: &[u32]) -> Option<RiskProfile> {
334    (!line_counts.is_empty()).then(|| compute_size_risk_profile(line_counts))
335}
336
337fn unit_interfacing_profile(param_counts: &[u8], all_cyclomatic: &[u16]) -> Option<RiskProfile> {
338    if all_cyclomatic.is_empty() {
339        return None;
340    }
341    Some(compute_interfacing_risk_profile(param_counts))
342}
343
344/// Compute unit size risk profile from function line counts.
345///
346/// Bins: low risk (1-15 LOC), medium risk (16-30), high risk (31-60), very high risk (>60).
347fn compute_size_risk_profile(line_counts: &[u32]) -> RiskProfile {
348    if line_counts.is_empty() {
349        return RiskProfile {
350            low_risk: 0.0,
351            medium_risk: 0.0,
352            high_risk: 0.0,
353            very_high_risk: 0.0,
354        };
355    }
356    let total = line_counts.len() as f64;
357    let low = line_counts.iter().filter(|&&lc| lc <= 15).count() as f64;
358    let medium = line_counts
359        .iter()
360        .filter(|&&lc| (16..=30).contains(&lc))
361        .count() as f64;
362    let high = line_counts
363        .iter()
364        .filter(|&&lc| (31..=60).contains(&lc))
365        .count() as f64;
366    let very_high = line_counts.iter().filter(|&&lc| lc > 60).count() as f64;
367    RiskProfile {
368        low_risk: (low / total * 1000.0).round() / 10.0,
369        medium_risk: (medium / total * 1000.0).round() / 10.0,
370        high_risk: (high / total * 1000.0).round() / 10.0,
371        very_high_risk: (very_high / total * 1000.0).round() / 10.0,
372    }
373}
374
375/// Compute unit interfacing risk profile from function parameter counts.
376///
377/// Bins: low risk (0-2 params), medium risk (3-4), high risk (5-6), very high risk (>=7).
378fn compute_interfacing_risk_profile(param_counts: &[u8]) -> RiskProfile {
379    if param_counts.is_empty() {
380        return RiskProfile {
381            low_risk: 0.0,
382            medium_risk: 0.0,
383            high_risk: 0.0,
384            very_high_risk: 0.0,
385        };
386    }
387    let total = param_counts.len() as f64;
388    let low = param_counts.iter().filter(|&&pc| pc <= 2).count() as f64;
389    let medium = param_counts
390        .iter()
391        .filter(|&&pc| (3..=4).contains(&pc))
392        .count() as f64;
393    let high = param_counts
394        .iter()
395        .filter(|&&pc| (5..=6).contains(&pc))
396        .count() as f64;
397    let very_high = param_counts.iter().filter(|&&pc| pc >= 7).count() as f64;
398    RiskProfile {
399        low_risk: (low / total * 1000.0).round() / 10.0,
400        medium_risk: (medium / total * 1000.0).round() / 10.0,
401        high_risk: (high / total * 1000.0).round() / 10.0,
402        very_high_risk: (very_high / total * 1000.0).round() / 10.0,
403    }
404}
405
406/// Compute coupling concentration from file health scores.
407///
408/// Returns (p95_fan_in, coupling_high_pct) where coupling_high_pct is the
409/// percentage of files with fan-in above the effective threshold (max(p95_fan_in, 10)).
410///
411/// The component-graph analogue (render fan-in concentration:
412/// `p95_render_fan_in` / `render_fan_in_high_pct` / `max_render_fan_in`) is
413/// computed in core (`crate::render_fan_in`), which has the
414/// resolved-module graph the CLI lacks. It mirrors this helper verbatim (p95 +
415/// `high_pct` over the per-component distinct-parents distribution, reusing the
416/// same `max(p95, 10)` floor) and is assigned onto `VitalSigns` in
417/// `health/mod.rs::prepare_health_vital_data`.
418#[expect(
419    clippy::cast_possible_truncation,
420    reason = "fan-in values are bounded by project size"
421)]
422fn compute_coupling_concentration(scores: &[FileHealthScore]) -> (Option<u32>, Option<f64>) {
423    if scores.is_empty() {
424        return (None, None);
425    }
426    let mut fan_ins: Vec<usize> = scores.iter().map(|s| s.fan_in).collect();
427    fan_ins.sort_unstable();
428    let idx = (fan_ins.len() as f64 * 0.95).ceil() as usize;
429    let idx = idx.min(fan_ins.len()) - 1;
430    let p95 = fan_ins[idx] as u32;
431
432    let threshold = (p95 as usize).max(10);
433    let high_count = fan_ins.iter().filter(|&&fi| fi > threshold).count();
434    let high_pct = (high_count as f64 / fan_ins.len() as f64 * 1000.0).round() / 10.0;
435
436    (Some(p95), Some(high_pct))
437}
438
439/// Compute a project-level health score from vital signs.
440///
441/// The score starts at 100 and subtracts penalties for each metric.
442/// Missing metrics (from pipelines that didn't run) don't penalize.
443/// `total_files` is used to normalize the hotspot count penalty.
444pub(crate) fn compute_health_score(vs: &VitalSigns, total_files: usize) -> HealthScore {
445    let penalties = compute_health_score_penalties(vs, total_files);
446    let score = apply_health_score_penalties(&penalties);
447    let grade = letter_grade(score);
448
449    HealthScore {
450        formula_version: HEALTH_SCORE_FORMULA_VERSION,
451        score,
452        grade,
453        penalties,
454    }
455}
456
457fn compute_health_score_penalties(vs: &VitalSigns, total_files: usize) -> HealthScorePenalties {
458    HealthScorePenalties {
459        dead_files: vs.dead_file_pct.map(|pct| round1((pct * 0.2).min(15.0))),
460        dead_exports: vs.dead_export_pct.map(|pct| round1((pct * 0.2).min(15.0))),
461        complexity: complexity_penalty(vs),
462        p90_complexity: p90_complexity_penalty(vs),
463        maintainability: maintainability_penalty(vs),
464        hotspots: hotspot_penalty(vs, total_files),
465        unused_deps: dependency_count_penalty(
466            vs.unused_deps_per_k_files,
467            vs.unused_dep_count,
468            25.0,
469            10.0,
470        ),
471        circular_deps: dependency_count_penalty(
472            vs.circular_deps_per_k_files,
473            vs.circular_dep_count,
474            25.0,
475            10.0,
476        ),
477        unit_size: unit_size_penalty(vs),
478        coupling: coupling_penalty(vs),
479        duplication: vs
480            .duplication_pct
481            .map(|dp| round1((dp - 5.0).clamp(0.0, 10.0))),
482        prop_drilling: prop_drilling_penalty(vs),
483    }
484}
485
486fn apply_health_score_penalties(penalties: &HealthScorePenalties) -> f64 {
487    let mut score = 100.0_f64;
488
489    subtract_optional_penalty(&mut score, penalties.dead_files);
490    subtract_optional_penalty(&mut score, penalties.dead_exports);
491    score -= penalties.complexity;
492    score -= penalties.p90_complexity;
493    subtract_optional_penalty(&mut score, penalties.maintainability);
494    subtract_optional_penalty(&mut score, penalties.hotspots);
495    subtract_optional_penalty(&mut score, penalties.unused_deps);
496    subtract_optional_penalty(&mut score, penalties.circular_deps);
497    subtract_optional_penalty(&mut score, penalties.unit_size);
498    subtract_optional_penalty(&mut score, penalties.coupling);
499    subtract_optional_penalty(&mut score, penalties.duplication);
500    subtract_optional_penalty(&mut score, penalties.prop_drilling);
501
502    round1(score).clamp(0.0, 100.0)
503}
504
505/// Small capped penalty for prop-drilling chains, sized like the coupling
506/// penalty (~5pt cap). Each located chain costs 1pt up to the cap; a deeper
507/// chain does not cost more (depth is descriptive, not a tunable threshold).
508/// `None` (no penalty) unless the opt-in `prop-drilling` rule populated the
509/// count, so the score is unchanged by default.
510fn prop_drilling_penalty(vs: &VitalSigns) -> Option<f64> {
511    vs.prop_drilling_chain_count
512        .map(|count| round1((f64::from(count) * 1.0).min(5.0)))
513}
514
515fn round1(value: f64) -> f64 {
516    (value * 10.0).round() / 10.0
517}
518
519fn subtract_optional_penalty(score: &mut f64, penalty: Option<f64>) {
520    if let Some(penalty) = penalty {
521        *score -= penalty;
522    }
523}
524
525fn complexity_penalty(vs: &VitalSigns) -> f64 {
526    if let Some(critical_pct) = vs.critical_complexity_pct {
527        round1((critical_pct * 4.0).min(20.0))
528    } else {
529        round1(((vs.avg_cyclomatic - 1.5).max(0.0) * 5.0).min(20.0))
530    }
531}
532
533fn p90_complexity_penalty(vs: &VitalSigns) -> f64 {
534    if vs.critical_complexity_pct.is_some() {
535        0.0
536    } else {
537        round1((f64::from(vs.p90_cyclomatic) - 10.0).clamp(0.0, 10.0))
538    }
539}
540
541fn maintainability_penalty(vs: &VitalSigns) -> Option<f64> {
542    if let Some(low_pct) = vs.maintainability_low_pct {
543        Some(round1((low_pct * 1.5).min(15.0)))
544    } else {
545        vs.maintainability_avg
546            .map(|mi| round1(((70.0 - mi).max(0.0) * 0.5).min(15.0)))
547    }
548}
549
550fn hotspot_penalty(vs: &VitalSigns, total_files: usize) -> Option<f64> {
551    if let Some(top_pct_count) = vs.hotspot_top_pct_count {
552        return Some(if total_files > 0 {
553            let top_pct_bucket = (total_files as f64 * 0.01).ceil().max(1.0);
554            round1((f64::from(top_pct_count) / top_pct_bucket * 10.0).min(10.0))
555        } else {
556            0.0
557        });
558    }
559
560    vs.hotspot_count.map(|hc| {
561        if total_files > 0 {
562            round1((f64::from(hc) / total_files as f64 * 200.0).min(10.0))
563        } else {
564            0.0
565        }
566    })
567}
568
569fn dependency_count_penalty(
570    per_k: Option<f64>,
571    count: Option<u32>,
572    per_k_cap: f64,
573    count_cap: f64,
574) -> Option<f64> {
575    if let Some(per_k) = per_k {
576        Some(round1((per_k * 0.5).min(per_k_cap)))
577    } else {
578        count.map(|count| round1(f64::from(count).min(count_cap)))
579    }
580}
581
582fn unit_size_penalty(vs: &VitalSigns) -> Option<f64> {
583    if let Some(per_k) = vs.functions_over_60_loc_per_k {
584        Some(round1((per_k * 0.5).min(10.0)))
585    } else {
586        vs.unit_size_profile
587            .as_ref()
588            .map(|profile| round1(((profile.very_high_risk - 5.0).max(0.0) * 0.5).min(10.0)))
589    }
590}
591
592fn coupling_penalty(vs: &VitalSigns) -> Option<f64> {
593    if let Some(high_pct) = vs.coupling_high_pct {
594        Some(round1((high_pct * 0.5).min(5.0)))
595    } else {
596        vs.p95_fan_in
597            .map(|p95| round1(((f64::from(p95) - 30.0).max(0.0) * 0.25).min(5.0)))
598    }
599}
600
601/// Build the raw counts for a snapshot.
602pub(crate) fn build_counts(input: &VitalSignsInput<'_>) -> VitalSignsCounts {
603    let (total_exports, dead_files, dead_exports, total_deps) = input
604        .analysis_counts
605        .as_ref()
606        .map_or((0, 0, 0, 0), |counts| {
607            (
608                counts.total_exports,
609                counts.dead_files,
610                counts.dead_exports,
611                counts.total_deps,
612            )
613        });
614
615    let total_lines: usize = input.selected_modules().map(|m| m.line_offsets.len()).sum();
616
617    VitalSignsCounts {
618        total_files: input.total_files,
619        total_exports,
620        dead_files,
621        dead_exports,
622        duplicated_lines: None,
623        total_lines: Some(total_lines),
624        files_scored: input.file_scores.map(<[_]>::len),
625        total_deps,
626    }
627}
628
629/// Get the current git SHA (short form).
630#[expect(
631    clippy::disallowed_methods,
632    reason = "trusted git spawn with ambient repo-state env stripped, matching the core git spawn policy"
633)]
634fn git_sha(root: &Path) -> Option<String> {
635    let mut command = std::process::Command::new("git");
636    command
637        .args(["rev-parse", "--short", "HEAD"])
638        .current_dir(root);
639    clear_ambient_git_env(&mut command);
640    command
641        .output()
642        .ok()
643        .filter(|o| o.status.success())
644        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
645}
646
647/// Get the current git branch name.
648#[expect(
649    clippy::disallowed_methods,
650    reason = "trusted git spawn with ambient repo-state env stripped, matching the core git spawn policy"
651)]
652fn git_branch(root: &Path) -> Option<String> {
653    let mut command = std::process::Command::new("git");
654    command
655        .args(["rev-parse", "--abbrev-ref", "HEAD"])
656        .current_dir(root);
657    clear_ambient_git_env(&mut command);
658    command
659        .output()
660        .ok()
661        .filter(|o| o.status.success())
662        .and_then(|o| {
663            let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
664            if name == "HEAD" { None } else { Some(name) }
665        })
666}
667
668/// Build a snapshot from vital signs and input data.
669#[expect(
670    clippy::too_many_arguments,
671    reason = "snapshot construction keeps every persisted compatibility input explicit"
672)]
673pub(crate) fn build_snapshot(
674    vital_signs: VitalSigns,
675    counts: VitalSignsCounts,
676    root: &Path,
677    shallow_clone: bool,
678    health_score: Option<&HealthScore>,
679    coverage_model: Option<fallow_output::CoverageModel>,
680    analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
681) -> VitalSignsSnapshot {
682    let now = chrono_timestamp();
683
684    VitalSignsSnapshot {
685        snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
686        version: env!("CARGO_PKG_VERSION").to_string(),
687        timestamp: now,
688        git_sha: git_sha(root),
689        git_branch: git_branch(root),
690        shallow_clone,
691        vital_signs,
692        counts,
693        score: health_score.map(|s| s.score),
694        grade: health_score.map(|s| s.grade.to_string()),
695        coverage_model,
696        analysis_identity,
697    }
698}
699
700/// ISO 8601 UTC timestamp without external chrono dependency.
701pub fn chrono_timestamp() -> String {
702    use std::time::SystemTime;
703    let now = SystemTime::now()
704        .duration_since(SystemTime::UNIX_EPOCH)
705        .unwrap_or_default();
706    let secs = now.as_secs();
707
708    let days = secs / SECS_PER_DAY;
709    let time_secs = secs % SECS_PER_DAY;
710    let hours = time_secs / 3600;
711    let minutes = (time_secs % 3600) / 60;
712    let seconds = time_secs % 60;
713
714    let (year, month, day) = days_to_ymd(days);
715
716    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
717}
718
719/// Convert days since Unix epoch to (year, month, day).
720const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
721    let z = days + 719_468;
722    let era = z / 146_097;
723    let doe = z - era * 146_097;
724    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
725    let y = yoe + era * 400;
726    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
727    let mp = (5 * doy + 2) / 153;
728    let d = doy - (153 * mp + 2) / 5 + 1;
729    let m = if mp < 10 { mp + 3 } else { mp - 9 };
730    let y = if m <= 2 { y + 1 } else { y };
731    (y, m, d)
732}
733
734/// Save a snapshot to disk.
735///
736/// If `path` is `None`, writes to `.fallow/snapshots/{timestamp}.json`.
737/// Creates parent directories as needed.
738pub(crate) fn save_snapshot(
739    snapshot: &VitalSignsSnapshot,
740    root: &Path,
741    explicit_path: Option<&Path>,
742) -> Result<PathBuf, String> {
743    let path = explicit_path.map_or_else(
744        || {
745            let dir = root.join(".fallow").join("snapshots");
746            let filename = snapshot.timestamp.replace(':', "-");
747            dir.join(format!("{filename}.json"))
748        },
749        Path::to_path_buf,
750    );
751
752    if let Some(parent) = path.parent() {
753        std::fs::create_dir_all(parent)
754            .map_err(|e| format!("failed to create snapshot directory: {e}"))?;
755    }
756
757    let json =
758        serde_json::to_string_pretty(snapshot).map_err(|e| format!("failed to serialize: {e}"))?;
759    std::fs::write(&path, json).map_err(|e| format!("failed to write snapshot: {e}"))?;
760
761    Ok(path)
762}
763
764/// Load all snapshots from the default snapshot directory, sorted by timestamp ascending.
765///
766/// Corrupt or unreadable files are skipped with a warning to stderr.
767/// Returns an empty vec if the directory does not exist.
768#[expect(
769    clippy::print_stderr,
770    reason = "corrupt-snapshot warnings to stderr, preserved verbatim from the CLI health path"
771)]
772pub(crate) fn load_snapshots(root: &Path) -> Vec<VitalSignsSnapshot> {
773    let dir = root.join(".fallow").join("snapshots");
774    let Ok(entries) = std::fs::read_dir(&dir) else {
775        return Vec::new();
776    };
777
778    let mut snapshots = Vec::new();
779    for entry in entries {
780        let Ok(entry) = entry else { continue };
781        let path = entry.path();
782        if path.extension().is_some_and(|ext| ext == "json") {
783            match std::fs::read_to_string(&path) {
784                Ok(content) => match serde_json::from_str::<VitalSignsSnapshot>(&content) {
785                    Ok(snap) => snapshots.push(snap),
786                    Err(e) => {
787                        eprintln!("warning: skipping corrupt snapshot {}: {e}", path.display());
788                    }
789                },
790                Err(e) => {
791                    eprintln!("warning: could not read snapshot {}: {e}", path.display());
792                }
793            }
794        }
795    }
796
797    snapshots.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
798    snapshots
799}
800
801/// Tolerance for treating a metric delta as "stable" rather than improving/declining.
802const TREND_TOLERANCE: f64 = 0.5;
803
804fn trend_point_from_snapshot(prev: &VitalSignsSnapshot) -> TrendPoint {
805    TrendPoint {
806        timestamp: prev.timestamp.clone(),
807        git_sha: prev.git_sha.clone(),
808        score: prev.score,
809        grade: prev.grade.clone(),
810        coverage_model: prev.coverage_model.clone(),
811        snapshot_schema_version: Some(prev.snapshot_schema_version),
812    }
813}
814
815fn overall_trend_direction(metrics: &[TrendMetric]) -> TrendDirection {
816    let (improving, declining) = metrics.iter().fold((0usize, 0usize), |(imp, dec), metric| {
817        match metric.direction {
818            TrendDirection::Improving => (imp + 1, dec),
819            TrendDirection::Declining => (imp, dec + 1),
820            TrendDirection::Stable => (imp, dec),
821        }
822    });
823
824    match improving.cmp(&declining) {
825        std::cmp::Ordering::Greater => TrendDirection::Improving,
826        std::cmp::Ordering::Less => TrendDirection::Declining,
827        std::cmp::Ordering::Equal => TrendDirection::Stable,
828    }
829}
830
831/// Compute a trend comparison between the current run and the most recent snapshot.
832///
833/// Uses the stored `score` field from the snapshot (never re-derives it).
834/// Returns `None` if no snapshots are available.
835pub(crate) fn compute_trend(
836    current_vs: &VitalSigns,
837    current_counts: &VitalSignsCounts,
838    current_score: Option<f64>,
839    snapshots: &[VitalSignsSnapshot],
840) -> Option<HealthTrend> {
841    let prev = snapshots.last()?;
842
843    let compared_to = trend_point_from_snapshot(prev);
844
845    let metrics = TrendBuilder::new(prev, current_vs, current_counts, current_score).build();
846
847    let overall_direction = overall_trend_direction(&metrics);
848
849    Some(HealthTrend {
850        compared_to,
851        metrics,
852        snapshots_loaded: snapshots.len(),
853        overall_direction,
854    })
855}
856
857struct TrendBuilder<'a> {
858    prev: &'a VitalSignsSnapshot,
859    current_vs: &'a VitalSigns,
860    current_counts: &'a VitalSignsCounts,
861    current_score: Option<f64>,
862    metrics: Vec<TrendMetric>,
863}
864
865impl TrendBuilder<'_> {
866    fn new<'a>(
867        prev: &'a VitalSignsSnapshot,
868        current_vs: &'a VitalSigns,
869        current_counts: &'a VitalSignsCounts,
870        current_score: Option<f64>,
871    ) -> TrendBuilder<'a> {
872        TrendBuilder {
873            prev,
874            current_vs,
875            current_counts,
876            current_score,
877            metrics: Vec::new(),
878        }
879    }
880
881    fn build(mut self) -> Vec<TrendMetric> {
882        self.add_score_metric();
883        self.add_dead_code_metrics();
884        self.add_complexity_metrics();
885        self.add_dependency_metrics();
886        self.add_structure_metrics();
887        self.metrics
888    }
889
890    fn push(&mut self, input: TrendMetricInput) {
891        self.metrics.push(make_metric(input));
892    }
893
894    fn add_score_metric(&mut self) {
895        if let (Some(prev_score), Some(cur_score)) = (self.prev.score, self.current_score) {
896            self.push(TrendMetricInput {
897                name: "score",
898                label: "Health Score",
899                previous: prev_score,
900                current: cur_score,
901                unit: "",
902                higher_is_better: true,
903                previous_count: None,
904                current_count: None,
905            });
906        }
907    }
908
909    fn add_dead_code_metrics(&mut self) {
910        if let (Some(prev_val), Some(cur_val)) = (
911            self.prev.vital_signs.dead_file_pct,
912            self.current_vs.dead_file_pct,
913        ) {
914            self.push(TrendMetricInput {
915                name: "dead_file_pct",
916                label: "Dead Files",
917                previous: prev_val,
918                current: cur_val,
919                unit: "%",
920                higher_is_better: false,
921                previous_count: Some(TrendCount {
922                    value: self.prev.counts.dead_files,
923                    total: self.prev.counts.total_files,
924                }),
925                current_count: Some(TrendCount {
926                    value: self.current_counts.dead_files,
927                    total: self.current_counts.total_files,
928                }),
929            });
930        }
931
932        if let (Some(prev_val), Some(cur_val)) = (
933            self.prev.vital_signs.dead_export_pct,
934            self.current_vs.dead_export_pct,
935        ) {
936            self.push(TrendMetricInput {
937                name: "dead_export_pct",
938                label: "Dead Exports",
939                previous: prev_val,
940                current: cur_val,
941                unit: "%",
942                higher_is_better: false,
943                previous_count: Some(TrendCount {
944                    value: self.prev.counts.dead_exports,
945                    total: self.prev.counts.total_exports,
946                }),
947                current_count: Some(TrendCount {
948                    value: self.current_counts.dead_exports,
949                    total: self.current_counts.total_exports,
950                }),
951            });
952        }
953    }
954
955    fn add_complexity_metrics(&mut self) {
956        self.push(TrendMetricInput {
957            name: "avg_cyclomatic",
958            label: "Avg Cyclomatic",
959            previous: self.prev.vital_signs.avg_cyclomatic,
960            current: self.current_vs.avg_cyclomatic,
961            unit: "",
962            higher_is_better: false,
963            previous_count: None,
964            current_count: None,
965        });
966
967        if let (Some(prev_val), Some(cur_val)) = (
968            self.prev.vital_signs.maintainability_avg,
969            self.current_vs.maintainability_avg,
970        ) {
971            self.push(TrendMetricInput {
972                name: "maintainability_avg",
973                label: "Maintainability",
974                previous: prev_val,
975                current: cur_val,
976                unit: "",
977                higher_is_better: true,
978                previous_count: None,
979                current_count: None,
980            });
981        }
982
983        if let (Some(prev_profile), Some(cur_profile)) = (
984            &self.prev.vital_signs.unit_size_profile,
985            &self.current_vs.unit_size_profile,
986        ) {
987            self.push(TrendMetricInput {
988                name: "unit_size_very_high_pct",
989                label: "Oversized Fns",
990                previous: prev_profile.very_high_risk,
991                current: cur_profile.very_high_risk,
992                unit: "%",
993                higher_is_better: false,
994                previous_count: None,
995                current_count: None,
996            });
997        }
998
999        self.add_duplication_metric();
1000    }
1001
1002    fn add_duplication_metric(&mut self) {
1003        if let (Some(prev_val), Some(cur_val)) = (
1004            self.prev.vital_signs.duplication_pct,
1005            self.current_vs.duplication_pct,
1006        ) {
1007            self.push(TrendMetricInput {
1008                name: "duplication_pct",
1009                label: "Duplication",
1010                previous: prev_val,
1011                current: cur_val,
1012                unit: "%",
1013                higher_is_better: false,
1014                previous_count: self
1015                    .prev
1016                    .counts
1017                    .duplicated_lines
1018                    .zip(self.prev.counts.total_lines)
1019                    .map(|(d, t)| TrendCount { value: d, total: t }),
1020                current_count: self
1021                    .current_counts
1022                    .duplicated_lines
1023                    .zip(self.current_counts.total_lines)
1024                    .map(|(d, t)| TrendCount { value: d, total: t }),
1025            });
1026        }
1027    }
1028
1029    fn add_dependency_metrics(&mut self) {
1030        if let (Some(prev_val), Some(cur_val)) = (
1031            self.prev.vital_signs.unused_dep_count,
1032            self.current_vs.unused_dep_count,
1033        ) {
1034            self.push(TrendMetricInput {
1035                name: "unused_dep_count",
1036                label: "Unused Deps",
1037                previous: f64::from(prev_val),
1038                current: f64::from(cur_val),
1039                unit: "",
1040                higher_is_better: false,
1041                previous_count: None,
1042                current_count: None,
1043            });
1044        }
1045    }
1046
1047    fn add_structure_metrics(&mut self) {
1048        if let (Some(prev_val), Some(cur_val)) = (
1049            self.prev.vital_signs.circular_dep_count,
1050            self.current_vs.circular_dep_count,
1051        ) {
1052            self.push(TrendMetricInput {
1053                name: "circular_dep_count",
1054                label: "Circular Deps",
1055                previous: f64::from(prev_val),
1056                current: f64::from(cur_val),
1057                unit: "",
1058                higher_is_better: false,
1059                previous_count: None,
1060                current_count: None,
1061            });
1062        }
1063
1064        if let (Some(prev_val), Some(cur_val)) = (
1065            self.prev.vital_signs.hotspot_count,
1066            self.current_vs.hotspot_count,
1067        ) {
1068            self.push(TrendMetricInput {
1069                name: "hotspot_count",
1070                label: "Hotspots",
1071                previous: f64::from(prev_val),
1072                current: f64::from(cur_val),
1073                unit: "",
1074                higher_is_better: false,
1075                previous_count: None,
1076                current_count: None,
1077            });
1078        }
1079
1080        if let (Some(prev_val), Some(cur_val)) =
1081            (self.prev.vital_signs.p95_fan_in, self.current_vs.p95_fan_in)
1082        {
1083            self.push(TrendMetricInput {
1084                name: "p95_fan_in",
1085                label: "P95 Fan-in",
1086                previous: f64::from(prev_val),
1087                current: f64::from(cur_val),
1088                unit: "",
1089                higher_is_better: false,
1090                previous_count: None,
1091                current_count: None,
1092            });
1093        }
1094    }
1095}
1096
1097/// Build a single trend metric.
1098struct TrendMetricInput {
1099    name: &'static str,
1100    label: &'static str,
1101    previous: f64,
1102    current: f64,
1103    unit: &'static str,
1104    higher_is_better: bool,
1105    previous_count: Option<TrendCount>,
1106    current_count: Option<TrendCount>,
1107}
1108
1109fn make_metric(input: TrendMetricInput) -> TrendMetric {
1110    let TrendMetricInput {
1111        name,
1112        label,
1113        previous,
1114        current,
1115        unit,
1116        higher_is_better,
1117        previous_count,
1118        current_count,
1119    } = input;
1120    let delta = (current - previous).round_to(1);
1121    let direction = if delta.abs() < TREND_TOLERANCE {
1122        TrendDirection::Stable
1123    } else if (higher_is_better && delta > 0.0) || (!higher_is_better && delta < 0.0) {
1124        TrendDirection::Improving
1125    } else {
1126        TrendDirection::Declining
1127    };
1128
1129    TrendMetric {
1130        name,
1131        label,
1132        previous,
1133        current,
1134        delta,
1135        direction,
1136        unit,
1137        previous_count,
1138        current_count,
1139    }
1140}
1141
1142/// Extension trait for rounding floats to N decimal places.
1143trait RoundTo {
1144    fn round_to(self, decimals: u32) -> Self;
1145}
1146
1147impl RoundTo for f64 {
1148    fn round_to(self, decimals: u32) -> Self {
1149        let factor = 10_f64.powi(decimals as i32);
1150        (self * factor).round() / factor
1151    }
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    fn make_module(id: u32, cyclomatic: u16) -> crate::source::ModuleInfo {
1159        crate::source::ModuleInfo {
1160            complexity: vec![fallow_types::extract::FunctionComplexity {
1161                name: format!("fn_{id}"),
1162                is_private_member: false,
1163                line: id + 1,
1164                col: 0,
1165                cyclomatic,
1166                cognitive: 0,
1167                line_count: 10,
1168                param_count: 0,
1169                react_hook_count: 0,
1170                react_jsx_max_depth: 0,
1171                react_prop_count: 0,
1172                source_hash: None,
1173                contributions: Vec::new(),
1174            }],
1175            ..crate::source::ModuleInfo::empty(crate::discover::FileId(id))
1176        }
1177    }
1178
1179    #[expect(
1180        clippy::cast_possible_truncation,
1181        reason = "test values are trivially small"
1182    )]
1183    fn make_modules() -> Vec<crate::source::ModuleInfo> {
1184        (0..10)
1185            .map(|i| make_module(i, (i as u16 + 1) * 2))
1186            .collect()
1187    }
1188
1189    fn assert_close(actual: f64, expected: f64) {
1190        assert!(
1191            (actual - expected).abs() < f64::EPSILON,
1192            "expected {expected}, got {actual}"
1193        );
1194    }
1195
1196    fn assert_some_close(actual: Option<f64>, expected: f64) {
1197        assert_close(actual.expect("expected metric to be present"), expected);
1198    }
1199
1200    #[test]
1201    fn compute_cyclomatic_stats() {
1202        let modules = make_modules();
1203        let input = VitalSignsInput {
1204            modules: &modules,
1205            module_filter: None,
1206            file_scores: None,
1207            hotspots: None,
1208            total_files: 10,
1209            analysis_counts: None,
1210        };
1211        let vs = compute_vital_signs(&input);
1212        assert!((vs.avg_cyclomatic - 11.0).abs() < f64::EPSILON);
1213        assert_eq!(vs.p90_cyclomatic, 18);
1214    }
1215
1216    #[test]
1217    fn cyclomatic_population_partitions_units_and_respects_module_filter() {
1218        let mut mixed = make_module(0, 1);
1219        let mut module_unit = mixed.complexity[0].clone();
1220        module_unit.name = "<module>".into();
1221        module_unit.cyclomatic = 31;
1222        let mut template_unit = module_unit.clone();
1223        template_unit.name = "<template>".into();
1224        template_unit.cyclomatic = 4;
1225        mixed.complexity.extend([module_unit, template_unit]);
1226        let modules = [mixed, make_module(1, 100)];
1227        let filter = rustc_hash::FxHashSet::from_iter([crate::discover::FileId(0)]);
1228        let input = VitalSignsInput {
1229            modules: &modules,
1230            module_filter: Some(&filter),
1231            file_scores: None,
1232            hotspots: None,
1233            total_files: 1,
1234            analysis_counts: None,
1235        };
1236        let vs = compute_vital_signs(&input);
1237        let population = vs.cyclomatic_population.unwrap();
1238        assert_eq!(population.functions.count, 1);
1239        assert_eq!(population.modules.count, 1);
1240        assert_eq!(population.templates.count, 1);
1241        assert_eq!(population.functions.sum, 1);
1242        assert_eq!(population.modules.sum, 31);
1243        assert_eq!(population.templates.sum, 4);
1244        assert_close(vs.avg_cyclomatic, 12.0);
1245        assert_eq!(vs.p90_cyclomatic, 31);
1246    }
1247
1248    #[test]
1249    fn compute_with_analysis_counts() {
1250        let modules = make_modules();
1251        let input = VitalSignsInput {
1252            modules: &modules,
1253            module_filter: None,
1254            file_scores: None,
1255            hotspots: None,
1256            total_files: 100,
1257            analysis_counts: Some(AnalysisCounts {
1258                total_exports: 500,
1259                dead_files: 5,
1260                dead_exports: 50,
1261                unused_deps: 3,
1262                circular_deps: 2,
1263                total_deps: 40,
1264            }),
1265        };
1266        let vs = compute_vital_signs(&input);
1267        assert_eq!(vs.dead_file_pct, Some(5.0)); // 5/100 * 100
1268        assert_eq!(vs.dead_export_pct, Some(10.0)); // 50/500 * 100
1269        assert_eq!(vs.unused_dep_count, Some(3));
1270        assert_eq!(vs.circular_dep_count, Some(2));
1271    }
1272
1273    #[test]
1274    fn compute_hotspot_count_with_threshold() {
1275        let hotspots = vec![
1276            HotspotEntry {
1277                path: PathBuf::from("a.ts"),
1278                score: 80.0,
1279                commits: 10,
1280                weighted_commits: 8.0,
1281                lines_added: 100,
1282                lines_deleted: 50,
1283                complexity_density: 0.5,
1284                fan_in: 5,
1285                trend: crate::churn::ChurnTrend::Stable,
1286                ownership: None,
1287                is_test_path: false,
1288            },
1289            HotspotEntry {
1290                path: PathBuf::from("b.ts"),
1291                score: 30.0, // Below threshold
1292                commits: 5,
1293                weighted_commits: 3.0,
1294                lines_added: 40,
1295                lines_deleted: 20,
1296                complexity_density: 0.2,
1297                fan_in: 2,
1298                trend: crate::churn::ChurnTrend::Cooling,
1299                ownership: None,
1300                is_test_path: false,
1301            },
1302            HotspotEntry {
1303                path: PathBuf::from("c.ts"),
1304                score: 50.0, // At threshold
1305                commits: 8,
1306                weighted_commits: 6.0,
1307                lines_added: 80,
1308                lines_deleted: 30,
1309                complexity_density: 0.4,
1310                fan_in: 3,
1311                trend: crate::churn::ChurnTrend::Accelerating,
1312                ownership: None,
1313                is_test_path: false,
1314            },
1315        ];
1316        let modules = Vec::new();
1317        let input = VitalSignsInput {
1318            modules: &modules,
1319            module_filter: None,
1320            file_scores: None,
1321            hotspots: Some(&hotspots),
1322            total_files: 10,
1323            analysis_counts: None,
1324        };
1325        let vs = compute_vital_signs(&input);
1326        assert_eq!(vs.hotspot_count, Some(2)); // 80.0 and 50.0 meet threshold
1327        assert_eq!(vs.hotspot_top_pct_count, Some(1)); // top 1% bucket rounds up to one file
1328    }
1329
1330    #[test]
1331    fn empty_cyclomatic_population_is_measured_not_unknown() {
1332        let vs = compute_vital_signs(&VitalSignsInput {
1333            modules: &[],
1334            module_filter: None,
1335            file_scores: None,
1336            hotspots: None,
1337            total_files: 0,
1338            analysis_counts: None,
1339        });
1340        let population = vs.cyclomatic_population.unwrap();
1341        for group in [
1342            population.functions,
1343            population.modules,
1344            population.templates,
1345        ] {
1346            assert_eq!(group.count, 0);
1347            assert_eq!(group.sum, 0);
1348            assert_eq!(group.max, None);
1349        }
1350        assert_close(vs.avg_cyclomatic, 0.0);
1351        assert_eq!(vs.p90_cyclomatic, 0);
1352        assert_eq!(vs.critical_complexity_pct, None);
1353    }
1354
1355    #[test]
1356    fn compute_without_hotspots_gives_none() {
1357        let modules = Vec::new();
1358        let input = VitalSignsInput {
1359            modules: &modules,
1360            module_filter: None,
1361            file_scores: None,
1362            hotspots: None,
1363            total_files: 0,
1364            analysis_counts: None,
1365        };
1366        let vs = compute_vital_signs(&input);
1367        assert!(vs.hotspot_count.is_none());
1368    }
1369
1370    #[test]
1371    fn snapshot_save_and_load() {
1372        let dir = tempfile::tempdir().unwrap();
1373        let root = dir.path();
1374        let vs = VitalSigns {
1375            dead_file_pct: Some(3.2),
1376            dead_export_pct: Some(8.1),
1377            avg_cyclomatic: 4.7,
1378            p90_cyclomatic: 12,
1379            hotspot_count: Some(5),
1380            maintainability_avg: Some(72.4),
1381            unused_dep_count: Some(4),
1382            circular_dep_count: Some(2),
1383            ..Default::default()
1384        };
1385        let counts = VitalSignsCounts {
1386            total_files: 1200,
1387            total_exports: 5400,
1388            dead_files: 38,
1389            dead_exports: 437,
1390            files_scored: Some(1150),
1391            total_deps: 42,
1392            ..Default::default()
1393        };
1394        let health_score = compute_health_score(&vs, 1200);
1395        let snapshot = build_snapshot(
1396            vs,
1397            counts,
1398            root,
1399            false,
1400            Some(&health_score),
1401            None,
1402            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1403        );
1404        let saved_path = save_snapshot(&snapshot, root, None).unwrap();
1405
1406        assert!(saved_path.exists());
1407        assert!(saved_path.starts_with(root.join(".fallow/snapshots")));
1408
1409        let content = std::fs::read_to_string(&saved_path).unwrap();
1410        let loaded: VitalSignsSnapshot = serde_json::from_str(&content).unwrap();
1411        assert_eq!(loaded.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
1412        assert!((loaded.vital_signs.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
1413        assert_eq!(loaded.counts.total_files, 1200);
1414        assert!(loaded.score.is_some());
1415        assert!(loaded.grade.is_some());
1416    }
1417
1418    #[test]
1419    fn snapshot_save_explicit_path() {
1420        let dir = tempfile::tempdir().unwrap();
1421        let root = dir.path();
1422        let explicit = root.join("my-snapshot.json");
1423        let vs = VitalSigns {
1424            avg_cyclomatic: 1.0,
1425            p90_cyclomatic: 2,
1426            ..Default::default()
1427        };
1428        let counts = VitalSignsCounts::default();
1429        let snapshot = build_snapshot(
1430            vs,
1431            counts,
1432            root,
1433            false,
1434            None,
1435            None,
1436            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1437        );
1438        let saved = save_snapshot(&snapshot, root, Some(&explicit)).unwrap();
1439        assert_eq!(saved, explicit);
1440        assert!(explicit.exists());
1441    }
1442
1443    #[test]
1444    fn snapshot_save_creates_nested_dirs() {
1445        let dir = tempfile::tempdir().unwrap();
1446        let root = dir.path();
1447        let nested = root.join("a/b/c/snapshot.json");
1448        let vs = VitalSigns {
1449            avg_cyclomatic: 1.0,
1450            p90_cyclomatic: 2,
1451            ..Default::default()
1452        };
1453        let counts = VitalSignsCounts::default();
1454        let snapshot = build_snapshot(
1455            vs,
1456            counts,
1457            root,
1458            false,
1459            None,
1460            None,
1461            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1462        );
1463        let saved = save_snapshot(&snapshot, root, Some(&nested)).unwrap();
1464        assert_eq!(saved, nested);
1465        assert!(nested.exists());
1466    }
1467
1468    #[test]
1469    fn days_to_ymd_epoch() {
1470        assert_eq!(days_to_ymd(0), (1970, 1, 1));
1471    }
1472
1473    #[test]
1474    fn days_to_ymd_known_date() {
1475        assert_eq!(days_to_ymd(20_537), (2026, 3, 25));
1476    }
1477
1478    #[test]
1479    fn health_score_perfect() {
1480        let vs = VitalSigns {
1481            dead_file_pct: Some(0.0),
1482            dead_export_pct: Some(0.0),
1483            avg_cyclomatic: 1.0,
1484            p90_cyclomatic: 2,
1485            hotspot_count: Some(0),
1486            maintainability_avg: Some(90.0),
1487            unused_dep_count: Some(0),
1488            circular_dep_count: Some(0),
1489            ..Default::default()
1490        };
1491        let score = compute_health_score(&vs, 100);
1492        assert!((score.score - 100.0).abs() < f64::EPSILON);
1493        assert_eq!(score.grade, "A");
1494    }
1495
1496    #[test]
1497    fn health_score_no_optional_metrics() {
1498        let vs = VitalSigns {
1499            avg_cyclomatic: 1.0,
1500            p90_cyclomatic: 2,
1501            ..Default::default()
1502        };
1503        let score = compute_health_score(&vs, 0);
1504        assert!((score.score - 100.0).abs() < f64::EPSILON);
1505        assert_eq!(score.grade, "A");
1506        assert!(score.penalties.dead_files.is_none());
1507        assert!(score.penalties.unused_deps.is_none());
1508        assert!(score.penalties.duplication.is_none());
1509    }
1510
1511    #[test]
1512    fn health_score_dead_code_penalty() {
1513        let vs = VitalSigns {
1514            dead_file_pct: Some(50.0),
1515            dead_export_pct: Some(30.0),
1516            avg_cyclomatic: 1.0,
1517            p90_cyclomatic: 2,
1518            ..Default::default()
1519        };
1520        let score = compute_health_score(&vs, 100);
1521        assert!((score.score - 84.0).abs() < 0.1);
1522        assert_eq!(score.grade, "B");
1523    }
1524
1525    #[test]
1526    fn health_score_complexity_penalty() {
1527        let vs = VitalSigns {
1528            avg_cyclomatic: 5.5,
1529            p90_cyclomatic: 15,
1530            ..Default::default()
1531        };
1532        let score = compute_health_score(&vs, 100);
1533        assert!((score.score - 75.0).abs() < 0.1);
1534        assert_eq!(score.grade, "B");
1535    }
1536
1537    #[test]
1538    fn health_score_prop_drilling_penalty_opt_in() {
1539        let base = || VitalSigns {
1540            avg_cyclomatic: 1.0,
1541            p90_cyclomatic: 2,
1542            ..Default::default()
1543        };
1544        let base_score = compute_health_score(&base(), 100).score;
1545
1546        // Each located chain costs 1pt (depth is descriptive, not a multiplier).
1547        let three = VitalSigns {
1548            prop_drilling_chain_count: Some(3),
1549            prop_drilling_max_depth: Some(5),
1550            ..base()
1551        };
1552        assert!((base_score - compute_health_score(&three, 100).score - 3.0).abs() < 0.1);
1553
1554        // Capped at 5pt regardless of chain count.
1555        let many = VitalSigns {
1556            prop_drilling_chain_count: Some(20),
1557            ..base()
1558        };
1559        assert!((base_score - compute_health_score(&many, 100).score - 5.0).abs() < 0.1);
1560
1561        // Dormant by default: the opt-in rule is off, so the count is `None` and
1562        // the score is unchanged.
1563        let off = VitalSigns {
1564            prop_drilling_chain_count: None,
1565            ..base()
1566        };
1567        assert!((base_score - compute_health_score(&off, 100).score).abs() < f64::EPSILON);
1568    }
1569
1570    #[test]
1571    fn health_score_clamped_at_zero() {
1572        let vs = VitalSigns {
1573            dead_file_pct: Some(100.0),
1574            dead_export_pct: Some(100.0),
1575            avg_cyclomatic: 10.0,
1576            p90_cyclomatic: 30,
1577            hotspot_count: Some(50),
1578            maintainability_avg: Some(20.0),
1579            unused_dep_count: Some(100),
1580            circular_dep_count: Some(50),
1581            ..Default::default()
1582        };
1583        let score = compute_health_score(&vs, 100);
1584        assert!((score.score).abs() < f64::EPSILON);
1585        assert_eq!(score.grade, "F");
1586    }
1587
1588    #[test]
1589    fn health_score_hotspot_normalized_by_files() {
1590        let vs = VitalSigns {
1591            avg_cyclomatic: 1.0,
1592            p90_cyclomatic: 2,
1593            hotspot_count: Some(5),
1594            ..Default::default()
1595        };
1596        let score_100 = compute_health_score(&vs, 100);
1597        let score_1000 = compute_health_score(&vs, 1000);
1598        assert!(score_1000.score > score_100.score);
1599    }
1600
1601    #[test]
1602    fn health_score_hotspot_top_pct_can_use_full_budget() {
1603        let vs = VitalSigns {
1604            avg_cyclomatic: 1.0,
1605            p90_cyclomatic: 2,
1606            hotspot_count: Some(0),
1607            hotspot_top_pct_count: Some(250),
1608            ..Default::default()
1609        };
1610
1611        let score = compute_health_score(&vs, 25_000);
1612
1613        assert_some_close(score.penalties.hotspots, 10.0);
1614        assert_close(score.score, 90.0);
1615    }
1616
1617    #[test]
1618    fn health_score_duplication_penalty() {
1619        let vs = VitalSigns {
1620            cyclomatic_population: None,
1621            dead_file_pct: None,
1622            dead_export_pct: None,
1623            avg_cyclomatic: 1.0,
1624            critical_complexity_pct: None,
1625            p90_cyclomatic: 2,
1626            duplication_pct: Some(10.0), // 10% - 5% = 5 points
1627            hotspot_count: None,
1628            hotspot_top_pct_count: None,
1629            maintainability_avg: None,
1630            maintainability_low_pct: None,
1631            unused_dep_count: None,
1632            unused_deps_per_k_files: None,
1633            circular_dep_count: None,
1634            circular_deps_per_k_files: None,
1635            counts: None,
1636            unit_size_profile: None,
1637            functions_over_60_loc_per_k: None,
1638            unit_interfacing_profile: None,
1639            p95_fan_in: None,
1640            coupling_high_pct: None,
1641            prop_drilling_chain_count: None,
1642            prop_drilling_max_depth: None,
1643            p95_render_fan_in: None,
1644            render_fan_in_high_pct: None,
1645            max_render_fan_in: None,
1646            top_render_fan_in: Vec::new(),
1647            total_loc: 0,
1648        };
1649        let score = compute_health_score(&vs, 100);
1650        assert_eq!(score.penalties.duplication, Some(5.0));
1651
1652        let vs_low = VitalSigns {
1653            duplication_pct: Some(4.0),
1654            ..vs.clone()
1655        };
1656        let score_low = compute_health_score(&vs_low, 100);
1657        assert_eq!(score_low.penalties.duplication, Some(0.0));
1658
1659        let vs_high = VitalSigns {
1660            duplication_pct: Some(20.0),
1661            ..vs
1662        };
1663        let score_high = compute_health_score(&vs_high, 100);
1664        assert_eq!(score_high.penalties.duplication, Some(10.0));
1665    }
1666
1667    #[test]
1668    fn health_score_uses_scale_invariant_monorepo_signals() {
1669        let vs = VitalSigns {
1670            dead_file_pct: Some(4.0),
1671            dead_export_pct: Some(9.0),
1672            avg_cyclomatic: 2.3,
1673            critical_complexity_pct: Some(2.3),
1674            p90_cyclomatic: 4,
1675            duplication_pct: Some(6.0),
1676            hotspot_count: Some(0),
1677            hotspot_top_pct_count: Some(250),
1678            maintainability_avg: Some(91.0),
1679            maintainability_low_pct: Some(8.0),
1680            unused_dep_count: Some(180),
1681            unused_deps_per_k_files: Some(7.2),
1682            circular_dep_count: Some(450),
1683            circular_deps_per_k_files: Some(18.0),
1684            unit_size_profile: Some(RiskProfile {
1685                low_risk: 80.0,
1686                medium_risk: 12.7,
1687                high_risk: 5.0,
1688                very_high_risk: 2.3,
1689            }),
1690            functions_over_60_loc_per_k: Some(23.0),
1691            p95_fan_in: Some(7),
1692            coupling_high_pct: Some(4.0),
1693            ..Default::default()
1694        };
1695        let score = compute_health_score(&vs, 25_000);
1696        let penalties = &score.penalties;
1697
1698        assert_some_close(penalties.dead_files, 0.8);
1699        assert_some_close(penalties.dead_exports, 1.8);
1700        assert_close(penalties.complexity, 9.2);
1701        assert!((penalties.p90_complexity).abs() < f64::EPSILON);
1702        assert_some_close(penalties.maintainability, 12.0);
1703        assert_some_close(penalties.hotspots, 10.0);
1704        assert_some_close(penalties.unused_deps, 3.6);
1705        assert_some_close(penalties.circular_deps, 9.0);
1706        assert_some_close(penalties.unit_size, 10.0);
1707        assert_some_close(penalties.coupling, 2.0);
1708        assert_some_close(penalties.duplication, 1.0);
1709        assert_close(score.score, 40.6);
1710        assert_eq!(score.grade, "D");
1711    }
1712
1713    #[test]
1714    fn load_snapshots_empty_dir() {
1715        let dir = tempfile::tempdir().unwrap();
1716        let snaps = load_snapshots(dir.path());
1717        assert!(snaps.is_empty());
1718    }
1719
1720    #[test]
1721    fn load_snapshots_returns_sorted() {
1722        let dir = tempfile::tempdir().unwrap();
1723        let root = dir.path();
1724        let snap_dir = root.join(".fallow/snapshots");
1725        std::fs::create_dir_all(&snap_dir).unwrap();
1726
1727        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1728        let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(78.0));
1729
1730        std::fs::write(
1731            snap_dir.join("2026-03-01T00-00-00Z.json"),
1732            serde_json::to_string(&newer).unwrap(),
1733        )
1734        .unwrap();
1735        std::fs::write(
1736            snap_dir.join("2026-01-01T00-00-00Z.json"),
1737            serde_json::to_string(&older).unwrap(),
1738        )
1739        .unwrap();
1740
1741        let loaded = load_snapshots(root);
1742        assert_eq!(loaded.len(), 2);
1743        assert_eq!(loaded[0].timestamp, "2026-01-01T00:00:00Z");
1744        assert_eq!(loaded[1].timestamp, "2026-03-01T00:00:00Z");
1745    }
1746
1747    #[test]
1748    fn load_snapshots_skips_corrupt_files() {
1749        let dir = tempfile::tempdir().unwrap();
1750        let root = dir.path();
1751        let snap_dir = root.join(".fallow/snapshots");
1752        std::fs::create_dir_all(&snap_dir).unwrap();
1753
1754        std::fs::write(snap_dir.join("corrupt.json"), "not valid json").unwrap();
1755        let good = make_test_snapshot("2026-02-01T00:00:00Z", Some(80.0));
1756        std::fs::write(
1757            snap_dir.join("good.json"),
1758            serde_json::to_string(&good).unwrap(),
1759        )
1760        .unwrap();
1761
1762        let loaded = load_snapshots(root);
1763        assert_eq!(loaded.len(), 1);
1764        assert_eq!(loaded[0].timestamp, "2026-02-01T00:00:00Z");
1765    }
1766
1767    #[test]
1768    fn load_snapshots_ignores_non_json() {
1769        let dir = tempfile::tempdir().unwrap();
1770        let root = dir.path();
1771        let snap_dir = root.join(".fallow/snapshots");
1772        std::fs::create_dir_all(&snap_dir).unwrap();
1773
1774        std::fs::write(snap_dir.join("readme.txt"), "not a snapshot").unwrap();
1775
1776        let loaded = load_snapshots(root);
1777        assert!(loaded.is_empty());
1778    }
1779
1780    #[test]
1781    fn compute_trend_no_snapshots() {
1782        let vs = make_test_vital_signs();
1783        let counts = make_test_counts();
1784        assert!(compute_trend(&vs, &counts, Some(78.0), &[]).is_none());
1785    }
1786
1787    #[test]
1788    fn compute_trend_improving() {
1789        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1790        let vs = VitalSigns {
1791            dead_file_pct: Some(2.8),
1792            dead_export_pct: Some(7.5),
1793            avg_cyclomatic: 4.1,
1794            p90_cyclomatic: 12,
1795            hotspot_count: Some(3),
1796            maintainability_avg: Some(75.0),
1797            unused_dep_count: Some(3),
1798            circular_dep_count: Some(1),
1799            ..Default::default()
1800        };
1801        let counts = VitalSignsCounts {
1802            total_files: 100,
1803            total_exports: 500,
1804            dead_files: 3,
1805            dead_exports: 38,
1806            files_scored: Some(95),
1807            total_deps: 40,
1808            ..Default::default()
1809        };
1810
1811        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
1812        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
1813        assert_eq!(trend.snapshots_loaded, 1);
1814        assert_eq!(trend.overall_direction, TrendDirection::Improving);
1815
1816        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
1817        assert_eq!(score_metric.direction, TrendDirection::Improving);
1818        assert!((score_metric.delta - 6.0).abs() < f64::EPSILON);
1819    }
1820
1821    #[test]
1822    fn compute_trend_stable_within_tolerance() {
1823        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(78.0));
1824        let vs = make_test_vital_signs();
1825        let counts = make_test_counts();
1826
1827        let trend = compute_trend(&vs, &counts, Some(78.3), &[prev]).unwrap();
1828        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
1829        assert_eq!(score_metric.direction, TrendDirection::Stable);
1830    }
1831
1832    #[test]
1833    fn compute_trend_uses_most_recent_snapshot() {
1834        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0));
1835        let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(72.0));
1836        let vs = make_test_vital_signs();
1837        let counts = make_test_counts();
1838
1839        let trend = compute_trend(&vs, &counts, Some(78.0), &[older, newer]).unwrap();
1840        assert_eq!(trend.compared_to.score, Some(72.0));
1841        assert_eq!(trend.snapshots_loaded, 2);
1842    }
1843
1844    #[test]
1845    fn compute_trend_includes_raw_counts() {
1846        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1847        let vs = make_test_vital_signs();
1848        let counts = make_test_counts();
1849
1850        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
1851        let dead_files = trend
1852            .metrics
1853            .iter()
1854            .find(|m| m.name == "dead_file_pct")
1855            .unwrap();
1856        assert!(dead_files.previous_count.is_some());
1857        assert!(dead_files.current_count.is_some());
1858    }
1859
1860    fn make_test_vital_signs() -> VitalSigns {
1861        VitalSigns {
1862            dead_file_pct: Some(3.2),
1863            dead_export_pct: Some(8.1),
1864            avg_cyclomatic: 4.2,
1865            p90_cyclomatic: 12,
1866            hotspot_count: Some(5),
1867            maintainability_avg: Some(72.4),
1868            unused_dep_count: Some(4),
1869            circular_dep_count: Some(2),
1870            ..Default::default()
1871        }
1872    }
1873
1874    fn make_test_counts() -> VitalSignsCounts {
1875        VitalSignsCounts {
1876            total_files: 100,
1877            total_exports: 500,
1878            dead_files: 3,
1879            dead_exports: 40,
1880            files_scored: Some(95),
1881            total_deps: 42,
1882            ..Default::default()
1883        }
1884    }
1885
1886    fn make_test_snapshot(timestamp: &str, score: Option<f64>) -> VitalSignsSnapshot {
1887        VitalSignsSnapshot {
1888            snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
1889            version: "2.5.5".into(),
1890            timestamp: timestamp.into(),
1891            git_sha: Some("abc1234".into()),
1892            git_branch: Some("main".into()),
1893            shallow_clone: false,
1894            vital_signs: VitalSigns {
1895                dead_file_pct: Some(3.2),
1896                dead_export_pct: Some(8.1),
1897                avg_cyclomatic: 4.7,
1898                p90_cyclomatic: 12,
1899                hotspot_count: Some(5),
1900                maintainability_avg: Some(72.4),
1901                unused_dep_count: Some(4),
1902                circular_dep_count: Some(2),
1903                ..Default::default()
1904            },
1905            counts: VitalSignsCounts {
1906                total_files: 100,
1907                total_exports: 500,
1908                dead_files: 3,
1909                dead_exports: 40,
1910                files_scored: Some(95),
1911                total_deps: 42,
1912                ..Default::default()
1913            },
1914            score,
1915            grade: score.map(|s| letter_grade(s).to_string()),
1916            coverage_model: None,
1917            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
1918        }
1919    }
1920}