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