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