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