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