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 branch name.
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_branch(root: &Path) -> Option<String> {
635    let mut command = std::process::Command::new("git");
636    command
637        .args(["rev-parse", "--abbrev-ref", "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        .and_then(|o| {
645            let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
646            if name == "HEAD" { None } else { Some(name) }
647        })
648}
649
650/// Build a snapshot from vital signs and input data.
651#[expect(
652    clippy::too_many_arguments,
653    reason = "snapshot construction keeps every persisted compatibility input explicit"
654)]
655pub(crate) fn build_snapshot(
656    vital_signs: VitalSigns,
657    counts: VitalSignsCounts,
658    root: &Path,
659    shallow_clone: bool,
660    health_score: Option<&HealthScore>,
661    coverage_model: Option<fallow_output::CoverageModel>,
662    analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
663) -> VitalSignsSnapshot {
664    let now = chrono_timestamp();
665
666    VitalSignsSnapshot {
667        snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
668        version: env!("CARGO_PKG_VERSION").to_string(),
669        timestamp: now,
670        git_sha: crate::repo_refs::short_head_sha(root),
671        git_branch: git_branch(root),
672        shallow_clone,
673        vital_signs,
674        counts,
675        score: health_score.map(|s| s.score),
676        grade: health_score.map(|s| s.grade.to_string()),
677        coverage_model,
678        analysis_identity,
679    }
680}
681
682/// ISO 8601 UTC timestamp without external chrono dependency.
683pub fn chrono_timestamp() -> String {
684    use std::time::SystemTime;
685    let now = SystemTime::now()
686        .duration_since(SystemTime::UNIX_EPOCH)
687        .unwrap_or_default();
688    let secs = now.as_secs();
689
690    let days = secs / SECS_PER_DAY;
691    let time_secs = secs % SECS_PER_DAY;
692    let hours = time_secs / 3600;
693    let minutes = (time_secs % 3600) / 60;
694    let seconds = time_secs % 60;
695
696    let (year, month, day) = days_to_ymd(days);
697
698    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
699}
700
701/// Convert days since Unix epoch to (year, month, day).
702const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
703    let z = days + 719_468;
704    let era = z / 146_097;
705    let doe = z - era * 146_097;
706    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
707    let y = yoe + era * 400;
708    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
709    let mp = (5 * doy + 2) / 153;
710    let d = doy - (153 * mp + 2) / 5 + 1;
711    let m = if mp < 10 { mp + 3 } else { mp - 9 };
712    let y = if m <= 2 { y + 1 } else { y };
713    (y, m, d)
714}
715
716/// Save a snapshot to disk.
717///
718/// If `path` is `None`, writes to `.fallow/snapshots/{timestamp}.json`.
719/// Creates parent directories as needed.
720pub(crate) fn save_snapshot(
721    snapshot: &VitalSignsSnapshot,
722    root: &Path,
723    explicit_path: Option<&Path>,
724) -> Result<PathBuf, String> {
725    let path = explicit_path.map_or_else(
726        || {
727            let dir = root.join(".fallow").join("snapshots");
728            let filename = snapshot.timestamp.replace(':', "-");
729            dir.join(format!("{filename}.json"))
730        },
731        Path::to_path_buf,
732    );
733
734    if let Some(parent) = path.parent() {
735        std::fs::create_dir_all(parent)
736            .map_err(|e| format!("failed to create snapshot directory: {e}"))?;
737    }
738
739    let json =
740        serde_json::to_string_pretty(snapshot).map_err(|e| format!("failed to serialize: {e}"))?;
741    std::fs::write(&path, json).map_err(|e| format!("failed to write snapshot: {e}"))?;
742
743    Ok(path)
744}
745
746/// Load all snapshots from the default snapshot directory, sorted by timestamp ascending.
747///
748/// Corrupt or unreadable files are skipped with a warning to stderr.
749/// Returns an empty vec if the directory does not exist.
750#[expect(
751    clippy::print_stderr,
752    reason = "corrupt-snapshot warnings to stderr, preserved verbatim from the CLI health path"
753)]
754pub(crate) fn load_snapshots(root: &Path) -> Vec<VitalSignsSnapshot> {
755    let dir = root.join(".fallow").join("snapshots");
756    let Ok(entries) = std::fs::read_dir(&dir) else {
757        return Vec::new();
758    };
759
760    let mut snapshots = Vec::new();
761    for entry in entries {
762        let Ok(entry) = entry else { continue };
763        let path = entry.path();
764        if path.extension().is_some_and(|ext| ext == "json") {
765            match std::fs::read_to_string(&path) {
766                Ok(content) => match serde_json::from_str::<VitalSignsSnapshot>(&content) {
767                    Ok(snap) => snapshots.push(snap),
768                    Err(e) => {
769                        eprintln!("warning: skipping corrupt snapshot {}: {e}", path.display());
770                        record_unreadable_snapshot(root, &path, &e.to_string());
771                    }
772                },
773                Err(e) => {
774                    eprintln!("warning: could not read snapshot {}: {e}", path.display());
775                    record_unreadable_snapshot(root, &path, &e.to_string());
776                }
777            }
778        }
779    }
780
781    snapshots.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
782    snapshots
783}
784
785/// Record a snapshot this run could not use, so the thinner trend is visible to
786/// a consumer that never sees the warning above (issue #2689).
787fn record_unreadable_snapshot(root: &Path, path: &Path, error: &str) {
788    crate::health::diagnostics::record_health_diagnostic(
789        root,
790        Some(path),
791        fallow_types::workspace::WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
792            error: error.to_owned(),
793        },
794    );
795}
796
797/// Tolerance for treating a metric delta as "stable" rather than improving/declining.
798const TREND_TOLERANCE: f64 = 0.5;
799
800fn trend_point_from_snapshot(prev: &VitalSignsSnapshot) -> TrendPoint {
801    TrendPoint {
802        timestamp: prev.timestamp.clone(),
803        git_sha: prev.git_sha.clone(),
804        score: prev.score,
805        grade: prev.grade.clone(),
806        coverage_model: prev.coverage_model.clone(),
807        snapshot_schema_version: Some(prev.snapshot_schema_version),
808    }
809}
810
811fn overall_trend_direction(metrics: &[TrendMetric]) -> TrendDirection {
812    let (improving, declining) = metrics.iter().fold((0usize, 0usize), |(imp, dec), metric| {
813        match metric.direction {
814            TrendDirection::Improving => (imp + 1, dec),
815            TrendDirection::Declining => (imp, dec + 1),
816            TrendDirection::Stable => (imp, dec),
817        }
818    });
819
820    match improving.cmp(&declining) {
821        std::cmp::Ordering::Greater => TrendDirection::Improving,
822        std::cmp::Ordering::Less => TrendDirection::Declining,
823        std::cmp::Ordering::Equal => TrendDirection::Stable,
824    }
825}
826
827/// Compute a trend comparison between the current run and the most recent snapshot.
828///
829/// Uses the stored `score` field from the snapshot (never re-derives it).
830/// Returns `None` if no snapshots are available.
831pub(crate) fn compute_trend(
832    current_vs: &VitalSigns,
833    current_counts: &VitalSignsCounts,
834    current_score: Option<f64>,
835    snapshots: &[VitalSignsSnapshot],
836) -> Option<HealthTrend> {
837    let prev = snapshots.last()?;
838
839    let compared_to = trend_point_from_snapshot(prev);
840
841    let metrics = TrendBuilder::new(prev, current_vs, current_counts, current_score).build();
842
843    let overall_direction = overall_trend_direction(&metrics);
844
845    Some(HealthTrend {
846        compared_to,
847        metrics,
848        snapshots_loaded: snapshots.len(),
849        overall_direction,
850    })
851}
852
853struct TrendBuilder<'a> {
854    prev: &'a VitalSignsSnapshot,
855    current_vs: &'a VitalSigns,
856    current_counts: &'a VitalSignsCounts,
857    current_score: Option<f64>,
858    metrics: Vec<TrendMetric>,
859}
860
861impl TrendBuilder<'_> {
862    fn new<'a>(
863        prev: &'a VitalSignsSnapshot,
864        current_vs: &'a VitalSigns,
865        current_counts: &'a VitalSignsCounts,
866        current_score: Option<f64>,
867    ) -> TrendBuilder<'a> {
868        TrendBuilder {
869            prev,
870            current_vs,
871            current_counts,
872            current_score,
873            metrics: Vec::new(),
874        }
875    }
876
877    fn build(mut self) -> Vec<TrendMetric> {
878        self.add_score_metric();
879        self.add_dead_code_metrics();
880        self.add_complexity_metrics();
881        self.add_dependency_metrics();
882        self.add_structure_metrics();
883        self.metrics
884    }
885
886    fn push(&mut self, input: TrendMetricInput) {
887        self.metrics.push(make_metric(input));
888    }
889
890    fn add_score_metric(&mut self) {
891        if let (Some(prev_score), Some(cur_score)) = (self.prev.score, self.current_score) {
892            self.push(TrendMetricInput {
893                name: "score",
894                label: "Health Score",
895                previous: prev_score,
896                current: cur_score,
897                unit: "",
898                higher_is_better: true,
899                previous_count: None,
900                current_count: None,
901            });
902        }
903    }
904
905    fn add_dead_code_metrics(&mut self) {
906        if let (Some(prev_val), Some(cur_val)) = (
907            self.prev.vital_signs.dead_file_pct,
908            self.current_vs.dead_file_pct,
909        ) {
910            self.push(TrendMetricInput {
911                name: "dead_file_pct",
912                label: "Dead Files",
913                previous: prev_val,
914                current: cur_val,
915                unit: "%",
916                higher_is_better: false,
917                previous_count: Some(TrendCount {
918                    value: self.prev.counts.dead_files,
919                    total: self.prev.counts.total_files,
920                }),
921                current_count: Some(TrendCount {
922                    value: self.current_counts.dead_files,
923                    total: self.current_counts.total_files,
924                }),
925            });
926        }
927
928        if let (Some(prev_val), Some(cur_val)) = (
929            self.prev.vital_signs.dead_export_pct,
930            self.current_vs.dead_export_pct,
931        ) {
932            self.push(TrendMetricInput {
933                name: "dead_export_pct",
934                label: "Dead Exports",
935                previous: prev_val,
936                current: cur_val,
937                unit: "%",
938                higher_is_better: false,
939                previous_count: Some(TrendCount {
940                    value: self.prev.counts.dead_exports,
941                    total: self.prev.counts.total_exports,
942                }),
943                current_count: Some(TrendCount {
944                    value: self.current_counts.dead_exports,
945                    total: self.current_counts.total_exports,
946                }),
947            });
948        }
949    }
950
951    fn add_complexity_metrics(&mut self) {
952        self.push(TrendMetricInput {
953            name: "avg_cyclomatic",
954            label: "Avg Cyclomatic",
955            previous: self.prev.vital_signs.avg_cyclomatic,
956            current: self.current_vs.avg_cyclomatic,
957            unit: "",
958            higher_is_better: false,
959            previous_count: None,
960            current_count: None,
961        });
962
963        if let (Some(prev_val), Some(cur_val)) = (
964            self.prev.vital_signs.maintainability_avg,
965            self.current_vs.maintainability_avg,
966        ) {
967            self.push(TrendMetricInput {
968                name: "maintainability_avg",
969                label: "Maintainability",
970                previous: prev_val,
971                current: cur_val,
972                unit: "",
973                higher_is_better: true,
974                previous_count: None,
975                current_count: None,
976            });
977        }
978
979        if let (Some(prev_profile), Some(cur_profile)) = (
980            &self.prev.vital_signs.unit_size_profile,
981            &self.current_vs.unit_size_profile,
982        ) {
983            self.push(TrendMetricInput {
984                name: "unit_size_very_high_pct",
985                label: "Oversized Fns",
986                previous: prev_profile.very_high_risk,
987                current: cur_profile.very_high_risk,
988                unit: "%",
989                higher_is_better: false,
990                previous_count: None,
991                current_count: None,
992            });
993        }
994
995        self.add_duplication_metric();
996    }
997
998    fn add_duplication_metric(&mut self) {
999        if let (Some(prev_val), Some(cur_val)) = (
1000            self.prev.vital_signs.duplication_pct,
1001            self.current_vs.duplication_pct,
1002        ) {
1003            self.push(TrendMetricInput {
1004                name: "duplication_pct",
1005                label: "Duplication",
1006                previous: prev_val,
1007                current: cur_val,
1008                unit: "%",
1009                higher_is_better: false,
1010                previous_count: self
1011                    .prev
1012                    .counts
1013                    .duplicated_lines
1014                    .zip(self.prev.counts.total_lines)
1015                    .map(|(d, t)| TrendCount { value: d, total: t }),
1016                current_count: self
1017                    .current_counts
1018                    .duplicated_lines
1019                    .zip(self.current_counts.total_lines)
1020                    .map(|(d, t)| TrendCount { value: d, total: t }),
1021            });
1022        }
1023    }
1024
1025    fn add_dependency_metrics(&mut self) {
1026        if let (Some(prev_val), Some(cur_val)) = (
1027            self.prev.vital_signs.unused_dep_count,
1028            self.current_vs.unused_dep_count,
1029        ) {
1030            self.push(TrendMetricInput {
1031                name: "unused_dep_count",
1032                label: "Unused Deps",
1033                previous: f64::from(prev_val),
1034                current: f64::from(cur_val),
1035                unit: "",
1036                higher_is_better: false,
1037                previous_count: None,
1038                current_count: None,
1039            });
1040        }
1041    }
1042
1043    fn add_structure_metrics(&mut self) {
1044        if let (Some(prev_val), Some(cur_val)) = (
1045            self.prev.vital_signs.circular_dep_count,
1046            self.current_vs.circular_dep_count,
1047        ) {
1048            self.push(TrendMetricInput {
1049                name: "circular_dep_count",
1050                label: "Circular Deps",
1051                previous: f64::from(prev_val),
1052                current: f64::from(cur_val),
1053                unit: "",
1054                higher_is_better: false,
1055                previous_count: None,
1056                current_count: None,
1057            });
1058        }
1059
1060        if let (Some(prev_val), Some(cur_val)) = (
1061            self.prev.vital_signs.hotspot_count,
1062            self.current_vs.hotspot_count,
1063        ) {
1064            self.push(TrendMetricInput {
1065                name: "hotspot_count",
1066                label: "Hotspots",
1067                previous: f64::from(prev_val),
1068                current: f64::from(cur_val),
1069                unit: "",
1070                higher_is_better: false,
1071                previous_count: None,
1072                current_count: None,
1073            });
1074        }
1075
1076        if let (Some(prev_val), Some(cur_val)) =
1077            (self.prev.vital_signs.p95_fan_in, self.current_vs.p95_fan_in)
1078        {
1079            self.push(TrendMetricInput {
1080                name: "p95_fan_in",
1081                label: "P95 Fan-in",
1082                previous: f64::from(prev_val),
1083                current: f64::from(cur_val),
1084                unit: "",
1085                higher_is_better: false,
1086                previous_count: None,
1087                current_count: None,
1088            });
1089        }
1090    }
1091}
1092
1093/// Build a single trend metric.
1094struct TrendMetricInput {
1095    name: &'static str,
1096    label: &'static str,
1097    previous: f64,
1098    current: f64,
1099    unit: &'static str,
1100    higher_is_better: bool,
1101    previous_count: Option<TrendCount>,
1102    current_count: Option<TrendCount>,
1103}
1104
1105fn make_metric(input: TrendMetricInput) -> TrendMetric {
1106    let TrendMetricInput {
1107        name,
1108        label,
1109        previous,
1110        current,
1111        unit,
1112        higher_is_better,
1113        previous_count,
1114        current_count,
1115    } = input;
1116    let delta = (current - previous).round_to(1);
1117    let direction = if delta.abs() < TREND_TOLERANCE {
1118        TrendDirection::Stable
1119    } else if (higher_is_better && delta > 0.0) || (!higher_is_better && delta < 0.0) {
1120        TrendDirection::Improving
1121    } else {
1122        TrendDirection::Declining
1123    };
1124
1125    TrendMetric {
1126        name,
1127        label,
1128        previous,
1129        current,
1130        delta,
1131        direction,
1132        unit,
1133        previous_count,
1134        current_count,
1135    }
1136}
1137
1138/// Extension trait for rounding floats to N decimal places.
1139trait RoundTo {
1140    fn round_to(self, decimals: u32) -> Self;
1141}
1142
1143impl RoundTo for f64 {
1144    fn round_to(self, decimals: u32) -> Self {
1145        let factor = 10_f64.powi(decimals as i32);
1146        (self * factor).round() / factor
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::*;
1153
1154    fn make_module(id: u32, cyclomatic: u16) -> crate::source::ModuleInfo {
1155        crate::source::ModuleInfo {
1156            complexity: vec![fallow_types::extract::FunctionComplexity {
1157                name: format!("fn_{id}"),
1158                is_private_member: false,
1159                line: id + 1,
1160                col: 0,
1161                cyclomatic,
1162                cognitive: 0,
1163                line_count: 10,
1164                param_count: 0,
1165                react_hook_count: 0,
1166                react_jsx_max_depth: 0,
1167                react_prop_count: 0,
1168                source_hash: None,
1169                contributions: Vec::new(),
1170            }],
1171            ..crate::source::ModuleInfo::empty(crate::discover::FileId(id))
1172        }
1173    }
1174
1175    #[expect(
1176        clippy::cast_possible_truncation,
1177        reason = "test values are trivially small"
1178    )]
1179    fn make_modules() -> Vec<crate::source::ModuleInfo> {
1180        (0..10)
1181            .map(|i| make_module(i, (i as u16 + 1) * 2))
1182            .collect()
1183    }
1184
1185    fn assert_close(actual: f64, expected: f64) {
1186        assert!(
1187            (actual - expected).abs() < f64::EPSILON,
1188            "expected {expected}, got {actual}"
1189        );
1190    }
1191
1192    fn assert_some_close(actual: Option<f64>, expected: f64) {
1193        assert_close(actual.expect("expected metric to be present"), expected);
1194    }
1195
1196    #[test]
1197    fn compute_cyclomatic_stats() {
1198        let modules = make_modules();
1199        let input = VitalSignsInput {
1200            modules: &modules,
1201            module_filter: None,
1202            file_scores: None,
1203            hotspots: None,
1204            total_files: 10,
1205            analysis_counts: None,
1206        };
1207        let vs = compute_vital_signs(&input);
1208        assert!((vs.avg_cyclomatic - 11.0).abs() < f64::EPSILON);
1209        assert_eq!(vs.p90_cyclomatic, 18);
1210    }
1211
1212    #[test]
1213    fn cyclomatic_population_partitions_units_and_respects_module_filter() {
1214        let mut mixed = make_module(0, 1);
1215        let mut module_unit = mixed.complexity[0].clone();
1216        module_unit.name = "<module>".into();
1217        module_unit.cyclomatic = 31;
1218        let mut template_unit = module_unit.clone();
1219        template_unit.name = "<template>".into();
1220        template_unit.cyclomatic = 4;
1221        mixed.complexity.extend([module_unit, template_unit]);
1222        let modules = [mixed, make_module(1, 100)];
1223        let filter = rustc_hash::FxHashSet::from_iter([crate::discover::FileId(0)]);
1224        let input = VitalSignsInput {
1225            modules: &modules,
1226            module_filter: Some(&filter),
1227            file_scores: None,
1228            hotspots: None,
1229            total_files: 1,
1230            analysis_counts: None,
1231        };
1232        let vs = compute_vital_signs(&input);
1233        let population = vs.cyclomatic_population.unwrap();
1234        assert_eq!(population.functions.count, 1);
1235        assert_eq!(population.modules.count, 1);
1236        assert_eq!(population.templates.count, 1);
1237        assert_eq!(population.functions.sum, 1);
1238        assert_eq!(population.modules.sum, 31);
1239        assert_eq!(population.templates.sum, 4);
1240        assert_close(vs.avg_cyclomatic, 12.0);
1241        assert_eq!(vs.p90_cyclomatic, 31);
1242    }
1243
1244    #[test]
1245    fn compute_with_analysis_counts() {
1246        let modules = make_modules();
1247        let input = VitalSignsInput {
1248            modules: &modules,
1249            module_filter: None,
1250            file_scores: None,
1251            hotspots: None,
1252            total_files: 100,
1253            analysis_counts: Some(AnalysisCounts {
1254                total_exports: 500,
1255                dead_files: 5,
1256                dead_exports: 50,
1257                unused_deps: 3,
1258                circular_deps: 2,
1259                total_deps: 40,
1260            }),
1261        };
1262        let vs = compute_vital_signs(&input);
1263        assert_eq!(vs.dead_file_pct, Some(5.0)); // 5/100 * 100
1264        assert_eq!(vs.dead_export_pct, Some(10.0)); // 50/500 * 100
1265        assert_eq!(vs.unused_dep_count, Some(3));
1266        assert_eq!(vs.circular_dep_count, Some(2));
1267    }
1268
1269    #[test]
1270    fn compute_hotspot_count_with_threshold() {
1271        let hotspots = vec![
1272            HotspotEntry {
1273                path: PathBuf::from("a.ts"),
1274                score: 80.0,
1275                commits: 10,
1276                weighted_commits: 8.0,
1277                lines_added: 100,
1278                lines_deleted: 50,
1279                complexity_density: 0.5,
1280                fan_in: 5,
1281                trend: crate::churn::ChurnTrend::Stable,
1282                ownership: None,
1283                is_test_path: false,
1284            },
1285            HotspotEntry {
1286                path: PathBuf::from("b.ts"),
1287                score: 30.0, // Below threshold
1288                commits: 5,
1289                weighted_commits: 3.0,
1290                lines_added: 40,
1291                lines_deleted: 20,
1292                complexity_density: 0.2,
1293                fan_in: 2,
1294                trend: crate::churn::ChurnTrend::Cooling,
1295                ownership: None,
1296                is_test_path: false,
1297            },
1298            HotspotEntry {
1299                path: PathBuf::from("c.ts"),
1300                score: 50.0, // At threshold
1301                commits: 8,
1302                weighted_commits: 6.0,
1303                lines_added: 80,
1304                lines_deleted: 30,
1305                complexity_density: 0.4,
1306                fan_in: 3,
1307                trend: crate::churn::ChurnTrend::Accelerating,
1308                ownership: None,
1309                is_test_path: false,
1310            },
1311        ];
1312        let modules = Vec::new();
1313        let input = VitalSignsInput {
1314            modules: &modules,
1315            module_filter: None,
1316            file_scores: None,
1317            hotspots: Some(&hotspots),
1318            total_files: 10,
1319            analysis_counts: None,
1320        };
1321        let vs = compute_vital_signs(&input);
1322        assert_eq!(vs.hotspot_count, Some(2)); // 80.0 and 50.0 meet threshold
1323        assert_eq!(vs.hotspot_top_pct_count, Some(1)); // top 1% bucket rounds up to one file
1324    }
1325
1326    #[test]
1327    fn empty_cyclomatic_population_is_measured_not_unknown() {
1328        let vs = compute_vital_signs(&VitalSignsInput {
1329            modules: &[],
1330            module_filter: None,
1331            file_scores: None,
1332            hotspots: None,
1333            total_files: 0,
1334            analysis_counts: None,
1335        });
1336        let population = vs.cyclomatic_population.unwrap();
1337        for group in [
1338            population.functions,
1339            population.modules,
1340            population.templates,
1341        ] {
1342            assert_eq!(group.count, 0);
1343            assert_eq!(group.sum, 0);
1344            assert_eq!(group.max, None);
1345        }
1346        assert_close(vs.avg_cyclomatic, 0.0);
1347        assert_eq!(vs.p90_cyclomatic, 0);
1348        assert_eq!(vs.critical_complexity_pct, None);
1349    }
1350
1351    #[test]
1352    fn compute_without_hotspots_gives_none() {
1353        let modules = Vec::new();
1354        let input = VitalSignsInput {
1355            modules: &modules,
1356            module_filter: None,
1357            file_scores: None,
1358            hotspots: None,
1359            total_files: 0,
1360            analysis_counts: None,
1361        };
1362        let vs = compute_vital_signs(&input);
1363        assert!(vs.hotspot_count.is_none());
1364    }
1365
1366    #[test]
1367    fn snapshot_save_and_load() {
1368        let dir = tempfile::tempdir().unwrap();
1369        let root = dir.path();
1370        let vs = VitalSigns {
1371            dead_file_pct: Some(3.2),
1372            dead_export_pct: Some(8.1),
1373            avg_cyclomatic: 4.7,
1374            p90_cyclomatic: 12,
1375            hotspot_count: Some(5),
1376            maintainability_avg: Some(72.4),
1377            unused_dep_count: Some(4),
1378            circular_dep_count: Some(2),
1379            ..Default::default()
1380        };
1381        let counts = VitalSignsCounts {
1382            total_files: 1200,
1383            total_exports: 5400,
1384            dead_files: 38,
1385            dead_exports: 437,
1386            files_scored: Some(1150),
1387            total_deps: 42,
1388            ..Default::default()
1389        };
1390        let health_score = compute_health_score(&vs, 1200);
1391        let snapshot = build_snapshot(
1392            vs,
1393            counts,
1394            root,
1395            false,
1396            Some(&health_score),
1397            None,
1398            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1399        );
1400        let saved_path = save_snapshot(&snapshot, root, None).unwrap();
1401
1402        assert!(saved_path.exists());
1403        assert!(saved_path.starts_with(root.join(".fallow/snapshots")));
1404
1405        let content = std::fs::read_to_string(&saved_path).unwrap();
1406        let loaded: VitalSignsSnapshot = serde_json::from_str(&content).unwrap();
1407        assert_eq!(loaded.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
1408        assert!((loaded.vital_signs.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
1409        assert_eq!(loaded.counts.total_files, 1200);
1410        assert!(loaded.score.is_some());
1411        assert!(loaded.grade.is_some());
1412    }
1413
1414    #[test]
1415    fn snapshot_save_explicit_path() {
1416        let dir = tempfile::tempdir().unwrap();
1417        let root = dir.path();
1418        let explicit = root.join("my-snapshot.json");
1419        let vs = VitalSigns {
1420            avg_cyclomatic: 1.0,
1421            p90_cyclomatic: 2,
1422            ..Default::default()
1423        };
1424        let counts = VitalSignsCounts::default();
1425        let snapshot = build_snapshot(
1426            vs,
1427            counts,
1428            root,
1429            false,
1430            None,
1431            None,
1432            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1433        );
1434        let saved = save_snapshot(&snapshot, root, Some(&explicit)).unwrap();
1435        assert_eq!(saved, explicit);
1436        assert!(explicit.exists());
1437    }
1438
1439    #[test]
1440    fn snapshot_save_creates_nested_dirs() {
1441        let dir = tempfile::tempdir().unwrap();
1442        let root = dir.path();
1443        let nested = root.join("a/b/c/snapshot.json");
1444        let vs = VitalSigns {
1445            avg_cyclomatic: 1.0,
1446            p90_cyclomatic: 2,
1447            ..Default::default()
1448        };
1449        let counts = VitalSignsCounts::default();
1450        let snapshot = build_snapshot(
1451            vs,
1452            counts,
1453            root,
1454            false,
1455            None,
1456            None,
1457            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1458        );
1459        let saved = save_snapshot(&snapshot, root, Some(&nested)).unwrap();
1460        assert_eq!(saved, nested);
1461        assert!(nested.exists());
1462    }
1463
1464    #[test]
1465    fn days_to_ymd_epoch() {
1466        assert_eq!(days_to_ymd(0), (1970, 1, 1));
1467    }
1468
1469    #[test]
1470    fn days_to_ymd_known_date() {
1471        assert_eq!(days_to_ymd(20_537), (2026, 3, 25));
1472    }
1473
1474    #[test]
1475    fn health_score_perfect() {
1476        let vs = VitalSigns {
1477            dead_file_pct: Some(0.0),
1478            dead_export_pct: Some(0.0),
1479            avg_cyclomatic: 1.0,
1480            p90_cyclomatic: 2,
1481            hotspot_count: Some(0),
1482            maintainability_avg: Some(90.0),
1483            unused_dep_count: Some(0),
1484            circular_dep_count: Some(0),
1485            ..Default::default()
1486        };
1487        let score = compute_health_score(&vs, 100);
1488        assert!((score.score - 100.0).abs() < f64::EPSILON);
1489        assert_eq!(score.grade, "A");
1490    }
1491
1492    #[test]
1493    fn health_score_no_optional_metrics() {
1494        let vs = VitalSigns {
1495            avg_cyclomatic: 1.0,
1496            p90_cyclomatic: 2,
1497            ..Default::default()
1498        };
1499        let score = compute_health_score(&vs, 0);
1500        assert!((score.score - 100.0).abs() < f64::EPSILON);
1501        assert_eq!(score.grade, "A");
1502        assert!(score.penalties.dead_files.is_none());
1503        assert!(score.penalties.unused_deps.is_none());
1504        assert!(score.penalties.duplication.is_none());
1505    }
1506
1507    #[test]
1508    fn health_score_dead_code_penalty() {
1509        let vs = VitalSigns {
1510            dead_file_pct: Some(50.0),
1511            dead_export_pct: Some(30.0),
1512            avg_cyclomatic: 1.0,
1513            p90_cyclomatic: 2,
1514            ..Default::default()
1515        };
1516        let score = compute_health_score(&vs, 100);
1517        assert!((score.score - 84.0).abs() < 0.1);
1518        assert_eq!(score.grade, "B");
1519    }
1520
1521    #[test]
1522    fn health_score_complexity_penalty() {
1523        let vs = VitalSigns {
1524            avg_cyclomatic: 5.5,
1525            p90_cyclomatic: 15,
1526            ..Default::default()
1527        };
1528        let score = compute_health_score(&vs, 100);
1529        assert!((score.score - 75.0).abs() < 0.1);
1530        assert_eq!(score.grade, "B");
1531    }
1532
1533    #[test]
1534    fn health_score_prop_drilling_penalty_opt_in() {
1535        let base = || VitalSigns {
1536            avg_cyclomatic: 1.0,
1537            p90_cyclomatic: 2,
1538            ..Default::default()
1539        };
1540        let base_score = compute_health_score(&base(), 100).score;
1541
1542        // Each located chain costs 1pt (depth is descriptive, not a multiplier).
1543        let three = VitalSigns {
1544            prop_drilling_chain_count: Some(3),
1545            prop_drilling_max_depth: Some(5),
1546            ..base()
1547        };
1548        assert!((base_score - compute_health_score(&three, 100).score - 3.0).abs() < 0.1);
1549
1550        // Capped at 5pt regardless of chain count.
1551        let many = VitalSigns {
1552            prop_drilling_chain_count: Some(20),
1553            ..base()
1554        };
1555        assert!((base_score - compute_health_score(&many, 100).score - 5.0).abs() < 0.1);
1556
1557        // Dormant by default: the opt-in rule is off, so the count is `None` and
1558        // the score is unchanged.
1559        let off = VitalSigns {
1560            prop_drilling_chain_count: None,
1561            ..base()
1562        };
1563        assert!((base_score - compute_health_score(&off, 100).score).abs() < f64::EPSILON);
1564    }
1565
1566    #[test]
1567    fn health_score_clamped_at_zero() {
1568        let vs = VitalSigns {
1569            dead_file_pct: Some(100.0),
1570            dead_export_pct: Some(100.0),
1571            avg_cyclomatic: 10.0,
1572            p90_cyclomatic: 30,
1573            hotspot_count: Some(50),
1574            maintainability_avg: Some(20.0),
1575            unused_dep_count: Some(100),
1576            circular_dep_count: Some(50),
1577            ..Default::default()
1578        };
1579        let score = compute_health_score(&vs, 100);
1580        assert!((score.score).abs() < f64::EPSILON);
1581        assert_eq!(score.grade, "F");
1582    }
1583
1584    #[test]
1585    fn health_score_hotspot_normalized_by_files() {
1586        let vs = VitalSigns {
1587            avg_cyclomatic: 1.0,
1588            p90_cyclomatic: 2,
1589            hotspot_count: Some(5),
1590            ..Default::default()
1591        };
1592        let score_100 = compute_health_score(&vs, 100);
1593        let score_1000 = compute_health_score(&vs, 1000);
1594        assert!(score_1000.score > score_100.score);
1595    }
1596
1597    #[test]
1598    fn health_score_hotspot_top_pct_can_use_full_budget() {
1599        let vs = VitalSigns {
1600            avg_cyclomatic: 1.0,
1601            p90_cyclomatic: 2,
1602            hotspot_count: Some(0),
1603            hotspot_top_pct_count: Some(250),
1604            ..Default::default()
1605        };
1606
1607        let score = compute_health_score(&vs, 25_000);
1608
1609        assert_some_close(score.penalties.hotspots, 10.0);
1610        assert_close(score.score, 90.0);
1611    }
1612
1613    #[test]
1614    fn health_score_duplication_penalty() {
1615        let vs = VitalSigns {
1616            cyclomatic_population: None,
1617            dead_file_pct: None,
1618            dead_export_pct: None,
1619            avg_cyclomatic: 1.0,
1620            critical_complexity_pct: None,
1621            p90_cyclomatic: 2,
1622            duplication_pct: Some(10.0), // 10% - 5% = 5 points
1623            hotspot_count: None,
1624            hotspot_top_pct_count: None,
1625            maintainability_avg: None,
1626            maintainability_low_pct: None,
1627            unused_dep_count: None,
1628            unused_deps_per_k_files: None,
1629            circular_dep_count: None,
1630            circular_deps_per_k_files: None,
1631            counts: None,
1632            unit_size_profile: None,
1633            functions_over_60_loc_per_k: None,
1634            unit_interfacing_profile: None,
1635            p95_fan_in: None,
1636            coupling_high_pct: None,
1637            prop_drilling_chain_count: None,
1638            prop_drilling_max_depth: None,
1639            p95_render_fan_in: None,
1640            render_fan_in_high_pct: None,
1641            max_render_fan_in: None,
1642            top_render_fan_in: Vec::new(),
1643            total_loc: 0,
1644        };
1645        let score = compute_health_score(&vs, 100);
1646        assert_eq!(score.penalties.duplication, Some(5.0));
1647
1648        let vs_low = VitalSigns {
1649            duplication_pct: Some(4.0),
1650            ..vs.clone()
1651        };
1652        let score_low = compute_health_score(&vs_low, 100);
1653        assert_eq!(score_low.penalties.duplication, Some(0.0));
1654
1655        let vs_high = VitalSigns {
1656            duplication_pct: Some(20.0),
1657            ..vs
1658        };
1659        let score_high = compute_health_score(&vs_high, 100);
1660        assert_eq!(score_high.penalties.duplication, Some(10.0));
1661    }
1662
1663    #[test]
1664    fn health_score_uses_scale_invariant_monorepo_signals() {
1665        let vs = VitalSigns {
1666            dead_file_pct: Some(4.0),
1667            dead_export_pct: Some(9.0),
1668            avg_cyclomatic: 2.3,
1669            critical_complexity_pct: Some(2.3),
1670            p90_cyclomatic: 4,
1671            duplication_pct: Some(6.0),
1672            hotspot_count: Some(0),
1673            hotspot_top_pct_count: Some(250),
1674            maintainability_avg: Some(91.0),
1675            maintainability_low_pct: Some(8.0),
1676            unused_dep_count: Some(180),
1677            unused_deps_per_k_files: Some(7.2),
1678            circular_dep_count: Some(450),
1679            circular_deps_per_k_files: Some(18.0),
1680            unit_size_profile: Some(RiskProfile {
1681                low_risk: 80.0,
1682                medium_risk: 12.7,
1683                high_risk: 5.0,
1684                very_high_risk: 2.3,
1685            }),
1686            functions_over_60_loc_per_k: Some(23.0),
1687            p95_fan_in: Some(7),
1688            coupling_high_pct: Some(4.0),
1689            ..Default::default()
1690        };
1691        let score = compute_health_score(&vs, 25_000);
1692        let penalties = &score.penalties;
1693
1694        assert_some_close(penalties.dead_files, 0.8);
1695        assert_some_close(penalties.dead_exports, 1.8);
1696        assert_close(penalties.complexity, 9.2);
1697        assert!((penalties.p90_complexity).abs() < f64::EPSILON);
1698        assert_some_close(penalties.maintainability, 12.0);
1699        assert_some_close(penalties.hotspots, 10.0);
1700        assert_some_close(penalties.unused_deps, 3.6);
1701        assert_some_close(penalties.circular_deps, 9.0);
1702        assert_some_close(penalties.unit_size, 10.0);
1703        assert_some_close(penalties.coupling, 2.0);
1704        assert_some_close(penalties.duplication, 1.0);
1705        assert_close(score.score, 40.6);
1706        assert_eq!(score.grade, "D");
1707    }
1708
1709    #[test]
1710    fn load_snapshots_empty_dir() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let snaps = load_snapshots(dir.path());
1713        assert!(snaps.is_empty());
1714    }
1715
1716    #[test]
1717    fn load_snapshots_returns_sorted() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let root = dir.path();
1720        let snap_dir = root.join(".fallow/snapshots");
1721        std::fs::create_dir_all(&snap_dir).unwrap();
1722
1723        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1724        let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(78.0));
1725
1726        std::fs::write(
1727            snap_dir.join("2026-03-01T00-00-00Z.json"),
1728            serde_json::to_string(&newer).unwrap(),
1729        )
1730        .unwrap();
1731        std::fs::write(
1732            snap_dir.join("2026-01-01T00-00-00Z.json"),
1733            serde_json::to_string(&older).unwrap(),
1734        )
1735        .unwrap();
1736
1737        let loaded = load_snapshots(root);
1738        assert_eq!(loaded.len(), 2);
1739        assert_eq!(loaded[0].timestamp, "2026-01-01T00:00:00Z");
1740        assert_eq!(loaded[1].timestamp, "2026-03-01T00:00:00Z");
1741    }
1742
1743    #[test]
1744    fn load_snapshots_skips_corrupt_files() {
1745        let dir = tempfile::tempdir().unwrap();
1746        let root = dir.path();
1747        let snap_dir = root.join(".fallow/snapshots");
1748        std::fs::create_dir_all(&snap_dir).unwrap();
1749
1750        std::fs::write(snap_dir.join("corrupt.json"), "not valid json").unwrap();
1751        let good = make_test_snapshot("2026-02-01T00:00:00Z", Some(80.0));
1752        std::fs::write(
1753            snap_dir.join("good.json"),
1754            serde_json::to_string(&good).unwrap(),
1755        )
1756        .unwrap();
1757
1758        let loaded = load_snapshots(root);
1759        assert_eq!(loaded.len(), 1);
1760        assert_eq!(loaded[0].timestamp, "2026-02-01T00:00:00Z");
1761    }
1762
1763    #[test]
1764    fn load_snapshots_ignores_non_json() {
1765        let dir = tempfile::tempdir().unwrap();
1766        let root = dir.path();
1767        let snap_dir = root.join(".fallow/snapshots");
1768        std::fs::create_dir_all(&snap_dir).unwrap();
1769
1770        std::fs::write(snap_dir.join("readme.txt"), "not a snapshot").unwrap();
1771
1772        let loaded = load_snapshots(root);
1773        assert!(loaded.is_empty());
1774    }
1775
1776    #[test]
1777    fn compute_trend_no_snapshots() {
1778        let vs = make_test_vital_signs();
1779        let counts = make_test_counts();
1780        assert!(compute_trend(&vs, &counts, Some(78.0), &[]).is_none());
1781    }
1782
1783    #[test]
1784    fn compute_trend_improving() {
1785        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1786        let vs = VitalSigns {
1787            dead_file_pct: Some(2.8),
1788            dead_export_pct: Some(7.5),
1789            avg_cyclomatic: 4.1,
1790            p90_cyclomatic: 12,
1791            hotspot_count: Some(3),
1792            maintainability_avg: Some(75.0),
1793            unused_dep_count: Some(3),
1794            circular_dep_count: Some(1),
1795            ..Default::default()
1796        };
1797        let counts = VitalSignsCounts {
1798            total_files: 100,
1799            total_exports: 500,
1800            dead_files: 3,
1801            dead_exports: 38,
1802            files_scored: Some(95),
1803            total_deps: 40,
1804            ..Default::default()
1805        };
1806
1807        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
1808        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
1809        assert_eq!(trend.snapshots_loaded, 1);
1810        assert_eq!(trend.overall_direction, TrendDirection::Improving);
1811
1812        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
1813        assert_eq!(score_metric.direction, TrendDirection::Improving);
1814        assert!((score_metric.delta - 6.0).abs() < f64::EPSILON);
1815    }
1816
1817    #[test]
1818    fn compute_trend_stable_within_tolerance() {
1819        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(78.0));
1820        let vs = make_test_vital_signs();
1821        let counts = make_test_counts();
1822
1823        let trend = compute_trend(&vs, &counts, Some(78.3), &[prev]).unwrap();
1824        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
1825        assert_eq!(score_metric.direction, TrendDirection::Stable);
1826    }
1827
1828    #[test]
1829    fn compute_trend_uses_most_recent_snapshot() {
1830        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0));
1831        let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(72.0));
1832        let vs = make_test_vital_signs();
1833        let counts = make_test_counts();
1834
1835        let trend = compute_trend(&vs, &counts, Some(78.0), &[older, newer]).unwrap();
1836        assert_eq!(trend.compared_to.score, Some(72.0));
1837        assert_eq!(trend.snapshots_loaded, 2);
1838    }
1839
1840    #[test]
1841    fn compute_trend_includes_raw_counts() {
1842        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1843        let vs = make_test_vital_signs();
1844        let counts = make_test_counts();
1845
1846        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
1847        let dead_files = trend
1848            .metrics
1849            .iter()
1850            .find(|m| m.name == "dead_file_pct")
1851            .unwrap();
1852        assert!(dead_files.previous_count.is_some());
1853        assert!(dead_files.current_count.is_some());
1854    }
1855
1856    fn make_test_vital_signs() -> VitalSigns {
1857        VitalSigns {
1858            dead_file_pct: Some(3.2),
1859            dead_export_pct: Some(8.1),
1860            avg_cyclomatic: 4.2,
1861            p90_cyclomatic: 12,
1862            hotspot_count: Some(5),
1863            maintainability_avg: Some(72.4),
1864            unused_dep_count: Some(4),
1865            circular_dep_count: Some(2),
1866            ..Default::default()
1867        }
1868    }
1869
1870    fn make_test_counts() -> VitalSignsCounts {
1871        VitalSignsCounts {
1872            total_files: 100,
1873            total_exports: 500,
1874            dead_files: 3,
1875            dead_exports: 40,
1876            files_scored: Some(95),
1877            total_deps: 42,
1878            ..Default::default()
1879        }
1880    }
1881
1882    fn make_test_snapshot(timestamp: &str, score: Option<f64>) -> VitalSignsSnapshot {
1883        VitalSignsSnapshot {
1884            snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
1885            version: "2.5.5".into(),
1886            timestamp: timestamp.into(),
1887            git_sha: Some("abc1234".into()),
1888            git_branch: Some("main".into()),
1889            shallow_clone: false,
1890            vital_signs: VitalSigns {
1891                dead_file_pct: Some(3.2),
1892                dead_export_pct: Some(8.1),
1893                avg_cyclomatic: 4.7,
1894                p90_cyclomatic: 12,
1895                hotspot_count: Some(5),
1896                maintainability_avg: Some(72.4),
1897                unused_dep_count: Some(4),
1898                circular_dep_count: Some(2),
1899                ..Default::default()
1900            },
1901            counts: VitalSignsCounts {
1902                total_files: 100,
1903                total_exports: 500,
1904                dead_files: 3,
1905                dead_exports: 40,
1906                files_scored: Some(95),
1907                total_deps: 42,
1908                ..Default::default()
1909            },
1910            score,
1911            grade: score.map(|s| letter_grade(s).to_string()),
1912            coverage_model: None,
1913            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
1914        }
1915    }
1916}