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