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
9/// Number of seconds in one day.
10const SECS_PER_DAY: u64 = 86_400;
11
12/// Fraction of the file population used for hotspot concentration.
13const HOTSPOT_TOP_FILE_FRACTION: f64 = 0.01;
14
15/// Maximum project health penalty for thresholded hotspots.
16const HOTSPOT_MAX_PENALTY: f64 = 10.0;
17
18use fallow_output::{
19    DEFAULT_CYCLOMATIC_CRITICAL, FileHealthScore, HEALTH_SCORE_FORMULA_VERSION,
20    HOTSPOT_SCORE_THRESHOLD, HealthScore, HealthScorePenalties, HealthTrend, HotspotEntry,
21    RiskProfile, SNAPSHOT_SCHEMA_VERSION, TrendCount, TrendDirection, TrendMetric, TrendPoint,
22    VitalSigns, VitalSignsCounts, VitalSignsSnapshot, letter_grade,
23};
24
25/// Data sources for computing vital signs.
26///
27/// Fields are `Option` because not all pipelines run in every health invocation.
28pub(crate) struct VitalSignsInput<'a> {
29    /// All parsed modules (always available).
30    pub(crate) modules: &'a [crate::source::ModuleInfo],
31    /// Optional file-id allowlist used to restrict per-module aggregates
32    /// (cyclomatic distribution, total LOC, unit profiles) to a subset.
33    /// Used by `--workspace` and `--group-by` to scope project-wide metrics
34    /// to a single workspace package without re-parsing.
35    /// `None` includes every module in `modules`.
36    pub(crate) module_filter: Option<&'a rustc_hash::FxHashSet<crate::discover::FileId>>,
37    /// File health scores (available when file_scores/hotspots/targets are computed).
38    pub(crate) file_scores: Option<&'a [FileHealthScore]>,
39    /// Hotspot entries (available when hotspots are computed).
40    pub(crate) hotspots: Option<&'a [HotspotEntry]>,
41    /// Total discovered files (already scoped to the workspace when `--workspace` is set).
42    pub(crate) total_files: usize,
43    /// Analysis results (available when file_scores pipeline ran). When a
44    /// `module_filter` is also set, callers should pass workspace-scoped
45    /// counts here so `dead_*_pct` denominators line up with the rest of the
46    /// metrics.
47    pub(crate) analysis_counts: Option<AnalysisCounts>,
48}
49
50impl<'a> VitalSignsInput<'a> {
51    /// Iterate the modules selected by `module_filter`.
52    fn selected_modules(&self) -> impl Iterator<Item = &'a crate::source::ModuleInfo> + '_ {
53        let filter = self.module_filter;
54        self.modules
55            .iter()
56            .filter(move |m| filter.is_none_or(|set| set.contains(&m.file_id)))
57    }
58}
59
60/// Aggregate counts from the analysis pipeline.
61#[derive(Clone, Copy)]
62pub struct AnalysisCounts {
63    pub(crate) total_exports: usize,
64    pub(crate) dead_files: usize,
65    pub(crate) dead_exports: usize,
66    pub(crate) unused_deps: usize,
67    pub(crate) circular_deps: usize,
68    pub(crate) total_deps: usize,
69}
70
71/// Every unit's cyclomatic score, sorted, feeding the average, the critical
72/// share, and p90. The synthetic module-scope unit is included: its decision
73/// points are real branching, and leaving them out is what let a file's average
74/// read low while its top level was a guard ladder.
75fn collect_sorted_cyclomatic(input: &VitalSignsInput<'_>) -> Vec<u16> {
76    let mut values: Vec<u16> = input
77        .selected_modules()
78        .flat_map(|m| m.complexity.iter().map(|c| c.cyclomatic))
79        .collect();
80    values.sort_unstable();
81    values
82}
83
84fn average_cyclomatic(all_cyclomatic: &[u16]) -> f64 {
85    if all_cyclomatic.is_empty() {
86        return 0.0;
87    }
88
89    let sum: u64 = all_cyclomatic.iter().map(|&c| u64::from(c)).sum();
90    (sum as f64 / all_cyclomatic.len() as f64 * 10.0).round() / 10.0
91}
92
93fn critical_complexity_pct(all_cyclomatic: &[u16]) -> Option<f64> {
94    if all_cyclomatic.is_empty() {
95        return None;
96    }
97
98    let critical_count = all_cyclomatic
99        .iter()
100        .filter(|&&c| c >= DEFAULT_CYCLOMATIC_CRITICAL)
101        .count();
102    Some((critical_count as f64 / all_cyclomatic.len() as f64 * 1000.0).round() / 10.0)
103}
104
105#[expect(
106    clippy::cast_sign_loss,
107    reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
108)]
109#[expect(
110    clippy::cast_possible_truncation,
111    reason = "percentile index is bounded by the cyclomatic collection length"
112)]
113fn p90_cyclomatic(all_cyclomatic: &[u16]) -> u32 {
114    if all_cyclomatic.is_empty() {
115        return 0;
116    }
117
118    let idx = (all_cyclomatic.len() as f64 * 0.9).ceil() as usize;
119    let idx = idx.min(all_cyclomatic.len()) - 1;
120    u32::from(all_cyclomatic[idx])
121}
122
123#[expect(
124    clippy::cast_possible_truncation,
125    reason = "analysis counts are bounded by project size and emitted as compact u32 metrics"
126)]
127fn analysis_count_vitals(
128    counts: Option<&AnalysisCounts>,
129    total_files: usize,
130) -> (Option<f64>, Option<f64>, Option<u32>, Option<u32>) {
131    let Some(counts) = counts else {
132        return (None, None, None, None);
133    };
134
135    let dead_file_pct = if total_files > 0 {
136        Some((counts.dead_files as f64 / total_files as f64 * 1000.0).round() / 10.0)
137    } else {
138        Some(0.0)
139    };
140    let dead_export_pct = if counts.total_exports > 0 {
141        Some((counts.dead_exports as f64 / counts.total_exports as f64 * 1000.0).round() / 10.0)
142    } else {
143        Some(0.0)
144    };
145
146    (
147        dead_file_pct,
148        dead_export_pct,
149        Some(counts.unused_deps as u32),
150        Some(counts.circular_deps as u32),
151    )
152}
153
154struct SelectedModuleMetrics {
155    total_loc: u64,
156    line_counts: Vec<u32>,
157    param_counts: Vec<u8>,
158    cyclomatic_population: fallow_output::CyclomaticPopulation,
159}
160
161fn selected_module_metrics(input: &VitalSignsInput<'_>) -> SelectedModuleMetrics {
162    let mut total_loc = 0;
163    let mut line_counts = Vec::new();
164    let mut param_counts = Vec::new();
165    let mut cyclomatic_population = fallow_output::CyclomaticPopulation::default();
166
167    for module in input.selected_modules() {
168        total_loc += module.line_offsets.len() as u64;
169        // The synthetic module-scope unit is excluded from both distributions.
170        // It has no parameter list, and its size is the distance between its
171        // first and last decision point rather than a body anyone can shorten,
172        // so it is not a refactoring signal in the unit-size profile.
173        for unit in &module.complexity {
174            let is_module = fallow_types::extract::is_synthetic_module_unit(&unit.name);
175            let population = if is_module {
176                &mut cyclomatic_population.modules
177            } else if fallow_types::extract::is_synthetic_template_unit(&unit.name) {
178                &mut cyclomatic_population.templates
179            } else {
180                &mut cyclomatic_population.functions
181            };
182            population.count += 1;
183            population.sum += u64::from(unit.cyclomatic);
184            population.max = Some(population.max.unwrap_or_default().max(unit.cyclomatic));
185            if is_module {
186                continue;
187            }
188            line_counts.push(unit.line_count);
189            param_counts.push(unit.param_count);
190        }
191    }
192
193    SelectedModuleMetrics {
194        total_loc,
195        line_counts,
196        param_counts,
197        cyclomatic_population,
198    }
199}
200
201fn vital_sign_counts(input: &VitalSignsInput<'_>, total_loc: u64) -> Option<VitalSignsCounts> {
202    input.analysis_counts.as_ref().map(|ac| VitalSignsCounts {
203        total_files: input.total_files,
204        total_exports: ac.total_exports,
205        dead_files: ac.dead_files,
206        dead_exports: ac.dead_exports,
207        duplicated_lines: None,
208        total_lines: Some(total_loc as usize),
209        files_scored: input.file_scores.map(<[_]>::len),
210        total_deps: ac.total_deps,
211    })
212}
213
214/// Compute vital signs from available health data.
215pub(crate) fn compute_vital_signs(input: &VitalSignsInput<'_>) -> VitalSigns {
216    let all_cyclomatic = collect_sorted_cyclomatic(input);
217    let avg_cyclomatic = average_cyclomatic(&all_cyclomatic);
218    let critical_complexity_pct = critical_complexity_pct(&all_cyclomatic);
219    let p90_cyclomatic = p90_cyclomatic(&all_cyclomatic);
220
221    let (dead_file_pct, dead_export_pct, unused_dep_count, circular_dep_count) =
222        analysis_count_vitals(input.analysis_counts.as_ref(), input.total_files);
223    let unused_deps_per_k_files =
224        unused_dep_count.map(|count| per_k_files(count, input.total_files));
225    let circular_deps_per_k_files =
226        circular_dep_count.map(|count| per_k_files(count, input.total_files));
227
228    let (maintainability_avg, maintainability_low_pct) = maintainability_vitals(input.file_scores);
229
230    let (hotspot_count, hotspot_top_pct_count) = hotspot_vitals(input.hotspots, input.total_files);
231
232    let module_metrics = selected_module_metrics(input);
233    let counts = vital_sign_counts(input, module_metrics.total_loc);
234    let functions_over_60_loc_per_k = functions_over_60_loc_per_k(&module_metrics.line_counts);
235    let unit_size_profile = unit_size_profile(&module_metrics.line_counts);
236
237    let unit_interfacing_profile =
238        unit_interfacing_profile(&module_metrics.param_counts, &all_cyclomatic);
239
240    let (p95_fan_in, coupling_high_pct) = if let Some(scores) = input.file_scores {
241        compute_coupling_concentration(scores)
242    } else {
243        (None, None)
244    };
245
246    VitalSigns {
247        dead_file_pct,
248        dead_export_pct,
249        avg_cyclomatic,
250        critical_complexity_pct,
251        p90_cyclomatic,
252        cyclomatic_population: Some(module_metrics.cyclomatic_population),
253        duplication_pct: None, // Lazy: only set if duplication pipeline was run
254        hotspot_count,
255        hotspot_top_pct_count,
256        maintainability_avg,
257        maintainability_low_pct,
258        unused_dep_count,
259        unused_deps_per_k_files,
260        circular_dep_count,
261        circular_deps_per_k_files,
262        counts,
263        unit_size_profile,
264        functions_over_60_loc_per_k,
265        unit_interfacing_profile,
266        p95_fan_in,
267        coupling_high_pct,
268        // Set post-construction from the whole-project analysis results (only
269        // when the opt-in prop-drilling rule is enabled); see health/mod.rs.
270        prop_drilling_chain_count: None,
271        prop_drilling_max_depth: None,
272        // Set post-construction from the whole-project render fan-in metric
273        // (whenever React is declared, the core metric carries the aggregates);
274        // see health/mod.rs.
275        p95_render_fan_in: None,
276        render_fan_in_high_pct: None,
277        max_render_fan_in: None,
278        top_render_fan_in: Vec::new(),
279        total_loc: module_metrics.total_loc,
280    }
281}
282
283fn per_k_files(count: u32, total_files: usize) -> f64 {
284    if total_files == 0 {
285        0.0
286    } else {
287        (f64::from(count) / total_files as f64 * 10_000.0).round() / 10.0
288    }
289}
290
291fn maintainability_vitals(scores: Option<&[FileHealthScore]>) -> (Option<f64>, Option<f64>) {
292    let Some(scores) = scores.filter(|scores| !scores.is_empty()) else {
293        return (None, None);
294    };
295    let sum: f64 = scores.iter().map(|s| s.maintainability_index).sum();
296    let low_count = scores
297        .iter()
298        .filter(|s| s.maintainability_index < 70.0)
299        .count();
300    (
301        Some((sum / scores.len() as f64 * 10.0).round() / 10.0),
302        Some((low_count as f64 / scores.len() as f64 * 1000.0).round() / 10.0),
303    )
304}
305
306#[expect(
307    clippy::cast_sign_loss,
308    reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
309)]
310fn hotspot_vitals(
311    hotspots: Option<&[HotspotEntry]>,
312    total_files: usize,
313) -> (Option<u32>, Option<u32>) {
314    let hotspot_count = hotspots.map(|entries| {
315        entries
316            .iter()
317            .filter(|e| e.score >= HOTSPOT_SCORE_THRESHOLD)
318            .count() as u32
319    });
320    let hotspot_top_pct_count = hotspots.map(|entries| {
321        if total_files == 0 || entries.is_empty() {
322            return 0;
323        }
324        let top_count = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION).ceil() as usize;
325        entries
326            .iter()
327            .take(top_count.max(1))
328            .filter(|entry| entry.score > 0.0)
329            .count() as u32
330    });
331    (hotspot_count, hotspot_top_pct_count)
332}
333
334fn functions_over_60_loc_per_k(line_counts: &[u32]) -> Option<f64> {
335    if line_counts.is_empty() {
336        return None;
337    }
338    let over_60 = line_counts
339        .iter()
340        .filter(|&&line_count| line_count > 60)
341        .count();
342    Some((over_60 as f64 / line_counts.len() as f64 * 10_000.0).round() / 10.0)
343}
344
345fn unit_size_profile(line_counts: &[u32]) -> Option<RiskProfile> {
346    (!line_counts.is_empty()).then(|| compute_size_risk_profile(line_counts))
347}
348
349fn unit_interfacing_profile(param_counts: &[u8], all_cyclomatic: &[u16]) -> Option<RiskProfile> {
350    if all_cyclomatic.is_empty() {
351        return None;
352    }
353    Some(compute_interfacing_risk_profile(param_counts))
354}
355
356/// Compute unit size risk profile from function line counts.
357///
358/// Bins: low risk (1-15 LOC), medium risk (16-30), high risk (31-60), very high risk (>60).
359fn compute_size_risk_profile(line_counts: &[u32]) -> RiskProfile {
360    if line_counts.is_empty() {
361        return RiskProfile {
362            low_risk: 0.0,
363            medium_risk: 0.0,
364            high_risk: 0.0,
365            very_high_risk: 0.0,
366        };
367    }
368    let total = line_counts.len() as f64;
369    let low = line_counts.iter().filter(|&&lc| lc <= 15).count() as f64;
370    let medium = line_counts
371        .iter()
372        .filter(|&&lc| (16..=30).contains(&lc))
373        .count() as f64;
374    let high = line_counts
375        .iter()
376        .filter(|&&lc| (31..=60).contains(&lc))
377        .count() as f64;
378    let very_high = line_counts.iter().filter(|&&lc| lc > 60).count() as f64;
379    RiskProfile {
380        low_risk: (low / total * 1000.0).round() / 10.0,
381        medium_risk: (medium / total * 1000.0).round() / 10.0,
382        high_risk: (high / total * 1000.0).round() / 10.0,
383        very_high_risk: (very_high / total * 1000.0).round() / 10.0,
384    }
385}
386
387/// Compute unit interfacing risk profile from function parameter counts.
388///
389/// Bins: low risk (0-2 params), medium risk (3-4), high risk (5-6), very high risk (>=7).
390fn compute_interfacing_risk_profile(param_counts: &[u8]) -> RiskProfile {
391    if param_counts.is_empty() {
392        return RiskProfile {
393            low_risk: 0.0,
394            medium_risk: 0.0,
395            high_risk: 0.0,
396            very_high_risk: 0.0,
397        };
398    }
399    let total = param_counts.len() as f64;
400    let low = param_counts.iter().filter(|&&pc| pc <= 2).count() as f64;
401    let medium = param_counts
402        .iter()
403        .filter(|&&pc| (3..=4).contains(&pc))
404        .count() as f64;
405    let high = param_counts
406        .iter()
407        .filter(|&&pc| (5..=6).contains(&pc))
408        .count() as f64;
409    let very_high = param_counts.iter().filter(|&&pc| pc >= 7).count() as f64;
410    RiskProfile {
411        low_risk: (low / total * 1000.0).round() / 10.0,
412        medium_risk: (medium / total * 1000.0).round() / 10.0,
413        high_risk: (high / total * 1000.0).round() / 10.0,
414        very_high_risk: (very_high / total * 1000.0).round() / 10.0,
415    }
416}
417
418/// Compute coupling concentration from file health scores.
419///
420/// Returns (p95_fan_in, coupling_high_pct) where coupling_high_pct is the
421/// percentage of files with fan-in above the effective threshold (max(p95_fan_in, 10)).
422///
423/// The component-graph analogue (render fan-in concentration:
424/// `p95_render_fan_in` / `render_fan_in_high_pct` / `max_render_fan_in`) is
425/// computed in the `analyze::render_fan_in` module of `fallow_core`, which has
426/// the resolved-module graph. It uses the same p95 and floor math over the
427/// per-component distinct-parents distribution; change both together. The
428/// result is assigned onto `VitalSigns` in
429/// `health/vital_data.rs::prepare_health_vital_data`.
430#[expect(
431    clippy::cast_sign_loss,
432    reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
433)]
434#[expect(
435    clippy::cast_possible_truncation,
436    reason = "fan-in values are bounded by project size"
437)]
438fn compute_coupling_concentration(scores: &[FileHealthScore]) -> (Option<u32>, Option<f64>) {
439    if scores.is_empty() {
440        return (None, None);
441    }
442    let mut fan_ins: Vec<usize> = scores.iter().map(|s| s.fan_in).collect();
443    fan_ins.sort_unstable();
444    let idx = (fan_ins.len() as f64 * 0.95).ceil() as usize;
445    let idx = idx.min(fan_ins.len()) - 1;
446    let p95 = fan_ins[idx] as u32;
447
448    let threshold = (p95 as usize).max(10);
449    let high_count = fan_ins.iter().filter(|&&fi| fi > threshold).count();
450    let high_pct = (high_count as f64 / fan_ins.len() as f64 * 1000.0).round() / 10.0;
451
452    (Some(p95), Some(high_pct))
453}
454
455/// Compute a project-level health score from vital signs.
456///
457/// The score starts at 100 and subtracts penalties for each metric.
458/// Missing metrics (from pipelines that didn't run) don't penalize.
459/// `total_files` is used to normalize the hotspot count penalty.
460pub(crate) fn compute_health_score(vs: &VitalSigns, total_files: usize) -> HealthScore {
461    let penalties = compute_health_score_penalties(vs, total_files);
462    let score = apply_health_score_penalties(&penalties);
463    let grade = letter_grade(score);
464
465    HealthScore {
466        formula_version: HEALTH_SCORE_FORMULA_VERSION,
467        score,
468        grade,
469        penalties,
470    }
471}
472
473fn compute_health_score_penalties(vs: &VitalSigns, total_files: usize) -> HealthScorePenalties {
474    HealthScorePenalties {
475        dead_files: vs.dead_file_pct.map(|pct| round1((pct * 0.2).min(15.0))),
476        dead_exports: vs.dead_export_pct.map(|pct| round1((pct * 0.2).min(15.0))),
477        complexity: complexity_penalty(vs),
478        p90_complexity: p90_complexity_penalty(vs),
479        maintainability: maintainability_penalty(vs),
480        hotspots: hotspot_penalty(vs, total_files),
481        unused_deps: dependency_count_penalty(
482            vs.unused_deps_per_k_files,
483            vs.unused_dep_count,
484            25.0,
485            10.0,
486        ),
487        circular_deps: dependency_count_penalty(
488            vs.circular_deps_per_k_files,
489            vs.circular_dep_count,
490            25.0,
491            10.0,
492        ),
493        unit_size: unit_size_penalty(vs),
494        coupling: coupling_penalty(vs),
495        duplication: vs
496            .duplication_pct
497            .map(|dp| round1((dp - 5.0).clamp(0.0, 10.0))),
498        prop_drilling: prop_drilling_penalty(vs),
499    }
500}
501
502fn apply_health_score_penalties(penalties: &HealthScorePenalties) -> f64 {
503    let mut score = 100.0_f64;
504
505    subtract_optional_penalty(&mut score, penalties.dead_files);
506    subtract_optional_penalty(&mut score, penalties.dead_exports);
507    score -= penalties.complexity;
508    score -= penalties.p90_complexity;
509    subtract_optional_penalty(&mut score, penalties.maintainability);
510    subtract_optional_penalty(&mut score, penalties.hotspots);
511    subtract_optional_penalty(&mut score, penalties.unused_deps);
512    subtract_optional_penalty(&mut score, penalties.circular_deps);
513    subtract_optional_penalty(&mut score, penalties.unit_size);
514    subtract_optional_penalty(&mut score, penalties.coupling);
515    subtract_optional_penalty(&mut score, penalties.duplication);
516    subtract_optional_penalty(&mut score, penalties.prop_drilling);
517
518    round1(score).clamp(0.0, 100.0)
519}
520
521/// Small capped penalty for prop-drilling chains, sized like the coupling
522/// penalty (~5pt cap). Each located chain costs 1pt up to the cap; a deeper
523/// chain does not cost more (depth is descriptive, not a tunable threshold).
524/// `None` (no penalty) unless the opt-in `prop-drilling` rule populated the
525/// count, so the score is unchanged by default.
526fn prop_drilling_penalty(vs: &VitalSigns) -> Option<f64> {
527    vs.prop_drilling_chain_count
528        .map(|count| round1((f64::from(count) * 1.0).min(5.0)))
529}
530
531fn round1(value: f64) -> f64 {
532    (value * 10.0).round() / 10.0
533}
534
535fn subtract_optional_penalty(score: &mut f64, penalty: Option<f64>) {
536    if let Some(penalty) = penalty {
537        *score -= penalty;
538    }
539}
540
541fn complexity_penalty(vs: &VitalSigns) -> f64 {
542    if let Some(critical_pct) = vs.critical_complexity_pct {
543        round1((critical_pct * 4.0).min(20.0))
544    } else {
545        round1(((vs.avg_cyclomatic - 1.5).max(0.0) * 5.0).min(20.0))
546    }
547}
548
549fn p90_complexity_penalty(vs: &VitalSigns) -> f64 {
550    if vs.critical_complexity_pct.is_some() {
551        0.0
552    } else {
553        round1((f64::from(vs.p90_cyclomatic) - 10.0).clamp(0.0, 10.0))
554    }
555}
556
557fn maintainability_penalty(vs: &VitalSigns) -> Option<f64> {
558    if let Some(low_pct) = vs.maintainability_low_pct {
559        Some(round1((low_pct * 1.5).min(15.0)))
560    } else {
561        vs.maintainability_avg
562            .map(|mi| round1(((70.0 - mi).max(0.0) * 0.5).min(15.0)))
563    }
564}
565
566fn hotspot_penalty(vs: &VitalSigns, total_files: usize) -> Option<f64> {
567    vs.hotspot_count.map(|count| {
568        if total_files == 0 {
569            return 0.0;
570        }
571        let top_pct_bucket = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION)
572            .ceil()
573            .max(1.0);
574        round1((f64::from(count) / top_pct_bucket * HOTSPOT_MAX_PENALTY).min(HOTSPOT_MAX_PENALTY))
575    })
576}
577
578fn dependency_count_penalty(
579    per_k: Option<f64>,
580    count: Option<u32>,
581    per_k_cap: f64,
582    count_cap: f64,
583) -> Option<f64> {
584    if let Some(per_k) = per_k {
585        Some(round1((per_k * 0.5).min(per_k_cap)))
586    } else {
587        count.map(|count| round1(f64::from(count).min(count_cap)))
588    }
589}
590
591fn unit_size_penalty(vs: &VitalSigns) -> Option<f64> {
592    if let Some(per_k) = vs.functions_over_60_loc_per_k {
593        Some(round1((per_k * 0.5).min(10.0)))
594    } else {
595        vs.unit_size_profile
596            .as_ref()
597            .map(|profile| round1(((profile.very_high_risk - 5.0).max(0.0) * 0.5).min(10.0)))
598    }
599}
600
601fn coupling_penalty(vs: &VitalSigns) -> Option<f64> {
602    if let Some(high_pct) = vs.coupling_high_pct {
603        Some(round1((high_pct * 0.5).min(5.0)))
604    } else {
605        vs.p95_fan_in
606            .map(|p95| round1(((f64::from(p95) - 30.0).max(0.0) * 0.25).min(5.0)))
607    }
608}
609
610/// Build the raw counts for a snapshot.
611pub(crate) fn build_counts(input: &VitalSignsInput<'_>) -> VitalSignsCounts {
612    let (total_exports, dead_files, dead_exports, total_deps) = input
613        .analysis_counts
614        .as_ref()
615        .map_or((0, 0, 0, 0), |counts| {
616            (
617                counts.total_exports,
618                counts.dead_files,
619                counts.dead_exports,
620                counts.total_deps,
621            )
622        });
623
624    let total_lines: usize = input.selected_modules().map(|m| m.line_offsets.len()).sum();
625
626    VitalSignsCounts {
627        total_files: input.total_files,
628        total_exports,
629        dead_files,
630        dead_exports,
631        duplicated_lines: None,
632        total_lines: Some(total_lines),
633        files_scored: input.file_scores.map(<[_]>::len),
634        total_deps,
635    }
636}
637
638/// Get the current git branch name.
639fn git_branch(root: &Path) -> Option<String> {
640    let mut command = crate::git_env::git_command(root);
641    command.args(["rev-parse", "--abbrev-ref", "HEAD"]);
642    command
643        .output()
644        .ok()
645        .filter(|o| o.status.success())
646        .and_then(|o| {
647            let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
648            if name == "HEAD" { None } else { Some(name) }
649        })
650}
651
652/// Build a snapshot from vital signs and input data.
653#[expect(
654    clippy::too_many_arguments,
655    reason = "snapshot construction keeps every persisted compatibility input explicit"
656)]
657pub(crate) fn build_snapshot(
658    vital_signs: VitalSigns,
659    counts: VitalSignsCounts,
660    root: &Path,
661    shallow_clone: bool,
662    health_score: Option<&HealthScore>,
663    coverage_model: Option<fallow_output::CoverageModel>,
664    analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
665) -> VitalSignsSnapshot {
666    let now = chrono_timestamp();
667
668    VitalSignsSnapshot {
669        snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
670        version: env!("CARGO_PKG_VERSION").to_string(),
671        timestamp: now,
672        git_sha: crate::repo_refs::short_head_sha(root),
673        git_branch: git_branch(root),
674        shallow_clone,
675        vital_signs,
676        counts,
677        score: health_score.map(|s| s.score),
678        grade: health_score.map(|s| s.grade.to_string()),
679        score_formula_version: health_score.map(|s| s.formula_version),
680        coverage_model,
681        analysis_identity,
682        groups: None,
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    let json =
739        serde_json::to_string_pretty(snapshot).map_err(|e| format!("failed to serialize: {e}"))?;
740    crate::write_guard::write_file(
741        &path,
742        json.as_bytes(),
743        crate::write_guard::WriteTarget::Path,
744    )
745    .map_err(|e| {
746        if e.is_directory() {
747            format!("failed to create snapshot directory: {e}")
748        } else {
749            format!("failed to write snapshot: {e}")
750        }
751    })?;
752
753    Ok(path)
754}
755
756/// Load all snapshots from the default snapshot directory, sorted by timestamp ascending.
757///
758/// Corrupt or unreadable files are skipped with a warning to stderr.
759/// Returns an empty vec if the directory does not exist.
760#[cfg(test)]
761pub(crate) fn load_snapshots(root: &Path) -> Vec<VitalSignsSnapshot> {
762    load_snapshots_with_paths(root)
763        .into_iter()
764        .map(|(_, snapshot)| snapshot)
765        .collect()
766}
767
768/// Load all snapshots from the default snapshot directory with their file
769/// paths, sorted by timestamp ascending. Corrupt or unreadable files are
770/// skipped with a warning to stderr.
771#[expect(
772    clippy::print_stderr,
773    reason = "corrupt-snapshot warnings to stderr, preserved verbatim from the CLI health path"
774)]
775fn load_snapshots_with_paths(root: &Path) -> Vec<(PathBuf, VitalSignsSnapshot)> {
776    let dir = root.join(".fallow").join("snapshots");
777    let Ok(entries) = std::fs::read_dir(&dir) else {
778        return Vec::new();
779    };
780
781    let mut snapshots = Vec::new();
782    for entry in entries {
783        let Ok(entry) = entry else { continue };
784        let path = entry.path();
785        if path.extension().is_some_and(|ext| ext == "json") {
786            match std::fs::read_to_string(&path) {
787                Ok(content) => match serde_json::from_str::<VitalSignsSnapshot>(&content) {
788                    Ok(snap) => snapshots.push((path, snap)),
789                    Err(e) => {
790                        eprintln!("warning: skipping corrupt snapshot {}: {e}", path.display());
791                        record_unreadable_snapshot(root, &path, &e.to_string());
792                    }
793                },
794                Err(e) => {
795                    eprintln!("warning: could not read snapshot {}: {e}", path.display());
796                    record_unreadable_snapshot(root, &path, &e.to_string());
797                }
798            }
799        }
800    }
801
802    snapshots.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
803    snapshots
804}
805
806/// The snapshot a trend compares against, with where it came from.
807#[derive(Debug, Clone)]
808pub(crate) struct TrendBaseline {
809    /// The baseline snapshot.
810    pub(crate) snapshot: VitalSignsSnapshot,
811    /// The file the snapshot was read from.
812    pub(crate) path: PathBuf,
813    /// Number of snapshots the trend source held: the snapshot directory
814    /// count, or `1` for an explicit `--trend-from` file.
815    pub(crate) snapshots_loaded: usize,
816}
817
818impl TrendBaseline {
819    /// The baseline snapshot without its group data.
820    ///
821    /// Each group trend copies this snapshot, so the group data is removed
822    /// once here and not copied again for every group.
823    pub(crate) fn without_groups(&self) -> VitalSignsSnapshot {
824        let mut snapshot = self.snapshot.clone();
825        snapshot.groups = None;
826        snapshot
827    }
828}
829
830/// Load the trend baseline for the project trend and the group trend.
831///
832/// With `explicit`, read exactly that file. It must exist and parse, because
833/// the user named it. Without it, use the newest snapshot of
834/// `.fallow/snapshots/`, and return `Ok(None)` when there is none.
835///
836/// # Errors
837///
838/// Returns a message when the explicit file cannot be read or parsed.
839pub(crate) fn load_trend_baseline(
840    root: &Path,
841    explicit: Option<&Path>,
842) -> Result<Option<TrendBaseline>, String> {
843    if let Some(path) = explicit {
844        let content = std::fs::read_to_string(path).map_err(|e| {
845            format!(
846                "failed to read --trend-from snapshot {}: {e}",
847                path.display()
848            )
849        })?;
850        // Report only the position. The serde message can quote values from
851        // the file, and MCP `trend_from` returns this text to the caller.
852        let snapshot = serde_json::from_str::<VitalSignsSnapshot>(&content).map_err(|e| {
853            format!(
854                "--trend-from file {} is not a fallow health snapshot (parse error at line {}, column {})",
855                path.display(),
856                e.line(),
857                e.column()
858            )
859        })?;
860        return Ok(Some(TrendBaseline {
861            snapshot,
862            path: path.to_path_buf(),
863            snapshots_loaded: 1,
864        }));
865    }
866    let mut snapshots = load_snapshots_with_paths(root);
867    let snapshots_loaded = snapshots.len();
868    Ok(snapshots.pop().map(|(path, snapshot)| TrendBaseline {
869        snapshot,
870        path,
871        snapshots_loaded,
872    }))
873}
874
875/// Record a snapshot this run could not use, so the thinner trend is visible to
876/// a consumer that never sees the warning above (issue #2689).
877fn record_unreadable_snapshot(root: &Path, path: &Path, error: &str) {
878    crate::health::diagnostics::record_health_diagnostic(
879        root,
880        Some(path),
881        fallow_types::workspace::WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
882            error: error.to_owned(),
883        },
884    );
885}
886
887/// Tolerance for treating a metric delta as "stable" rather than improving/declining.
888const TREND_TOLERANCE: f64 = 0.5;
889
890fn trend_point_from_snapshot(prev: &VitalSignsSnapshot) -> TrendPoint {
891    TrendPoint {
892        timestamp: prev.timestamp.clone(),
893        git_sha: prev.git_sha.clone(),
894        score: prev.score,
895        grade: prev.grade.clone(),
896        score_formula_version: prev.score_formula_version,
897        coverage_model: prev.coverage_model.clone(),
898        snapshot_schema_version: Some(prev.snapshot_schema_version),
899    }
900}
901
902fn overall_trend_direction(metrics: &[TrendMetric]) -> TrendDirection {
903    let (improving, declining) = metrics.iter().fold((0usize, 0usize), |(imp, dec), metric| {
904        match metric.direction {
905            TrendDirection::Improving => (imp + 1, dec),
906            TrendDirection::Declining => (imp, dec + 1),
907            TrendDirection::Stable => (imp, dec),
908        }
909    });
910
911    match improving.cmp(&declining) {
912        std::cmp::Ordering::Greater => TrendDirection::Improving,
913        std::cmp::Ordering::Less => TrendDirection::Declining,
914        std::cmp::Ordering::Equal => TrendDirection::Stable,
915    }
916}
917
918/// Compute a trend comparison between the current run and the most recent snapshot.
919///
920/// Uses the stored `score` field from the snapshot (never re-derives it).
921/// Returns `None` if no snapshots are available.
922#[cfg(test)]
923pub(crate) fn compute_trend(
924    current_vs: &VitalSigns,
925    current_counts: &VitalSignsCounts,
926    current_score: Option<f64>,
927    snapshots: &[VitalSignsSnapshot],
928) -> Option<HealthTrend> {
929    let prev = snapshots.last()?;
930    let current_score = current_score.map(|score| HealthScore {
931        score,
932        ..compute_health_score(current_vs, current_counts.total_files)
933    });
934    Some(compute_trend_against(
935        prev,
936        snapshots.len(),
937        current_vs,
938        current_counts,
939        current_score.as_ref(),
940    ))
941}
942
943/// Compare the current metrics against one baseline snapshot.
944///
945/// Uses the stored `score` of the snapshot (never re-derives it).
946pub(crate) fn compute_trend_against(
947    prev: &VitalSignsSnapshot,
948    snapshots_loaded: usize,
949    current_vs: &VitalSigns,
950    current_counts: &VitalSignsCounts,
951    current_score: Option<&HealthScore>,
952) -> HealthTrend {
953    let compared_to = trend_point_from_snapshot(prev);
954
955    let metrics = TrendBuilder::new(prev, current_vs, current_counts, current_score).build();
956
957    let overall_direction = overall_trend_direction(&metrics);
958
959    HealthTrend {
960        compared_to,
961        metrics,
962        snapshots_loaded,
963        overall_direction,
964    }
965}
966
967/// Compare the current metrics of one group against the same group in the
968/// baseline snapshot.
969///
970/// `compared_to` names the baseline snapshot (timestamp, commit), with the
971/// stored score and grade of the group. `base` is the baseline snapshot
972/// without its `groups`, built once for all groups by
973/// [`TrendBaseline::without_groups`].
974pub(crate) fn compute_group_trend(
975    base: &VitalSignsSnapshot,
976    snapshots_loaded: usize,
977    previous: &fallow_output::GroupSnapshot,
978    current_vs: &VitalSigns,
979    current_counts: &VitalSignsCounts,
980    current_score: Option<&HealthScore>,
981) -> HealthTrend {
982    let prev = VitalSignsSnapshot {
983        vital_signs: previous.vital_signs.clone(),
984        counts: previous.counts.clone(),
985        score: previous.score,
986        grade: previous.grade.clone(),
987        ..base.clone()
988    };
989    compute_trend_against(
990        &prev,
991        snapshots_loaded,
992        current_vs,
993        current_counts,
994        current_score,
995    )
996}
997
998struct TrendBuilder<'a> {
999    prev: &'a VitalSignsSnapshot,
1000    current_vs: &'a VitalSigns,
1001    current_counts: &'a VitalSignsCounts,
1002    current_score: Option<&'a HealthScore>,
1003    metrics: Vec<TrendMetric>,
1004}
1005
1006impl TrendBuilder<'_> {
1007    fn new<'a>(
1008        prev: &'a VitalSignsSnapshot,
1009        current_vs: &'a VitalSigns,
1010        current_counts: &'a VitalSignsCounts,
1011        current_score: Option<&'a HealthScore>,
1012    ) -> TrendBuilder<'a> {
1013        TrendBuilder {
1014            prev,
1015            current_vs,
1016            current_counts,
1017            current_score,
1018            metrics: Vec::new(),
1019        }
1020    }
1021
1022    fn build(mut self) -> Vec<TrendMetric> {
1023        self.add_score_metric();
1024        self.add_dead_code_metrics();
1025        self.add_complexity_metrics();
1026        self.add_dependency_metrics();
1027        self.add_structure_metrics();
1028        self.metrics
1029    }
1030
1031    fn push(&mut self, input: TrendMetricInput) {
1032        self.metrics.push(make_metric(input));
1033    }
1034
1035    fn add_score_metric(&mut self) {
1036        let Some(current) = self.current_score else {
1037            return;
1038        };
1039        if self.prev.score_formula_version != Some(current.formula_version) {
1040            return;
1041        }
1042        if let Some(prev_score) = self.prev.score {
1043            self.push(TrendMetricInput {
1044                name: "score",
1045                label: "Health Score",
1046                previous: prev_score,
1047                current: current.score,
1048                unit: "",
1049                higher_is_better: true,
1050                previous_count: None,
1051                current_count: None,
1052            });
1053        }
1054    }
1055
1056    fn add_dead_code_metrics(&mut self) {
1057        if let (Some(prev_val), Some(cur_val)) = (
1058            self.prev.vital_signs.dead_file_pct,
1059            self.current_vs.dead_file_pct,
1060        ) {
1061            self.push(TrendMetricInput {
1062                name: "dead_file_pct",
1063                label: "Dead Files",
1064                previous: prev_val,
1065                current: cur_val,
1066                unit: "%",
1067                higher_is_better: false,
1068                previous_count: Some(TrendCount {
1069                    value: self.prev.counts.dead_files,
1070                    total: self.prev.counts.total_files,
1071                }),
1072                current_count: Some(TrendCount {
1073                    value: self.current_counts.dead_files,
1074                    total: self.current_counts.total_files,
1075                }),
1076            });
1077        }
1078
1079        if let (Some(prev_val), Some(cur_val)) = (
1080            self.prev.vital_signs.dead_export_pct,
1081            self.current_vs.dead_export_pct,
1082        ) {
1083            self.push(TrendMetricInput {
1084                name: "dead_export_pct",
1085                label: "Dead Exports",
1086                previous: prev_val,
1087                current: cur_val,
1088                unit: "%",
1089                higher_is_better: false,
1090                previous_count: Some(TrendCount {
1091                    value: self.prev.counts.dead_exports,
1092                    total: self.prev.counts.total_exports,
1093                }),
1094                current_count: Some(TrendCount {
1095                    value: self.current_counts.dead_exports,
1096                    total: self.current_counts.total_exports,
1097                }),
1098            });
1099        }
1100    }
1101
1102    fn add_complexity_metrics(&mut self) {
1103        self.push(TrendMetricInput {
1104            name: "avg_cyclomatic",
1105            label: "Avg Cyclomatic",
1106            previous: self.prev.vital_signs.avg_cyclomatic,
1107            current: self.current_vs.avg_cyclomatic,
1108            unit: "",
1109            higher_is_better: false,
1110            previous_count: None,
1111            current_count: None,
1112        });
1113
1114        if let (Some(prev_val), Some(cur_val)) = (
1115            self.prev.vital_signs.maintainability_avg,
1116            self.current_vs.maintainability_avg,
1117        ) {
1118            self.push(TrendMetricInput {
1119                name: "maintainability_avg",
1120                label: "Maintainability",
1121                previous: prev_val,
1122                current: cur_val,
1123                unit: "",
1124                higher_is_better: true,
1125                previous_count: None,
1126                current_count: None,
1127            });
1128        }
1129
1130        if let (Some(prev_profile), Some(cur_profile)) = (
1131            &self.prev.vital_signs.unit_size_profile,
1132            &self.current_vs.unit_size_profile,
1133        ) {
1134            self.push(TrendMetricInput {
1135                name: "unit_size_very_high_pct",
1136                label: "Oversized Fns",
1137                previous: prev_profile.very_high_risk,
1138                current: cur_profile.very_high_risk,
1139                unit: "%",
1140                higher_is_better: false,
1141                previous_count: None,
1142                current_count: None,
1143            });
1144        }
1145
1146        self.add_duplication_metric();
1147    }
1148
1149    fn add_duplication_metric(&mut self) {
1150        if let (Some(prev_val), Some(cur_val)) = (
1151            self.prev.vital_signs.duplication_pct,
1152            self.current_vs.duplication_pct,
1153        ) {
1154            self.push(TrendMetricInput {
1155                name: "duplication_pct",
1156                label: "Duplication",
1157                previous: prev_val,
1158                current: cur_val,
1159                unit: "%",
1160                higher_is_better: false,
1161                previous_count: self
1162                    .prev
1163                    .counts
1164                    .duplicated_lines
1165                    .zip(self.prev.counts.total_lines)
1166                    .map(|(d, t)| TrendCount { value: d, total: t }),
1167                current_count: self
1168                    .current_counts
1169                    .duplicated_lines
1170                    .zip(self.current_counts.total_lines)
1171                    .map(|(d, t)| TrendCount { value: d, total: t }),
1172            });
1173        }
1174    }
1175
1176    fn add_dependency_metrics(&mut self) {
1177        if let (Some(prev_val), Some(cur_val)) = (
1178            self.prev.vital_signs.unused_dep_count,
1179            self.current_vs.unused_dep_count,
1180        ) {
1181            self.push(TrendMetricInput {
1182                name: "unused_dep_count",
1183                label: "Unused Deps",
1184                previous: f64::from(prev_val),
1185                current: f64::from(cur_val),
1186                unit: "",
1187                higher_is_better: false,
1188                previous_count: None,
1189                current_count: None,
1190            });
1191        }
1192    }
1193
1194    fn add_structure_metrics(&mut self) {
1195        if let (Some(prev_val), Some(cur_val)) = (
1196            self.prev.vital_signs.circular_dep_count,
1197            self.current_vs.circular_dep_count,
1198        ) {
1199            self.push(TrendMetricInput {
1200                name: "circular_dep_count",
1201                label: "Circular Deps",
1202                previous: f64::from(prev_val),
1203                current: f64::from(cur_val),
1204                unit: "",
1205                higher_is_better: false,
1206                previous_count: None,
1207                current_count: None,
1208            });
1209        }
1210
1211        if let (Some(prev_val), Some(cur_val)) = (
1212            self.prev.vital_signs.hotspot_count,
1213            self.current_vs.hotspot_count,
1214        ) {
1215            self.push(TrendMetricInput {
1216                name: "hotspot_count",
1217                label: "Hotspots",
1218                previous: f64::from(prev_val),
1219                current: f64::from(cur_val),
1220                unit: "",
1221                higher_is_better: false,
1222                previous_count: None,
1223                current_count: None,
1224            });
1225        }
1226
1227        if let (Some(prev_val), Some(cur_val)) =
1228            (self.prev.vital_signs.p95_fan_in, self.current_vs.p95_fan_in)
1229        {
1230            self.push(TrendMetricInput {
1231                name: "p95_fan_in",
1232                label: "P95 Fan-in",
1233                previous: f64::from(prev_val),
1234                current: f64::from(cur_val),
1235                unit: "",
1236                higher_is_better: false,
1237                previous_count: None,
1238                current_count: None,
1239            });
1240        }
1241    }
1242}
1243
1244/// Build a single trend metric.
1245struct TrendMetricInput {
1246    name: &'static str,
1247    label: &'static str,
1248    previous: f64,
1249    current: f64,
1250    unit: &'static str,
1251    higher_is_better: bool,
1252    previous_count: Option<TrendCount>,
1253    current_count: Option<TrendCount>,
1254}
1255
1256fn make_metric(input: TrendMetricInput) -> TrendMetric {
1257    let TrendMetricInput {
1258        name,
1259        label,
1260        previous,
1261        current,
1262        unit,
1263        higher_is_better,
1264        previous_count,
1265        current_count,
1266    } = input;
1267    let delta = (current - previous).round_to(1);
1268    let direction = if delta.abs() < TREND_TOLERANCE {
1269        TrendDirection::Stable
1270    } else if (higher_is_better && delta > 0.0) || (!higher_is_better && delta < 0.0) {
1271        TrendDirection::Improving
1272    } else {
1273        TrendDirection::Declining
1274    };
1275
1276    TrendMetric {
1277        name,
1278        label,
1279        previous,
1280        current,
1281        delta,
1282        direction,
1283        unit,
1284        previous_count,
1285        current_count,
1286    }
1287}
1288
1289/// Extension trait for rounding floats to N decimal places.
1290trait RoundTo {
1291    fn round_to(self, decimals: u32) -> Self;
1292}
1293
1294impl RoundTo for f64 {
1295    fn round_to(self, decimals: u32) -> Self {
1296        let factor = 10_f64.powi(i32::try_from(decimals).unwrap_or(i32::MAX));
1297        (self * factor).round() / factor
1298    }
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304
1305    fn make_module(id: u32, cyclomatic: u16) -> crate::source::ModuleInfo {
1306        crate::source::ModuleInfo {
1307            complexity: vec![fallow_types::extract::FunctionComplexity {
1308                name: format!("fn_{id}"),
1309                is_private_member: false,
1310                context_named: None,
1311                line: id + 1,
1312                col: 0,
1313                cyclomatic,
1314                cognitive: 0,
1315                line_count: 10,
1316                param_count: 0,
1317                react_hook_count: 0,
1318                react_jsx_max_depth: 0,
1319                react_prop_count: 0,
1320                source_hash: None,
1321                contributions: Vec::new(),
1322            }],
1323            ..crate::source::ModuleInfo::empty(crate::discover::FileId(id))
1324        }
1325    }
1326
1327    #[expect(
1328        clippy::cast_possible_truncation,
1329        reason = "test values are trivially small"
1330    )]
1331    fn make_modules() -> Vec<crate::source::ModuleInfo> {
1332        (0..10)
1333            .map(|i| make_module(i, (i as u16 + 1) * 2))
1334            .collect()
1335    }
1336
1337    fn assert_close(actual: f64, expected: f64) {
1338        assert!(
1339            (actual - expected).abs() < f64::EPSILON,
1340            "expected {expected}, got {actual}"
1341        );
1342    }
1343
1344    fn assert_some_close(actual: Option<f64>, expected: f64) {
1345        assert_close(actual.expect("expected metric to be present"), expected);
1346    }
1347
1348    #[test]
1349    fn compute_cyclomatic_stats() {
1350        let modules = make_modules();
1351        let input = VitalSignsInput {
1352            modules: &modules,
1353            module_filter: None,
1354            file_scores: None,
1355            hotspots: None,
1356            total_files: 10,
1357            analysis_counts: None,
1358        };
1359        let vs = compute_vital_signs(&input);
1360        assert!((vs.avg_cyclomatic - 11.0).abs() < f64::EPSILON);
1361        assert_eq!(vs.p90_cyclomatic, 18);
1362    }
1363
1364    #[test]
1365    fn cyclomatic_population_partitions_units_and_respects_module_filter() {
1366        let mut mixed = make_module(0, 1);
1367        let mut module_unit = mixed.complexity[0].clone();
1368        module_unit.name = "<module>".into();
1369        module_unit.cyclomatic = 31;
1370        let mut template_unit = module_unit.clone();
1371        template_unit.name = "<template>".into();
1372        template_unit.cyclomatic = 4;
1373        mixed.complexity.extend([module_unit, template_unit]);
1374        let modules = [mixed, make_module(1, 100)];
1375        let filter = rustc_hash::FxHashSet::from_iter([crate::discover::FileId(0)]);
1376        let input = VitalSignsInput {
1377            modules: &modules,
1378            module_filter: Some(&filter),
1379            file_scores: None,
1380            hotspots: None,
1381            total_files: 1,
1382            analysis_counts: None,
1383        };
1384        let vs = compute_vital_signs(&input);
1385        let population = vs.cyclomatic_population.unwrap();
1386        assert_eq!(population.functions.count, 1);
1387        assert_eq!(population.modules.count, 1);
1388        assert_eq!(population.templates.count, 1);
1389        assert_eq!(population.functions.sum, 1);
1390        assert_eq!(population.modules.sum, 31);
1391        assert_eq!(population.templates.sum, 4);
1392        assert_close(vs.avg_cyclomatic, 12.0);
1393        assert_eq!(vs.p90_cyclomatic, 31);
1394    }
1395
1396    #[test]
1397    fn compute_with_analysis_counts() {
1398        let modules = make_modules();
1399        let input = VitalSignsInput {
1400            modules: &modules,
1401            module_filter: None,
1402            file_scores: None,
1403            hotspots: None,
1404            total_files: 100,
1405            analysis_counts: Some(AnalysisCounts {
1406                total_exports: 500,
1407                dead_files: 5,
1408                dead_exports: 50,
1409                unused_deps: 3,
1410                circular_deps: 2,
1411                total_deps: 40,
1412            }),
1413        };
1414        let vs = compute_vital_signs(&input);
1415        assert_eq!(vs.dead_file_pct, Some(5.0)); // 5/100 * 100
1416        assert_eq!(vs.dead_export_pct, Some(10.0)); // 50/500 * 100
1417        assert_eq!(vs.unused_dep_count, Some(3));
1418        assert_eq!(vs.circular_dep_count, Some(2));
1419    }
1420
1421    #[test]
1422    fn compute_hotspot_count_with_threshold() {
1423        let hotspots = vec![
1424            HotspotEntry {
1425                path: PathBuf::from("a.ts"),
1426                score: 80.0,
1427                commits: 10,
1428                weighted_commits: 8.0,
1429                lines_added: 100,
1430                lines_deleted: 50,
1431                complexity_density: 0.5,
1432                fan_in: 5,
1433                trend: crate::churn::ChurnTrend::Stable,
1434                median_files_per_commit: None,
1435                median_lines_per_commit: None,
1436                ownership: None,
1437                is_test_path: false,
1438            },
1439            HotspotEntry {
1440                path: PathBuf::from("b.ts"),
1441                score: 30.0, // Below threshold
1442                commits: 5,
1443                weighted_commits: 3.0,
1444                lines_added: 40,
1445                lines_deleted: 20,
1446                complexity_density: 0.2,
1447                fan_in: 2,
1448                trend: crate::churn::ChurnTrend::Cooling,
1449                median_files_per_commit: None,
1450                median_lines_per_commit: None,
1451                ownership: None,
1452                is_test_path: false,
1453            },
1454            HotspotEntry {
1455                path: PathBuf::from("c.ts"),
1456                score: 50.0, // At threshold
1457                commits: 8,
1458                weighted_commits: 6.0,
1459                lines_added: 80,
1460                lines_deleted: 30,
1461                complexity_density: 0.4,
1462                fan_in: 3,
1463                trend: crate::churn::ChurnTrend::Accelerating,
1464                median_files_per_commit: None,
1465                median_lines_per_commit: None,
1466                ownership: None,
1467                is_test_path: false,
1468            },
1469        ];
1470        let modules = Vec::new();
1471        let input = VitalSignsInput {
1472            modules: &modules,
1473            module_filter: None,
1474            file_scores: None,
1475            hotspots: Some(&hotspots),
1476            total_files: 10,
1477            analysis_counts: None,
1478        };
1479        let vs = compute_vital_signs(&input);
1480        assert_eq!(vs.hotspot_count, Some(2)); // 80.0 and 50.0 meet threshold
1481        assert_eq!(vs.hotspot_top_pct_count, Some(1)); // top 1% bucket rounds up to one file
1482    }
1483
1484    #[test]
1485    fn hotspot_penalty_ignores_subthreshold_ranked_entries() {
1486        let hotspots: Vec<HotspotEntry> = [49.9, 0.1]
1487            .into_iter()
1488            .enumerate()
1489            .map(|(index, score)| HotspotEntry {
1490                path: PathBuf::from(format!("src/active-{index}.ts")),
1491                score,
1492                commits: 3,
1493                weighted_commits: 3.0,
1494                lines_added: 10,
1495                lines_deleted: 5,
1496                complexity_density: 0.1,
1497                fan_in: 0,
1498                trend: crate::churn::ChurnTrend::Stable,
1499                median_files_per_commit: None,
1500                median_lines_per_commit: None,
1501                ownership: None,
1502                is_test_path: false,
1503            })
1504            .collect();
1505        let input = VitalSignsInput {
1506            modules: &[],
1507            module_filter: None,
1508            file_scores: None,
1509            hotspots: Some(&hotspots),
1510            total_files: 200,
1511            analysis_counts: None,
1512        };
1513
1514        let vitals = compute_vital_signs(&input);
1515        let health = compute_health_score(&vitals, input.total_files);
1516
1517        assert_eq!(vitals.hotspot_count, Some(0));
1518        assert_eq!(vitals.hotspot_top_pct_count, Some(2));
1519        assert_some_close(health.penalties.hotspots, 0.0);
1520    }
1521
1522    #[test]
1523    fn empty_cyclomatic_population_is_measured_not_unknown() {
1524        let vs = compute_vital_signs(&VitalSignsInput {
1525            modules: &[],
1526            module_filter: None,
1527            file_scores: None,
1528            hotspots: None,
1529            total_files: 0,
1530            analysis_counts: None,
1531        });
1532        let population = vs.cyclomatic_population.unwrap();
1533        for group in [
1534            population.functions,
1535            population.modules,
1536            population.templates,
1537        ] {
1538            assert_eq!(group.count, 0);
1539            assert_eq!(group.sum, 0);
1540            assert_eq!(group.max, None);
1541        }
1542        assert_close(vs.avg_cyclomatic, 0.0);
1543        assert_eq!(vs.p90_cyclomatic, 0);
1544        assert_eq!(vs.critical_complexity_pct, None);
1545    }
1546
1547    #[test]
1548    fn compute_without_hotspots_gives_none() {
1549        let modules = Vec::new();
1550        let input = VitalSignsInput {
1551            modules: &modules,
1552            module_filter: None,
1553            file_scores: None,
1554            hotspots: None,
1555            total_files: 0,
1556            analysis_counts: None,
1557        };
1558        let vs = compute_vital_signs(&input);
1559        assert!(vs.hotspot_count.is_none());
1560    }
1561
1562    #[test]
1563    fn snapshot_save_and_load() {
1564        let dir = tempfile::tempdir().unwrap();
1565        let root = dir.path();
1566        let vs = VitalSigns {
1567            dead_file_pct: Some(3.2),
1568            dead_export_pct: Some(8.1),
1569            avg_cyclomatic: 4.7,
1570            p90_cyclomatic: 12,
1571            hotspot_count: Some(5),
1572            maintainability_avg: Some(72.4),
1573            unused_dep_count: Some(4),
1574            circular_dep_count: Some(2),
1575            ..Default::default()
1576        };
1577        let counts = VitalSignsCounts {
1578            total_files: 1200,
1579            total_exports: 5400,
1580            dead_files: 38,
1581            dead_exports: 437,
1582            files_scored: Some(1150),
1583            total_deps: 42,
1584            ..Default::default()
1585        };
1586        let health_score = compute_health_score(&vs, 1200);
1587        let snapshot = build_snapshot(
1588            vs,
1589            counts,
1590            root,
1591            false,
1592            Some(&health_score),
1593            None,
1594            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1595        );
1596        let saved_path = save_snapshot(&snapshot, root, None).unwrap();
1597
1598        assert!(saved_path.exists());
1599        assert!(saved_path.starts_with(root.join(".fallow/snapshots")));
1600
1601        let content = std::fs::read_to_string(&saved_path).unwrap();
1602        let loaded: VitalSignsSnapshot = serde_json::from_str(&content).unwrap();
1603        assert_eq!(loaded.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
1604        assert!((loaded.vital_signs.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
1605        assert_eq!(loaded.counts.total_files, 1200);
1606        assert!(loaded.score.is_some());
1607        assert!(loaded.grade.is_some());
1608    }
1609
1610    #[test]
1611    fn snapshot_save_explicit_path() {
1612        let dir = tempfile::tempdir().unwrap();
1613        let root = dir.path();
1614        let explicit = root.join("my-snapshot.json");
1615        let vs = VitalSigns {
1616            avg_cyclomatic: 1.0,
1617            p90_cyclomatic: 2,
1618            ..Default::default()
1619        };
1620        let counts = VitalSignsCounts::default();
1621        let snapshot = build_snapshot(
1622            vs,
1623            counts,
1624            root,
1625            false,
1626            None,
1627            None,
1628            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1629        );
1630        let saved = save_snapshot(&snapshot, root, Some(&explicit)).unwrap();
1631        assert_eq!(saved, explicit);
1632        assert!(explicit.exists());
1633    }
1634
1635    #[test]
1636    fn snapshot_save_creates_nested_dirs() {
1637        let dir = tempfile::tempdir().unwrap();
1638        let root = dir.path();
1639        let nested = root.join("a/b/c/snapshot.json");
1640        let vs = VitalSigns {
1641            avg_cyclomatic: 1.0,
1642            p90_cyclomatic: 2,
1643            ..Default::default()
1644        };
1645        let counts = VitalSignsCounts::default();
1646        let snapshot = build_snapshot(
1647            vs,
1648            counts,
1649            root,
1650            false,
1651            None,
1652            None,
1653            fallow_types::semantic::SemanticAnalysisIdentity::default(),
1654        );
1655        let saved = save_snapshot(&snapshot, root, Some(&nested)).unwrap();
1656        assert_eq!(saved, nested);
1657        assert!(nested.exists());
1658    }
1659
1660    #[test]
1661    fn days_to_ymd_epoch() {
1662        assert_eq!(days_to_ymd(0), (1970, 1, 1));
1663    }
1664
1665    #[test]
1666    fn days_to_ymd_known_date() {
1667        assert_eq!(days_to_ymd(20_537), (2026, 3, 25));
1668    }
1669
1670    #[test]
1671    fn health_score_perfect() {
1672        let vs = VitalSigns {
1673            dead_file_pct: Some(0.0),
1674            dead_export_pct: Some(0.0),
1675            avg_cyclomatic: 1.0,
1676            p90_cyclomatic: 2,
1677            hotspot_count: Some(0),
1678            maintainability_avg: Some(90.0),
1679            unused_dep_count: Some(0),
1680            circular_dep_count: Some(0),
1681            ..Default::default()
1682        };
1683        let score = compute_health_score(&vs, 100);
1684        assert!((score.score - 100.0).abs() < f64::EPSILON);
1685        assert_eq!(score.grade, "A");
1686    }
1687
1688    #[test]
1689    fn health_score_no_optional_metrics() {
1690        let vs = VitalSigns {
1691            avg_cyclomatic: 1.0,
1692            p90_cyclomatic: 2,
1693            ..Default::default()
1694        };
1695        let score = compute_health_score(&vs, 0);
1696        assert!((score.score - 100.0).abs() < f64::EPSILON);
1697        assert_eq!(score.grade, "A");
1698        assert!(score.penalties.dead_files.is_none());
1699        assert!(score.penalties.unused_deps.is_none());
1700        assert!(score.penalties.duplication.is_none());
1701    }
1702
1703    #[test]
1704    fn health_score_dead_code_penalty() {
1705        let vs = VitalSigns {
1706            dead_file_pct: Some(50.0),
1707            dead_export_pct: Some(30.0),
1708            avg_cyclomatic: 1.0,
1709            p90_cyclomatic: 2,
1710            ..Default::default()
1711        };
1712        let score = compute_health_score(&vs, 100);
1713        assert!((score.score - 84.0).abs() < 0.1);
1714        assert_eq!(score.grade, "B");
1715    }
1716
1717    #[test]
1718    fn health_score_complexity_penalty() {
1719        let vs = VitalSigns {
1720            avg_cyclomatic: 5.5,
1721            p90_cyclomatic: 15,
1722            ..Default::default()
1723        };
1724        let score = compute_health_score(&vs, 100);
1725        assert!((score.score - 75.0).abs() < 0.1);
1726        assert_eq!(score.grade, "B");
1727    }
1728
1729    #[test]
1730    fn health_score_prop_drilling_penalty_opt_in() {
1731        let base = || VitalSigns {
1732            avg_cyclomatic: 1.0,
1733            p90_cyclomatic: 2,
1734            ..Default::default()
1735        };
1736        let base_score = compute_health_score(&base(), 100).score;
1737
1738        // Each located chain costs 1pt (depth is descriptive, not a multiplier).
1739        let three = VitalSigns {
1740            prop_drilling_chain_count: Some(3),
1741            prop_drilling_max_depth: Some(5),
1742            ..base()
1743        };
1744        assert!((base_score - compute_health_score(&three, 100).score - 3.0).abs() < 0.1);
1745
1746        // Capped at 5pt regardless of chain count.
1747        let many = VitalSigns {
1748            prop_drilling_chain_count: Some(20),
1749            ..base()
1750        };
1751        assert!((base_score - compute_health_score(&many, 100).score - 5.0).abs() < 0.1);
1752
1753        // Dormant by default: the opt-in rule is off, so the count is `None` and
1754        // the score is unchanged.
1755        let off = VitalSigns {
1756            prop_drilling_chain_count: None,
1757            ..base()
1758        };
1759        assert!((base_score - compute_health_score(&off, 100).score).abs() < f64::EPSILON);
1760    }
1761
1762    #[test]
1763    fn health_score_clamped_at_zero() {
1764        let vs = VitalSigns {
1765            dead_file_pct: Some(100.0),
1766            dead_export_pct: Some(100.0),
1767            avg_cyclomatic: 10.0,
1768            p90_cyclomatic: 30,
1769            hotspot_count: Some(50),
1770            maintainability_avg: Some(20.0),
1771            unused_dep_count: Some(100),
1772            circular_dep_count: Some(50),
1773            ..Default::default()
1774        };
1775        let score = compute_health_score(&vs, 100);
1776        assert!((score.score).abs() < f64::EPSILON);
1777        assert_eq!(score.grade, "F");
1778    }
1779
1780    #[test]
1781    fn health_score_hotspot_normalized_by_files() {
1782        let vs = VitalSigns {
1783            avg_cyclomatic: 1.0,
1784            p90_cyclomatic: 2,
1785            hotspot_count: Some(5),
1786            ..Default::default()
1787        };
1788        let score_100 = compute_health_score(&vs, 100);
1789        let score_1000 = compute_health_score(&vs, 1000);
1790        assert!(score_1000.score > score_100.score);
1791    }
1792
1793    #[test]
1794    fn health_score_thresholded_hotspots_can_use_full_budget() {
1795        let vs = VitalSigns {
1796            avg_cyclomatic: 1.0,
1797            p90_cyclomatic: 2,
1798            hotspot_count: Some(250),
1799            hotspot_top_pct_count: Some(250),
1800            ..Default::default()
1801        };
1802
1803        let score = compute_health_score(&vs, 25_000);
1804
1805        assert_some_close(score.penalties.hotspots, 10.0);
1806        assert_close(score.score, 90.0);
1807    }
1808
1809    #[test]
1810    fn health_score_duplication_penalty() {
1811        let vs = VitalSigns {
1812            cyclomatic_population: None,
1813            dead_file_pct: None,
1814            dead_export_pct: None,
1815            avg_cyclomatic: 1.0,
1816            critical_complexity_pct: None,
1817            p90_cyclomatic: 2,
1818            duplication_pct: Some(10.0), // 10% - 5% = 5 points
1819            hotspot_count: None,
1820            hotspot_top_pct_count: None,
1821            maintainability_avg: None,
1822            maintainability_low_pct: None,
1823            unused_dep_count: None,
1824            unused_deps_per_k_files: None,
1825            circular_dep_count: None,
1826            circular_deps_per_k_files: None,
1827            counts: None,
1828            unit_size_profile: None,
1829            functions_over_60_loc_per_k: None,
1830            unit_interfacing_profile: None,
1831            p95_fan_in: None,
1832            coupling_high_pct: None,
1833            prop_drilling_chain_count: None,
1834            prop_drilling_max_depth: None,
1835            p95_render_fan_in: None,
1836            render_fan_in_high_pct: None,
1837            max_render_fan_in: None,
1838            top_render_fan_in: Vec::new(),
1839            total_loc: 0,
1840        };
1841        let score = compute_health_score(&vs, 100);
1842        assert_eq!(score.penalties.duplication, Some(5.0));
1843
1844        let vs_low = VitalSigns {
1845            duplication_pct: Some(4.0),
1846            ..vs.clone()
1847        };
1848        let score_low = compute_health_score(&vs_low, 100);
1849        assert_eq!(score_low.penalties.duplication, Some(0.0));
1850
1851        let vs_high = VitalSigns {
1852            duplication_pct: Some(20.0),
1853            ..vs
1854        };
1855        let score_high = compute_health_score(&vs_high, 100);
1856        assert_eq!(score_high.penalties.duplication, Some(10.0));
1857    }
1858
1859    #[test]
1860    fn health_score_uses_scale_invariant_monorepo_signals() {
1861        let vs = VitalSigns {
1862            dead_file_pct: Some(4.0),
1863            dead_export_pct: Some(9.0),
1864            avg_cyclomatic: 2.3,
1865            critical_complexity_pct: Some(2.3),
1866            p90_cyclomatic: 4,
1867            duplication_pct: Some(6.0),
1868            hotspot_count: Some(250),
1869            hotspot_top_pct_count: Some(250),
1870            maintainability_avg: Some(91.0),
1871            maintainability_low_pct: Some(8.0),
1872            unused_dep_count: Some(180),
1873            unused_deps_per_k_files: Some(7.2),
1874            circular_dep_count: Some(450),
1875            circular_deps_per_k_files: Some(18.0),
1876            unit_size_profile: Some(RiskProfile {
1877                low_risk: 80.0,
1878                medium_risk: 12.7,
1879                high_risk: 5.0,
1880                very_high_risk: 2.3,
1881            }),
1882            functions_over_60_loc_per_k: Some(23.0),
1883            p95_fan_in: Some(7),
1884            coupling_high_pct: Some(4.0),
1885            ..Default::default()
1886        };
1887        let score = compute_health_score(&vs, 25_000);
1888        let penalties = &score.penalties;
1889
1890        assert_some_close(penalties.dead_files, 0.8);
1891        assert_some_close(penalties.dead_exports, 1.8);
1892        assert_close(penalties.complexity, 9.2);
1893        assert!((penalties.p90_complexity).abs() < f64::EPSILON);
1894        assert_some_close(penalties.maintainability, 12.0);
1895        assert_some_close(penalties.hotspots, 10.0);
1896        assert_some_close(penalties.unused_deps, 3.6);
1897        assert_some_close(penalties.circular_deps, 9.0);
1898        assert_some_close(penalties.unit_size, 10.0);
1899        assert_some_close(penalties.coupling, 2.0);
1900        assert_some_close(penalties.duplication, 1.0);
1901        assert_close(score.score, 40.6);
1902        assert_eq!(score.grade, "D");
1903    }
1904
1905    #[test]
1906    fn load_snapshots_empty_dir() {
1907        let dir = tempfile::tempdir().unwrap();
1908        let snaps = load_snapshots(dir.path());
1909        assert!(snaps.is_empty());
1910    }
1911
1912    #[test]
1913    fn load_snapshots_returns_sorted() {
1914        let dir = tempfile::tempdir().unwrap();
1915        let root = dir.path();
1916        let snap_dir = root.join(".fallow/snapshots");
1917        std::fs::create_dir_all(&snap_dir).unwrap();
1918
1919        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1920        let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(78.0));
1921
1922        std::fs::write(
1923            snap_dir.join("2026-03-01T00-00-00Z.json"),
1924            serde_json::to_string(&newer).unwrap(),
1925        )
1926        .unwrap();
1927        std::fs::write(
1928            snap_dir.join("2026-01-01T00-00-00Z.json"),
1929            serde_json::to_string(&older).unwrap(),
1930        )
1931        .unwrap();
1932
1933        let loaded = load_snapshots(root);
1934        assert_eq!(loaded.len(), 2);
1935        assert_eq!(loaded[0].timestamp, "2026-01-01T00:00:00Z");
1936        assert_eq!(loaded[1].timestamp, "2026-03-01T00:00:00Z");
1937    }
1938
1939    #[test]
1940    fn load_snapshots_skips_corrupt_files() {
1941        let dir = tempfile::tempdir().unwrap();
1942        let root = dir.path();
1943        let snap_dir = root.join(".fallow/snapshots");
1944        std::fs::create_dir_all(&snap_dir).unwrap();
1945
1946        std::fs::write(snap_dir.join("corrupt.json"), "not valid json").unwrap();
1947        let good = make_test_snapshot("2026-02-01T00:00:00Z", Some(80.0));
1948        std::fs::write(
1949            snap_dir.join("good.json"),
1950            serde_json::to_string(&good).unwrap(),
1951        )
1952        .unwrap();
1953
1954        let loaded = load_snapshots(root);
1955        assert_eq!(loaded.len(), 1);
1956        assert_eq!(loaded[0].timestamp, "2026-02-01T00:00:00Z");
1957    }
1958
1959    #[test]
1960    fn load_snapshots_ignores_non_json() {
1961        let dir = tempfile::tempdir().unwrap();
1962        let root = dir.path();
1963        let snap_dir = root.join(".fallow/snapshots");
1964        std::fs::create_dir_all(&snap_dir).unwrap();
1965
1966        std::fs::write(snap_dir.join("readme.txt"), "not a snapshot").unwrap();
1967
1968        let loaded = load_snapshots(root);
1969        assert!(loaded.is_empty());
1970    }
1971
1972    #[test]
1973    fn hotspot_penalty_respects_score_threshold_and_scoped_population() {
1974        for (scores, total_files, expected) in [
1975            (vec![49.9, 0.1], 563, 0.0),
1976            (vec![50.0, 49.9, 0.1], 563, 1.7),
1977            (vec![50.0; 6], 563, 10.0),
1978            (vec![50.0; 7], 563, 10.0),
1979            (vec![50.0], 1, 10.0),
1980            (vec![50.0], 101, 5.0),
1981            (vec![50.0], 0, 0.0),
1982            (vec![], 563, 0.0),
1983        ] {
1984            let hotspots: Vec<HotspotEntry> = scores
1985                .into_iter()
1986                .enumerate()
1987                .map(|(index, score)| HotspotEntry {
1988                    path: PathBuf::from(format!("src/scoped-{index}.ts")),
1989                    score,
1990                    commits: 3,
1991                    weighted_commits: 3.0,
1992                    lines_added: 10,
1993                    lines_deleted: 5,
1994                    complexity_density: 0.1,
1995                    fan_in: 0,
1996                    trend: crate::churn::ChurnTrend::Stable,
1997                    median_files_per_commit: None,
1998                    median_lines_per_commit: None,
1999                    ownership: None,
2000                    is_test_path: false,
2001                })
2002                .collect();
2003            let input = VitalSignsInput {
2004                modules: &[],
2005                module_filter: None,
2006                file_scores: None,
2007                hotspots: Some(&hotspots),
2008                total_files,
2009                analysis_counts: None,
2010            };
2011            let vitals = compute_vital_signs(&input);
2012            let score = compute_health_score(&vitals, total_files);
2013            assert_some_close(score.penalties.hotspots, expected);
2014            assert_eq!(score.formula_version, HEALTH_SCORE_FORMULA_VERSION);
2015        }
2016    }
2017
2018    #[test]
2019    fn hotspot_penalty_requires_thresholded_measurement_and_ignores_rank_diagnostic() {
2020        let mut vitals = VitalSigns {
2021            hotspot_top_pct_count: Some(6),
2022            ..Default::default()
2023        };
2024        assert!(
2025            compute_health_score(&vitals, 563)
2026                .penalties
2027                .hotspots
2028                .is_none()
2029        );
2030
2031        vitals.hotspot_count = Some(1);
2032        for diagnostic in [None, Some(0), Some(6)] {
2033            vitals.hotspot_top_pct_count = diagnostic;
2034            assert_some_close(compute_health_score(&vitals, 563).penalties.hotspots, 1.7);
2035        }
2036    }
2037
2038    #[test]
2039    fn snapshot_persists_the_computed_score_formula() {
2040        let dir = tempfile::tempdir().unwrap();
2041        let vitals = make_test_vital_signs();
2042        let counts = make_test_counts();
2043        let mut health = compute_health_score(&vitals, counts.total_files);
2044        health.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2045        let snapshot = build_snapshot(
2046            vitals,
2047            counts,
2048            dir.path(),
2049            false,
2050            Some(&health),
2051            None,
2052            fallow_types::semantic::SemanticAnalysisIdentity::default(),
2053        );
2054        assert_eq!(snapshot.score_formula_version, Some(health.formula_version));
2055        assert_eq!(snapshot.score, Some(health.score));
2056        assert_eq!(snapshot.grade.as_deref(), Some(health.grade));
2057
2058        let unscored = build_snapshot(
2059            snapshot.vital_signs,
2060            snapshot.counts,
2061            dir.path(),
2062            false,
2063            None,
2064            None,
2065            fallow_types::semantic::SemanticAnalysisIdentity::default(),
2066        );
2067        assert!(unscored.score_formula_version.is_none());
2068    }
2069
2070    #[test]
2071    fn trend_requires_both_scores_even_with_matching_formula_identity() {
2072        let current_vs = make_test_vital_signs();
2073        let counts = make_test_counts();
2074        let health = compute_health_score(&current_vs, counts.total_files);
2075        for (previous_score, current_score) in [(None, Some(&health)), (Some(72.0), None)] {
2076            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", previous_score);
2077            previous.score_formula_version = Some(health.formula_version);
2078            let trend = compute_trend_against(&previous, 1, &current_vs, &counts, current_score);
2079            assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2080            assert!(
2081                trend
2082                    .metrics
2083                    .iter()
2084                    .any(|metric| metric.name == "avg_cyclomatic")
2085            );
2086            assert!(
2087                fallow_output::health_score_comparison_note(
2088                    previous.score_formula_version,
2089                    Some(health.formula_version),
2090                )
2091                .is_none()
2092            );
2093        }
2094    }
2095
2096    #[test]
2097    fn trend_compares_only_known_matching_score_formulas() {
2098        let current_vs = make_test_vital_signs();
2099        let counts = make_test_counts();
2100        let current_score = compute_health_score(&current_vs, counts.total_files);
2101        for previous_formula in [
2102            None,
2103            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2104            Some(HEALTH_SCORE_FORMULA_VERSION),
2105            Some(HEALTH_SCORE_FORMULA_VERSION + 1),
2106        ] {
2107            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2108            previous.score_formula_version = previous_formula;
2109            let trend =
2110                compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2111            assert_eq!(
2112                trend.metrics.iter().any(|metric| metric.name == "score"),
2113                previous_formula == Some(current_score.formula_version),
2114                "previous formula: {previous_formula:?}"
2115            );
2116            assert!(
2117                trend
2118                    .metrics
2119                    .iter()
2120                    .any(|metric| metric.name == "avg_cyclomatic")
2121            );
2122            assert_eq!(trend.compared_to.score, previous.score);
2123            assert_eq!(trend.compared_to.grade, previous.grade);
2124            assert_eq!(trend.compared_to.score_formula_version, previous_formula);
2125        }
2126    }
2127
2128    #[test]
2129    fn trend_uses_actual_current_formula_and_keeps_raw_direction_on_mismatch() {
2130        let current_vs = make_test_vital_signs();
2131        let counts = make_test_counts();
2132        let mut current_score = compute_health_score(&current_vs, counts.total_files);
2133        current_score.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2134        current_score.score = 90.0;
2135        let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2136        previous.vital_signs = current_vs.clone();
2137        previous.counts = counts.clone();
2138        previous.vital_signs.avg_cyclomatic = current_vs.avg_cyclomatic - 2.0;
2139
2140        let incompatible =
2141            compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2142        assert!(
2143            !incompatible
2144                .metrics
2145                .iter()
2146                .any(|metric| metric.name == "score")
2147        );
2148        assert_eq!(incompatible.overall_direction, TrendDirection::Declining);
2149
2150        previous.score_formula_version = Some(current_score.formula_version);
2151        let compatible =
2152            compute_trend_against(&previous, 1, &current_vs, &counts, Some(&current_score));
2153        assert!(
2154            compatible
2155                .metrics
2156                .iter()
2157                .any(|metric| metric.name == "score")
2158        );
2159    }
2160
2161    #[test]
2162    fn legacy_snapshot_retains_unknown_formula_without_tool_version_inference() {
2163        let mut value =
2164            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2165        value["snapshot_schema_version"] = serde_json::json!(11);
2166        value["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
2167        value
2168            .as_object_mut()
2169            .unwrap()
2170            .remove("score_formula_version");
2171        let previous: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2172        assert!(previous.score_formula_version.is_none());
2173        let current_vs = make_test_vital_signs();
2174        let counts = make_test_counts();
2175        let health = compute_health_score(&current_vs, counts.total_files);
2176        let trend = compute_trend_against(&previous, 1, &current_vs, &counts, Some(&health));
2177        assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2178        assert!(!trend.metrics.is_empty());
2179    }
2180
2181    #[test]
2182    fn group_trend_uses_snapshot_level_formula_identity() {
2183        let current_vs = make_test_vital_signs();
2184        let counts = make_test_counts();
2185        let health = compute_health_score(&current_vs, counts.total_files);
2186        let group = group_snapshot("@team/a", 70.0);
2187        for formula in [
2188            None,
2189            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2190            Some(health.formula_version),
2191        ] {
2192            let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2193            previous.score_formula_version = formula;
2194            let trend =
2195                compute_group_trend(&previous, 1, &group, &current_vs, &counts, Some(&health));
2196            assert_eq!(
2197                trend.metrics.iter().any(|metric| metric.name == "score"),
2198                formula == Some(health.formula_version),
2199            );
2200            assert_eq!(trend.compared_to.score, group.score);
2201            assert_eq!(trend.compared_to.grade, group.grade);
2202            assert_eq!(trend.compared_to.score_formula_version, formula);
2203            assert!(!trend.metrics.is_empty());
2204        }
2205    }
2206
2207    #[test]
2208    fn compute_trend_no_snapshots() {
2209        let vs = make_test_vital_signs();
2210        let counts = make_test_counts();
2211        assert!(compute_trend(&vs, &counts, Some(78.0), &[]).is_none());
2212    }
2213
2214    #[test]
2215    fn compute_trend_improving() {
2216        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2217        let vs = VitalSigns {
2218            dead_file_pct: Some(2.8),
2219            dead_export_pct: Some(7.5),
2220            avg_cyclomatic: 4.1,
2221            p90_cyclomatic: 12,
2222            hotspot_count: Some(3),
2223            maintainability_avg: Some(75.0),
2224            unused_dep_count: Some(3),
2225            circular_dep_count: Some(1),
2226            ..Default::default()
2227        };
2228        let counts = VitalSignsCounts {
2229            total_files: 100,
2230            total_exports: 500,
2231            dead_files: 3,
2232            dead_exports: 38,
2233            files_scored: Some(95),
2234            total_deps: 40,
2235            ..Default::default()
2236        };
2237
2238        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2239        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2240        assert_eq!(trend.snapshots_loaded, 1);
2241        assert_eq!(trend.overall_direction, TrendDirection::Improving);
2242
2243        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2244        assert_eq!(score_metric.direction, TrendDirection::Improving);
2245        assert!((score_metric.delta - 6.0).abs() < f64::EPSILON);
2246    }
2247
2248    #[test]
2249    fn compute_trend_stable_within_tolerance() {
2250        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(78.0));
2251        let vs = make_test_vital_signs();
2252        let counts = make_test_counts();
2253
2254        let trend = compute_trend(&vs, &counts, Some(78.3), &[prev]).unwrap();
2255        let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2256        assert_eq!(score_metric.direction, TrendDirection::Stable);
2257    }
2258
2259    #[test]
2260    fn compute_trend_uses_most_recent_snapshot() {
2261        let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0));
2262        let vs = make_test_vital_signs();
2263        let counts = make_test_counts();
2264
2265        for newest_formula in [
2266            None,
2267            Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2268            Some(HEALTH_SCORE_FORMULA_VERSION),
2269        ] {
2270            let mut newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(72.0));
2271            newer.score_formula_version = newest_formula;
2272            let trend = compute_trend(&vs, &counts, Some(78.0), &[older.clone(), newer]).unwrap();
2273            assert_eq!(trend.compared_to.score, Some(72.0));
2274            assert_eq!(trend.snapshots_loaded, 2);
2275            assert_eq!(
2276                trend.metrics.iter().any(|metric| metric.name == "score"),
2277                newest_formula == Some(HEALTH_SCORE_FORMULA_VERSION)
2278            );
2279        }
2280    }
2281
2282    #[test]
2283    fn compute_trend_includes_raw_counts() {
2284        let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2285        let vs = make_test_vital_signs();
2286        let counts = make_test_counts();
2287
2288        let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2289        let dead_files = trend
2290            .metrics
2291            .iter()
2292            .find(|m| m.name == "dead_file_pct")
2293            .unwrap();
2294        assert!(dead_files.previous_count.is_some());
2295        assert!(dead_files.current_count.is_some());
2296    }
2297
2298    fn make_test_vital_signs() -> VitalSigns {
2299        VitalSigns {
2300            dead_file_pct: Some(3.2),
2301            dead_export_pct: Some(8.1),
2302            avg_cyclomatic: 4.2,
2303            p90_cyclomatic: 12,
2304            hotspot_count: Some(5),
2305            maintainability_avg: Some(72.4),
2306            unused_dep_count: Some(4),
2307            circular_dep_count: Some(2),
2308            ..Default::default()
2309        }
2310    }
2311
2312    fn make_test_counts() -> VitalSignsCounts {
2313        VitalSignsCounts {
2314            total_files: 100,
2315            total_exports: 500,
2316            dead_files: 3,
2317            dead_exports: 40,
2318            files_scored: Some(95),
2319            total_deps: 42,
2320            ..Default::default()
2321        }
2322    }
2323
2324    fn make_test_snapshot(timestamp: &str, score: Option<f64>) -> VitalSignsSnapshot {
2325        VitalSignsSnapshot {
2326            groups: None,
2327            snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
2328            version: "2.5.5".into(),
2329            timestamp: timestamp.into(),
2330            git_sha: Some("abc1234".into()),
2331            git_branch: Some("main".into()),
2332            shallow_clone: false,
2333            vital_signs: VitalSigns {
2334                dead_file_pct: Some(3.2),
2335                dead_export_pct: Some(8.1),
2336                avg_cyclomatic: 4.7,
2337                p90_cyclomatic: 12,
2338                hotspot_count: Some(5),
2339                maintainability_avg: Some(72.4),
2340                unused_dep_count: Some(4),
2341                circular_dep_count: Some(2),
2342                ..Default::default()
2343            },
2344            counts: VitalSignsCounts {
2345                total_files: 100,
2346                total_exports: 500,
2347                dead_files: 3,
2348                dead_exports: 40,
2349                files_scored: Some(95),
2350                total_deps: 42,
2351                ..Default::default()
2352            },
2353            score,
2354            grade: score.map(|s| letter_grade(s).to_string()),
2355            score_formula_version: score.map(|_| HEALTH_SCORE_FORMULA_VERSION),
2356            coverage_model: None,
2357            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2358        }
2359    }
2360
2361    fn group_snapshot(key: &str, score: f64) -> fallow_output::GroupSnapshot {
2362        fallow_output::GroupSnapshot {
2363            key: key.to_owned(),
2364            files_analyzed: 10,
2365            vital_signs: VitalSigns {
2366                avg_cyclomatic: 3.0,
2367                p90_cyclomatic: 8,
2368                ..Default::default()
2369            },
2370            counts: VitalSignsCounts {
2371                total_files: 10,
2372                ..Default::default()
2373            },
2374            score: Some(score),
2375            grade: Some(letter_grade(score).to_string()),
2376            severity_critical_count: 2,
2377            hotspot_count: 1,
2378        }
2379    }
2380
2381    #[test]
2382    fn v10_snapshot_without_groups_still_deserializes() {
2383        let mut value =
2384            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2385        value["snapshot_schema_version"] = serde_json::json!(10);
2386        value.as_object_mut().unwrap().remove("groups");
2387        let snapshot: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2388        assert_eq!(snapshot.snapshot_schema_version, 10);
2389        assert!(snapshot.groups.is_none());
2390    }
2391
2392    #[test]
2393    fn v11_snapshot_round_trips_group_data() {
2394        let mut snapshot = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2395        snapshot.groups = Some(fallow_output::SnapshotGrouping {
2396            grouped_by: "owner".to_owned(),
2397            group_filter: Some(vec!["@team/*".to_owned()]),
2398            groups: vec![group_snapshot("@team/a", 81.5)],
2399        });
2400        let json = serde_json::to_string(&snapshot).unwrap();
2401        let loaded: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
2402        let groups = loaded.groups.expect("groups survive the round trip");
2403        assert_eq!(groups.grouped_by, "owner");
2404        assert_eq!(groups.group_filter, Some(vec!["@team/*".to_owned()]));
2405        assert_eq!(groups.groups[0].key, "@team/a");
2406        assert_eq!(groups.groups[0].score, Some(81.5));
2407        assert_eq!(groups.groups[0].severity_critical_count, 2);
2408        let ungrouped =
2409            serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", None)).unwrap();
2410        assert!(
2411            ungrouped.get("groups").is_none(),
2412            "ungrouped snapshot omits groups"
2413        );
2414    }
2415
2416    #[test]
2417    fn explicit_trend_baseline_reads_that_file_only() {
2418        let dir = tempfile::tempdir().unwrap();
2419        let root = dir.path();
2420        let snap_dir = root.join(".fallow/snapshots");
2421        std::fs::create_dir_all(&snap_dir).unwrap();
2422        std::fs::write(
2423            snap_dir.join("newer.json"),
2424            serde_json::to_string(&make_test_snapshot("2026-09-01T00:00:00Z", Some(90.0))).unwrap(),
2425        )
2426        .unwrap();
2427        let explicit = root.join("restored.json");
2428        std::fs::write(
2429            &explicit,
2430            serde_json::to_string(&make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0))).unwrap(),
2431        )
2432        .unwrap();
2433
2434        let baseline = load_trend_baseline(root, Some(&explicit))
2435            .unwrap()
2436            .expect("explicit baseline");
2437        assert_eq!(baseline.snapshot.timestamp, "2026-01-01T00:00:00Z");
2438        assert_eq!(baseline.snapshots_loaded, 1);
2439        assert_eq!(baseline.path, explicit);
2440
2441        let directory = load_trend_baseline(root, None).unwrap().expect("newest");
2442        assert_eq!(directory.snapshot.timestamp, "2026-09-01T00:00:00Z");
2443        assert_eq!(directory.snapshots_loaded, 1);
2444    }
2445
2446    #[test]
2447    fn explicit_trend_baseline_fails_on_missing_or_invalid_file() {
2448        let dir = tempfile::tempdir().unwrap();
2449        let root = dir.path();
2450        let missing = load_trend_baseline(root, Some(&root.join("absent.json"))).unwrap_err();
2451        assert!(
2452            missing.contains("failed to read --trend-from snapshot"),
2453            "{missing}"
2454        );
2455        let invalid_path = root.join("invalid.json");
2456        std::fs::write(&invalid_path, "{\"not\": \"a snapshot\"}").unwrap();
2457        let invalid = load_trend_baseline(root, Some(&invalid_path)).unwrap_err();
2458        assert!(
2459            invalid.contains("is not a fallow health snapshot"),
2460            "{invalid}"
2461        );
2462        assert!(load_trend_baseline(root, None).unwrap().is_none());
2463    }
2464
2465    #[test]
2466    fn explicit_trend_baseline_error_does_not_echo_file_content() {
2467        let dir = tempfile::tempdir().unwrap();
2468        let root = dir.path();
2469        for (index, content) in [r#"{"timestamp": "private-value"}"#, r#"["private-value"]"#]
2470            .into_iter()
2471            .enumerate()
2472        {
2473            let path = root.join(format!("leak-{index}.json"));
2474            std::fs::write(&path, content).unwrap();
2475            let error = load_trend_baseline(root, Some(&path)).unwrap_err();
2476            assert!(error.contains("is not a fallow health snapshot"), "{error}");
2477            assert!(error.contains("line 1, column"), "{error}");
2478            assert!(!error.contains("private-value"), "{error}");
2479        }
2480    }
2481
2482    #[test]
2483    fn group_trend_compares_against_the_stored_group() {
2484        let baseline = TrendBaseline {
2485            snapshot: make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0)),
2486            path: PathBuf::from("baseline.json"),
2487            snapshots_loaded: 1,
2488        };
2489        let current = VitalSigns {
2490            avg_cyclomatic: 3.0,
2491            p90_cyclomatic: 8,
2492            ..Default::default()
2493        };
2494        let counts = VitalSignsCounts {
2495            total_files: 10,
2496            ..Default::default()
2497        };
2498        let current_score = HealthScore {
2499            score: 84.0,
2500            ..compute_health_score(&current, counts.total_files)
2501        };
2502        let trend = compute_group_trend(
2503            &baseline.without_groups(),
2504            baseline.snapshots_loaded,
2505            &group_snapshot("@team/a", 80.0),
2506            &current,
2507            &counts,
2508            Some(&current_score),
2509        );
2510        let score = trend
2511            .metrics
2512            .iter()
2513            .find(|metric| metric.name == "score")
2514            .expect("score metric");
2515        assert!((score.previous - 80.0).abs() < f64::EPSILON);
2516        assert!((score.delta - 4.0).abs() < 1e-9);
2517        assert_eq!(score.direction, TrendDirection::Improving);
2518        assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2519        assert_eq!(trend.compared_to.score, Some(80.0));
2520    }
2521}