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