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