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