1use std::path::{Path, PathBuf};
8
9const SECS_PER_DAY: u64 = 86_400;
11
12const HOTSPOT_TOP_FILE_FRACTION: f64 = 0.01;
14
15const HOTSPOT_MAX_PENALTY: f64 = 10.0;
17
18use fallow_output::{
19 DEFAULT_CYCLOMATIC_CRITICAL, FileHealthScore, HEALTH_SCORE_FORMULA_VERSION,
20 HOTSPOT_SCORE_THRESHOLD, HealthScore, HealthScorePenalties, HealthTrend, HotspotEntry,
21 RiskProfile, SNAPSHOT_SCHEMA_VERSION, TrendCount, TrendDirection, TrendMetric, TrendPoint,
22 VitalSigns, VitalSignsCounts, VitalSignsSnapshot, letter_grade,
23};
24
25pub(crate) struct VitalSignsInput<'a> {
29 pub(crate) modules: &'a [crate::source::ModuleInfo],
31 pub(crate) module_filter: Option<&'a rustc_hash::FxHashSet<crate::discover::FileId>>,
37 pub(crate) file_scores: Option<&'a [FileHealthScore]>,
39 pub(crate) hotspots: Option<&'a [HotspotEntry]>,
41 pub(crate) total_files: usize,
43 pub(crate) analysis_counts: Option<AnalysisCounts>,
48}
49
50impl<'a> VitalSignsInput<'a> {
51 fn selected_modules(&self) -> impl Iterator<Item = &'a crate::source::ModuleInfo> + '_ {
53 let filter = self.module_filter;
54 self.modules
55 .iter()
56 .filter(move |m| filter.is_none_or(|set| set.contains(&m.file_id)))
57 }
58}
59
60#[derive(Clone, Copy)]
62pub struct AnalysisCounts {
63 pub(crate) total_exports: usize,
64 pub(crate) dead_files: usize,
65 pub(crate) dead_exports: usize,
66 pub(crate) unused_deps: usize,
67 pub(crate) circular_deps: usize,
68 pub(crate) total_deps: usize,
69}
70
71fn collect_sorted_cyclomatic(input: &VitalSignsInput<'_>) -> Vec<u16> {
76 let mut values: Vec<u16> = input
77 .selected_modules()
78 .flat_map(|m| m.complexity.iter().map(|c| c.cyclomatic))
79 .collect();
80 values.sort_unstable();
81 values
82}
83
84fn average_cyclomatic(all_cyclomatic: &[u16]) -> f64 {
85 if all_cyclomatic.is_empty() {
86 return 0.0;
87 }
88
89 let sum: u64 = all_cyclomatic.iter().map(|&c| u64::from(c)).sum();
90 (sum as f64 / all_cyclomatic.len() as f64 * 10.0).round() / 10.0
91}
92
93fn critical_complexity_pct(all_cyclomatic: &[u16]) -> Option<f64> {
94 if all_cyclomatic.is_empty() {
95 return None;
96 }
97
98 let critical_count = all_cyclomatic
99 .iter()
100 .filter(|&&c| c >= DEFAULT_CYCLOMATIC_CRITICAL)
101 .count();
102 Some((critical_count as f64 / all_cyclomatic.len() as f64 * 1000.0).round() / 10.0)
103}
104
105#[expect(
106 clippy::cast_sign_loss,
107 reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
108)]
109#[expect(
110 clippy::cast_possible_truncation,
111 reason = "percentile index is bounded by the cyclomatic collection length"
112)]
113fn p90_cyclomatic(all_cyclomatic: &[u16]) -> u32 {
114 if all_cyclomatic.is_empty() {
115 return 0;
116 }
117
118 let idx = (all_cyclomatic.len() as f64 * 0.9).ceil() as usize;
119 let idx = idx.min(all_cyclomatic.len()) - 1;
120 u32::from(all_cyclomatic[idx])
121}
122
123#[expect(
124 clippy::cast_possible_truncation,
125 reason = "analysis counts are bounded by project size and emitted as compact u32 metrics"
126)]
127fn analysis_count_vitals(
128 counts: Option<&AnalysisCounts>,
129 total_files: usize,
130) -> (Option<f64>, Option<f64>, Option<u32>, Option<u32>) {
131 let Some(counts) = counts else {
132 return (None, None, None, None);
133 };
134
135 let dead_file_pct = if total_files > 0 {
136 Some((counts.dead_files as f64 / total_files as f64 * 1000.0).round() / 10.0)
137 } else {
138 Some(0.0)
139 };
140 let dead_export_pct = if counts.total_exports > 0 {
141 Some((counts.dead_exports as f64 / counts.total_exports as f64 * 1000.0).round() / 10.0)
142 } else {
143 Some(0.0)
144 };
145
146 (
147 dead_file_pct,
148 dead_export_pct,
149 Some(counts.unused_deps as u32),
150 Some(counts.circular_deps as u32),
151 )
152}
153
154struct SelectedModuleMetrics {
155 total_loc: u64,
156 line_counts: Vec<u32>,
157 param_counts: Vec<u8>,
158 cyclomatic_population: fallow_output::CyclomaticPopulation,
159}
160
161fn selected_module_metrics(input: &VitalSignsInput<'_>) -> SelectedModuleMetrics {
162 let mut total_loc = 0;
163 let mut line_counts = Vec::new();
164 let mut param_counts = Vec::new();
165 let mut cyclomatic_population = fallow_output::CyclomaticPopulation::default();
166
167 for module in input.selected_modules() {
168 total_loc += module.line_offsets.len() as u64;
169 for unit in &module.complexity {
174 let is_module = fallow_types::extract::is_synthetic_module_unit(&unit.name);
175 let population = if is_module {
176 &mut cyclomatic_population.modules
177 } else if fallow_types::extract::is_synthetic_template_unit(&unit.name) {
178 &mut cyclomatic_population.templates
179 } else {
180 &mut cyclomatic_population.functions
181 };
182 population.count += 1;
183 population.sum += u64::from(unit.cyclomatic);
184 population.max = Some(population.max.unwrap_or_default().max(unit.cyclomatic));
185 if is_module {
186 continue;
187 }
188 line_counts.push(unit.line_count);
189 param_counts.push(unit.param_count);
190 }
191 }
192
193 SelectedModuleMetrics {
194 total_loc,
195 line_counts,
196 param_counts,
197 cyclomatic_population,
198 }
199}
200
201fn vital_sign_counts(input: &VitalSignsInput<'_>, total_loc: u64) -> Option<VitalSignsCounts> {
202 input.analysis_counts.as_ref().map(|ac| VitalSignsCounts {
203 total_files: input.total_files,
204 total_exports: ac.total_exports,
205 dead_files: ac.dead_files,
206 dead_exports: ac.dead_exports,
207 duplicated_lines: None,
208 total_lines: Some(total_loc as usize),
209 files_scored: input.file_scores.map(<[_]>::len),
210 total_deps: ac.total_deps,
211 })
212}
213
214pub(crate) fn compute_vital_signs(input: &VitalSignsInput<'_>) -> VitalSigns {
216 let all_cyclomatic = collect_sorted_cyclomatic(input);
217 let avg_cyclomatic = average_cyclomatic(&all_cyclomatic);
218 let critical_complexity_pct = critical_complexity_pct(&all_cyclomatic);
219 let p90_cyclomatic = p90_cyclomatic(&all_cyclomatic);
220
221 let (dead_file_pct, dead_export_pct, unused_dep_count, circular_dep_count) =
222 analysis_count_vitals(input.analysis_counts.as_ref(), input.total_files);
223 let unused_deps_per_k_files =
224 unused_dep_count.map(|count| per_k_files(count, input.total_files));
225 let circular_deps_per_k_files =
226 circular_dep_count.map(|count| per_k_files(count, input.total_files));
227
228 let (maintainability_avg, maintainability_low_pct) = maintainability_vitals(input.file_scores);
229
230 let (hotspot_count, hotspot_top_pct_count) = hotspot_vitals(input.hotspots, input.total_files);
231
232 let module_metrics = selected_module_metrics(input);
233 let counts = vital_sign_counts(input, module_metrics.total_loc);
234 let functions_over_60_loc_per_k = functions_over_60_loc_per_k(&module_metrics.line_counts);
235 let unit_size_profile = unit_size_profile(&module_metrics.line_counts);
236
237 let unit_interfacing_profile =
238 unit_interfacing_profile(&module_metrics.param_counts, &all_cyclomatic);
239
240 let (p95_fan_in, coupling_high_pct) = if let Some(scores) = input.file_scores {
241 compute_coupling_concentration(scores)
242 } else {
243 (None, None)
244 };
245
246 VitalSigns {
247 dead_file_pct,
248 dead_export_pct,
249 avg_cyclomatic,
250 critical_complexity_pct,
251 p90_cyclomatic,
252 cyclomatic_population: Some(module_metrics.cyclomatic_population),
253 duplication_pct: None, hotspot_count,
255 hotspot_top_pct_count,
256 maintainability_avg,
257 maintainability_low_pct,
258 unused_dep_count,
259 unused_deps_per_k_files,
260 circular_dep_count,
261 circular_deps_per_k_files,
262 counts,
263 unit_size_profile,
264 functions_over_60_loc_per_k,
265 unit_interfacing_profile,
266 p95_fan_in,
267 coupling_high_pct,
268 prop_drilling_chain_count: None,
271 prop_drilling_max_depth: None,
272 p95_render_fan_in: None,
276 render_fan_in_high_pct: None,
277 max_render_fan_in: None,
278 top_render_fan_in: Vec::new(),
279 total_loc: module_metrics.total_loc,
280 }
281}
282
283fn per_k_files(count: u32, total_files: usize) -> f64 {
284 if total_files == 0 {
285 0.0
286 } else {
287 (f64::from(count) / total_files as f64 * 10_000.0).round() / 10.0
288 }
289}
290
291fn maintainability_vitals(scores: Option<&[FileHealthScore]>) -> (Option<f64>, Option<f64>) {
292 let Some(scores) = scores.filter(|scores| !scores.is_empty()) else {
293 return (None, None);
294 };
295 let sum: f64 = scores.iter().map(|s| s.maintainability_index).sum();
296 let low_count = scores
297 .iter()
298 .filter(|s| s.maintainability_index < 70.0)
299 .count();
300 (
301 Some((sum / scores.len() as f64 * 10.0).round() / 10.0),
302 Some((low_count as f64 / scores.len() as f64 * 1000.0).round() / 10.0),
303 )
304}
305
306#[expect(
307 clippy::cast_sign_loss,
308 reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
309)]
310fn hotspot_vitals(
311 hotspots: Option<&[HotspotEntry]>,
312 total_files: usize,
313) -> (Option<u32>, Option<u32>) {
314 let hotspot_count = hotspots.map(|entries| {
315 entries
316 .iter()
317 .filter(|e| e.score >= HOTSPOT_SCORE_THRESHOLD)
318 .count() as u32
319 });
320 let hotspot_top_pct_count = hotspots.map(|entries| {
321 if total_files == 0 || entries.is_empty() {
322 return 0;
323 }
324 let top_count = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION).ceil() as usize;
325 entries
326 .iter()
327 .take(top_count.max(1))
328 .filter(|entry| entry.score > 0.0)
329 .count() as u32
330 });
331 (hotspot_count, hotspot_top_pct_count)
332}
333
334fn functions_over_60_loc_per_k(line_counts: &[u32]) -> Option<f64> {
335 if line_counts.is_empty() {
336 return None;
337 }
338 let over_60 = line_counts
339 .iter()
340 .filter(|&&line_count| line_count > 60)
341 .count();
342 Some((over_60 as f64 / line_counts.len() as f64 * 10_000.0).round() / 10.0)
343}
344
345fn unit_size_profile(line_counts: &[u32]) -> Option<RiskProfile> {
346 (!line_counts.is_empty()).then(|| compute_size_risk_profile(line_counts))
347}
348
349fn unit_interfacing_profile(param_counts: &[u8], all_cyclomatic: &[u16]) -> Option<RiskProfile> {
350 if all_cyclomatic.is_empty() {
351 return None;
352 }
353 Some(compute_interfacing_risk_profile(param_counts))
354}
355
356fn compute_size_risk_profile(line_counts: &[u32]) -> RiskProfile {
360 if line_counts.is_empty() {
361 return RiskProfile {
362 low_risk: 0.0,
363 medium_risk: 0.0,
364 high_risk: 0.0,
365 very_high_risk: 0.0,
366 };
367 }
368 let total = line_counts.len() as f64;
369 let low = line_counts.iter().filter(|&&lc| lc <= 15).count() as f64;
370 let medium = line_counts
371 .iter()
372 .filter(|&&lc| (16..=30).contains(&lc))
373 .count() as f64;
374 let high = line_counts
375 .iter()
376 .filter(|&&lc| (31..=60).contains(&lc))
377 .count() as f64;
378 let very_high = line_counts.iter().filter(|&&lc| lc > 60).count() as f64;
379 RiskProfile {
380 low_risk: (low / total * 1000.0).round() / 10.0,
381 medium_risk: (medium / total * 1000.0).round() / 10.0,
382 high_risk: (high / total * 1000.0).round() / 10.0,
383 very_high_risk: (very_high / total * 1000.0).round() / 10.0,
384 }
385}
386
387fn compute_interfacing_risk_profile(param_counts: &[u8]) -> RiskProfile {
391 if param_counts.is_empty() {
392 return RiskProfile {
393 low_risk: 0.0,
394 medium_risk: 0.0,
395 high_risk: 0.0,
396 very_high_risk: 0.0,
397 };
398 }
399 let total = param_counts.len() as f64;
400 let low = param_counts.iter().filter(|&&pc| pc <= 2).count() as f64;
401 let medium = param_counts
402 .iter()
403 .filter(|&&pc| (3..=4).contains(&pc))
404 .count() as f64;
405 let high = param_counts
406 .iter()
407 .filter(|&&pc| (5..=6).contains(&pc))
408 .count() as f64;
409 let very_high = param_counts.iter().filter(|&&pc| pc >= 7).count() as f64;
410 RiskProfile {
411 low_risk: (low / total * 1000.0).round() / 10.0,
412 medium_risk: (medium / total * 1000.0).round() / 10.0,
413 high_risk: (high / total * 1000.0).round() / 10.0,
414 very_high_risk: (very_high / total * 1000.0).round() / 10.0,
415 }
416}
417
418#[expect(
431 clippy::cast_sign_loss,
432 reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
433)]
434#[expect(
435 clippy::cast_possible_truncation,
436 reason = "fan-in values are bounded by project size"
437)]
438fn compute_coupling_concentration(scores: &[FileHealthScore]) -> (Option<u32>, Option<f64>) {
439 if scores.is_empty() {
440 return (None, None);
441 }
442 let mut fan_ins: Vec<usize> = scores.iter().map(|s| s.fan_in).collect();
443 fan_ins.sort_unstable();
444 let idx = (fan_ins.len() as f64 * 0.95).ceil() as usize;
445 let idx = idx.min(fan_ins.len()) - 1;
446 let p95 = fan_ins[idx] as u32;
447
448 let threshold = (p95 as usize).max(10);
449 let high_count = fan_ins.iter().filter(|&&fi| fi > threshold).count();
450 let high_pct = (high_count as f64 / fan_ins.len() as f64 * 1000.0).round() / 10.0;
451
452 (Some(p95), Some(high_pct))
453}
454
455pub(crate) fn compute_health_score(vs: &VitalSigns, total_files: usize) -> HealthScore {
461 let penalties = compute_health_score_penalties(vs, total_files);
462 let score = apply_health_score_penalties(&penalties);
463 let grade = letter_grade(score);
464
465 HealthScore {
466 formula_version: HEALTH_SCORE_FORMULA_VERSION,
467 score,
468 grade,
469 penalties,
470 }
471}
472
473fn compute_health_score_penalties(vs: &VitalSigns, total_files: usize) -> HealthScorePenalties {
474 HealthScorePenalties {
475 dead_files: vs.dead_file_pct.map(|pct| round1((pct * 0.2).min(15.0))),
476 dead_exports: vs.dead_export_pct.map(|pct| round1((pct * 0.2).min(15.0))),
477 complexity: complexity_penalty(vs),
478 p90_complexity: p90_complexity_penalty(vs),
479 maintainability: maintainability_penalty(vs),
480 hotspots: hotspot_penalty(vs, total_files),
481 unused_deps: dependency_count_penalty(
482 vs.unused_deps_per_k_files,
483 vs.unused_dep_count,
484 25.0,
485 10.0,
486 ),
487 circular_deps: dependency_count_penalty(
488 vs.circular_deps_per_k_files,
489 vs.circular_dep_count,
490 25.0,
491 10.0,
492 ),
493 unit_size: unit_size_penalty(vs),
494 coupling: coupling_penalty(vs),
495 duplication: vs
496 .duplication_pct
497 .map(|dp| round1((dp - 5.0).clamp(0.0, 10.0))),
498 prop_drilling: prop_drilling_penalty(vs),
499 }
500}
501
502fn apply_health_score_penalties(penalties: &HealthScorePenalties) -> f64 {
503 let mut score = 100.0_f64;
504
505 subtract_optional_penalty(&mut score, penalties.dead_files);
506 subtract_optional_penalty(&mut score, penalties.dead_exports);
507 score -= penalties.complexity;
508 score -= penalties.p90_complexity;
509 subtract_optional_penalty(&mut score, penalties.maintainability);
510 subtract_optional_penalty(&mut score, penalties.hotspots);
511 subtract_optional_penalty(&mut score, penalties.unused_deps);
512 subtract_optional_penalty(&mut score, penalties.circular_deps);
513 subtract_optional_penalty(&mut score, penalties.unit_size);
514 subtract_optional_penalty(&mut score, penalties.coupling);
515 subtract_optional_penalty(&mut score, penalties.duplication);
516 subtract_optional_penalty(&mut score, penalties.prop_drilling);
517
518 round1(score).clamp(0.0, 100.0)
519}
520
521fn prop_drilling_penalty(vs: &VitalSigns) -> Option<f64> {
527 vs.prop_drilling_chain_count
528 .map(|count| round1((f64::from(count) * 1.0).min(5.0)))
529}
530
531fn round1(value: f64) -> f64 {
532 (value * 10.0).round() / 10.0
533}
534
535fn subtract_optional_penalty(score: &mut f64, penalty: Option<f64>) {
536 if let Some(penalty) = penalty {
537 *score -= penalty;
538 }
539}
540
541fn complexity_penalty(vs: &VitalSigns) -> f64 {
542 if let Some(critical_pct) = vs.critical_complexity_pct {
543 round1((critical_pct * 4.0).min(20.0))
544 } else {
545 round1(((vs.avg_cyclomatic - 1.5).max(0.0) * 5.0).min(20.0))
546 }
547}
548
549fn p90_complexity_penalty(vs: &VitalSigns) -> f64 {
550 if vs.critical_complexity_pct.is_some() {
551 0.0
552 } else {
553 round1((f64::from(vs.p90_cyclomatic) - 10.0).clamp(0.0, 10.0))
554 }
555}
556
557fn maintainability_penalty(vs: &VitalSigns) -> Option<f64> {
558 if let Some(low_pct) = vs.maintainability_low_pct {
559 Some(round1((low_pct * 1.5).min(15.0)))
560 } else {
561 vs.maintainability_avg
562 .map(|mi| round1(((70.0 - mi).max(0.0) * 0.5).min(15.0)))
563 }
564}
565
566fn hotspot_penalty(vs: &VitalSigns, total_files: usize) -> Option<f64> {
567 vs.hotspot_count.map(|count| {
568 if total_files == 0 {
569 return 0.0;
570 }
571 let top_pct_bucket = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION)
572 .ceil()
573 .max(1.0);
574 round1((f64::from(count) / top_pct_bucket * HOTSPOT_MAX_PENALTY).min(HOTSPOT_MAX_PENALTY))
575 })
576}
577
578fn dependency_count_penalty(
579 per_k: Option<f64>,
580 count: Option<u32>,
581 per_k_cap: f64,
582 count_cap: f64,
583) -> Option<f64> {
584 if let Some(per_k) = per_k {
585 Some(round1((per_k * 0.5).min(per_k_cap)))
586 } else {
587 count.map(|count| round1(f64::from(count).min(count_cap)))
588 }
589}
590
591fn unit_size_penalty(vs: &VitalSigns) -> Option<f64> {
592 if let Some(per_k) = vs.functions_over_60_loc_per_k {
593 Some(round1((per_k * 0.5).min(10.0)))
594 } else {
595 vs.unit_size_profile
596 .as_ref()
597 .map(|profile| round1(((profile.very_high_risk - 5.0).max(0.0) * 0.5).min(10.0)))
598 }
599}
600
601fn coupling_penalty(vs: &VitalSigns) -> Option<f64> {
602 if let Some(high_pct) = vs.coupling_high_pct {
603 Some(round1((high_pct * 0.5).min(5.0)))
604 } else {
605 vs.p95_fan_in
606 .map(|p95| round1(((f64::from(p95) - 30.0).max(0.0) * 0.25).min(5.0)))
607 }
608}
609
610pub(crate) fn build_counts(input: &VitalSignsInput<'_>) -> VitalSignsCounts {
612 let (total_exports, dead_files, dead_exports, total_deps) = input
613 .analysis_counts
614 .as_ref()
615 .map_or((0, 0, 0, 0), |counts| {
616 (
617 counts.total_exports,
618 counts.dead_files,
619 counts.dead_exports,
620 counts.total_deps,
621 )
622 });
623
624 let total_lines: usize = input.selected_modules().map(|m| m.line_offsets.len()).sum();
625
626 VitalSignsCounts {
627 total_files: input.total_files,
628 total_exports,
629 dead_files,
630 dead_exports,
631 duplicated_lines: None,
632 total_lines: Some(total_lines),
633 files_scored: input.file_scores.map(<[_]>::len),
634 total_deps,
635 }
636}
637
638fn git_branch(root: &Path) -> Option<String> {
640 let mut command = crate::git_env::git_command(root);
641 command.args(["rev-parse", "--abbrev-ref", "HEAD"]);
642 command
643 .output()
644 .ok()
645 .filter(|o| o.status.success())
646 .and_then(|o| {
647 let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
648 if name == "HEAD" { None } else { Some(name) }
649 })
650}
651
652#[expect(
654 clippy::too_many_arguments,
655 reason = "snapshot construction keeps every persisted compatibility input explicit"
656)]
657pub(crate) fn build_snapshot(
658 vital_signs: VitalSigns,
659 counts: VitalSignsCounts,
660 root: &Path,
661 shallow_clone: bool,
662 health_score: Option<&HealthScore>,
663 coverage_model: Option<fallow_output::CoverageModel>,
664 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
665) -> VitalSignsSnapshot {
666 let now = chrono_timestamp();
667
668 VitalSignsSnapshot {
669 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
670 version: env!("CARGO_PKG_VERSION").to_string(),
671 timestamp: now,
672 git_sha: crate::repo_refs::short_head_sha(root),
673 git_branch: git_branch(root),
674 shallow_clone,
675 vital_signs,
676 counts,
677 score: health_score.map(|s| s.score),
678 grade: health_score.map(|s| s.grade.to_string()),
679 score_formula_version: health_score.map(|s| s.formula_version),
680 coverage_model,
681 analysis_identity,
682 groups: None,
683 }
684}
685
686pub fn chrono_timestamp() -> String {
688 use std::time::SystemTime;
689 let now = SystemTime::now()
690 .duration_since(SystemTime::UNIX_EPOCH)
691 .unwrap_or_default();
692 let secs = now.as_secs();
693
694 let days = secs / SECS_PER_DAY;
695 let time_secs = secs % SECS_PER_DAY;
696 let hours = time_secs / 3600;
697 let minutes = (time_secs % 3600) / 60;
698 let seconds = time_secs % 60;
699
700 let (year, month, day) = days_to_ymd(days);
701
702 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
703}
704
705const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
707 let z = days + 719_468;
708 let era = z / 146_097;
709 let doe = z - era * 146_097;
710 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
711 let y = yoe + era * 400;
712 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
713 let mp = (5 * doy + 2) / 153;
714 let d = doy - (153 * mp + 2) / 5 + 1;
715 let m = if mp < 10 { mp + 3 } else { mp - 9 };
716 let y = if m <= 2 { y + 1 } else { y };
717 (y, m, d)
718}
719
720pub(crate) fn save_snapshot(
725 snapshot: &VitalSignsSnapshot,
726 root: &Path,
727 explicit_path: Option<&Path>,
728) -> Result<PathBuf, String> {
729 let path = explicit_path.map_or_else(
730 || {
731 let dir = root.join(".fallow").join("snapshots");
732 let filename = snapshot.timestamp.replace(':', "-");
733 dir.join(format!("{filename}.json"))
734 },
735 Path::to_path_buf,
736 );
737
738 let json =
739 serde_json::to_string_pretty(snapshot).map_err(|e| format!("failed to serialize: {e}"))?;
740 crate::write_guard::write_file(
741 &path,
742 json.as_bytes(),
743 crate::write_guard::WriteTarget::Path,
744 )
745 .map_err(|e| {
746 if e.is_directory() {
747 format!("failed to create snapshot directory: {e}")
748 } else {
749 format!("failed to write snapshot: {e}")
750 }
751 })?;
752
753 Ok(path)
754}
755
756#[cfg(test)]
761pub(crate) fn load_snapshots(root: &Path) -> Vec<VitalSignsSnapshot> {
762 load_snapshots_with_paths(root)
763 .into_iter()
764 .map(|(_, snapshot)| snapshot)
765 .collect()
766}
767
768#[expect(
772 clippy::print_stderr,
773 reason = "corrupt-snapshot warnings to stderr, preserved verbatim from the CLI health path"
774)]
775fn load_snapshots_with_paths(root: &Path) -> Vec<(PathBuf, VitalSignsSnapshot)> {
776 let dir = root.join(".fallow").join("snapshots");
777 let Ok(entries) = std::fs::read_dir(&dir) else {
778 return Vec::new();
779 };
780
781 let mut snapshots = Vec::new();
782 for entry in entries {
783 let Ok(entry) = entry else { continue };
784 let path = entry.path();
785 if path.extension().is_some_and(|ext| ext == "json") {
786 match std::fs::read_to_string(&path) {
787 Ok(content) => match serde_json::from_str::<VitalSignsSnapshot>(&content) {
788 Ok(snap) => snapshots.push((path, snap)),
789 Err(e) => {
790 eprintln!("warning: skipping corrupt snapshot {}: {e}", path.display());
791 record_unreadable_snapshot(root, &path, &e.to_string());
792 }
793 },
794 Err(e) => {
795 eprintln!("warning: could not read snapshot {}: {e}", path.display());
796 record_unreadable_snapshot(root, &path, &e.to_string());
797 }
798 }
799 }
800 }
801
802 snapshots.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
803 snapshots
804}
805
806#[derive(Debug, Clone)]
808pub(crate) struct TrendBaseline {
809 pub(crate) snapshot: VitalSignsSnapshot,
811 pub(crate) path: PathBuf,
813 pub(crate) snapshots_loaded: usize,
816}
817
818impl TrendBaseline {
819 pub(crate) fn without_groups(&self) -> VitalSignsSnapshot {
824 let mut snapshot = self.snapshot.clone();
825 snapshot.groups = None;
826 snapshot
827 }
828}
829
830pub(crate) fn load_trend_baseline(
840 root: &Path,
841 explicit: Option<&Path>,
842) -> Result<Option<TrendBaseline>, String> {
843 if let Some(path) = explicit {
844 let content = std::fs::read_to_string(path).map_err(|e| {
845 format!(
846 "failed to read --trend-from snapshot {}: {e}",
847 path.display()
848 )
849 })?;
850 let snapshot = serde_json::from_str::<VitalSignsSnapshot>(&content).map_err(|e| {
853 format!(
854 "--trend-from file {} is not a fallow health snapshot (parse error at line {}, column {})",
855 path.display(),
856 e.line(),
857 e.column()
858 )
859 })?;
860 return Ok(Some(TrendBaseline {
861 snapshot,
862 path: path.to_path_buf(),
863 snapshots_loaded: 1,
864 }));
865 }
866 let mut snapshots = load_snapshots_with_paths(root);
867 let snapshots_loaded = snapshots.len();
868 Ok(snapshots.pop().map(|(path, snapshot)| TrendBaseline {
869 snapshot,
870 path,
871 snapshots_loaded,
872 }))
873}
874
875fn record_unreadable_snapshot(root: &Path, path: &Path, error: &str) {
878 crate::health::diagnostics::record_health_diagnostic(
879 root,
880 Some(path),
881 fallow_types::workspace::WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
882 error: error.to_owned(),
883 },
884 );
885}
886
887const TREND_TOLERANCE: f64 = 0.5;
889
890fn trend_point_from_snapshot(prev: &VitalSignsSnapshot) -> TrendPoint {
891 TrendPoint {
892 timestamp: prev.timestamp.clone(),
893 git_sha: prev.git_sha.clone(),
894 score: prev.score,
895 grade: prev.grade.clone(),
896 score_formula_version: prev.score_formula_version,
897 coverage_model: prev.coverage_model.clone(),
898 snapshot_schema_version: Some(prev.snapshot_schema_version),
899 }
900}
901
902fn overall_trend_direction(metrics: &[TrendMetric]) -> TrendDirection {
903 let (improving, declining) = metrics.iter().fold((0usize, 0usize), |(imp, dec), metric| {
904 match metric.direction {
905 TrendDirection::Improving => (imp + 1, dec),
906 TrendDirection::Declining => (imp, dec + 1),
907 TrendDirection::Stable => (imp, dec),
908 }
909 });
910
911 match improving.cmp(&declining) {
912 std::cmp::Ordering::Greater => TrendDirection::Improving,
913 std::cmp::Ordering::Less => TrendDirection::Declining,
914 std::cmp::Ordering::Equal => TrendDirection::Stable,
915 }
916}
917
918#[cfg(test)]
923pub(crate) fn compute_trend(
924 current_vs: &VitalSigns,
925 current_counts: &VitalSignsCounts,
926 current_score: Option<f64>,
927 snapshots: &[VitalSignsSnapshot],
928) -> Option<HealthTrend> {
929 let prev = snapshots.last()?;
930 let current_score = current_score.map(|score| HealthScore {
931 score,
932 ..compute_health_score(current_vs, current_counts.total_files)
933 });
934 Some(compute_trend_against(
935 prev,
936 snapshots.len(),
937 current_vs,
938 current_counts,
939 current_score.as_ref(),
940 ))
941}
942
943pub(crate) fn compute_trend_against(
947 prev: &VitalSignsSnapshot,
948 snapshots_loaded: usize,
949 current_vs: &VitalSigns,
950 current_counts: &VitalSignsCounts,
951 current_score: Option<&HealthScore>,
952) -> HealthTrend {
953 let compared_to = trend_point_from_snapshot(prev);
954
955 let metrics = TrendBuilder::new(prev, current_vs, current_counts, current_score).build();
956
957 let overall_direction = overall_trend_direction(&metrics);
958
959 HealthTrend {
960 compared_to,
961 metrics,
962 snapshots_loaded,
963 overall_direction,
964 }
965}
966
967pub(crate) fn compute_group_trend(
975 base: &VitalSignsSnapshot,
976 snapshots_loaded: usize,
977 previous: &fallow_output::GroupSnapshot,
978 current_vs: &VitalSigns,
979 current_counts: &VitalSignsCounts,
980 current_score: Option<&HealthScore>,
981) -> HealthTrend {
982 let prev = VitalSignsSnapshot {
983 vital_signs: previous.vital_signs.clone(),
984 counts: previous.counts.clone(),
985 score: previous.score,
986 grade: previous.grade.clone(),
987 ..base.clone()
988 };
989 compute_trend_against(
990 &prev,
991 snapshots_loaded,
992 current_vs,
993 current_counts,
994 current_score,
995 )
996}
997
998struct TrendBuilder<'a> {
999 prev: &'a VitalSignsSnapshot,
1000 current_vs: &'a VitalSigns,
1001 current_counts: &'a VitalSignsCounts,
1002 current_score: Option<&'a HealthScore>,
1003 metrics: Vec<TrendMetric>,
1004}
1005
1006impl TrendBuilder<'_> {
1007 fn new<'a>(
1008 prev: &'a VitalSignsSnapshot,
1009 current_vs: &'a VitalSigns,
1010 current_counts: &'a VitalSignsCounts,
1011 current_score: Option<&'a HealthScore>,
1012 ) -> TrendBuilder<'a> {
1013 TrendBuilder {
1014 prev,
1015 current_vs,
1016 current_counts,
1017 current_score,
1018 metrics: Vec::new(),
1019 }
1020 }
1021
1022 fn build(mut self) -> Vec<TrendMetric> {
1023 self.add_score_metric();
1024 self.add_dead_code_metrics();
1025 self.add_complexity_metrics();
1026 self.add_dependency_metrics();
1027 self.add_structure_metrics();
1028 self.metrics
1029 }
1030
1031 fn push(&mut self, input: TrendMetricInput) {
1032 self.metrics.push(make_metric(input));
1033 }
1034
1035 fn add_score_metric(&mut self) {
1036 let Some(current) = self.current_score else {
1037 return;
1038 };
1039 if self.prev.score_formula_version != Some(current.formula_version) {
1040 return;
1041 }
1042 if let Some(prev_score) = self.prev.score {
1043 self.push(TrendMetricInput {
1044 name: "score",
1045 label: "Health Score",
1046 previous: prev_score,
1047 current: current.score,
1048 unit: "",
1049 higher_is_better: true,
1050 previous_count: None,
1051 current_count: None,
1052 });
1053 }
1054 }
1055
1056 fn add_dead_code_metrics(&mut self) {
1057 if let (Some(prev_val), Some(cur_val)) = (
1058 self.prev.vital_signs.dead_file_pct,
1059 self.current_vs.dead_file_pct,
1060 ) {
1061 self.push(TrendMetricInput {
1062 name: "dead_file_pct",
1063 label: "Dead Files",
1064 previous: prev_val,
1065 current: cur_val,
1066 unit: "%",
1067 higher_is_better: false,
1068 previous_count: Some(TrendCount {
1069 value: self.prev.counts.dead_files,
1070 total: self.prev.counts.total_files,
1071 }),
1072 current_count: Some(TrendCount {
1073 value: self.current_counts.dead_files,
1074 total: self.current_counts.total_files,
1075 }),
1076 });
1077 }
1078
1079 if let (Some(prev_val), Some(cur_val)) = (
1080 self.prev.vital_signs.dead_export_pct,
1081 self.current_vs.dead_export_pct,
1082 ) {
1083 self.push(TrendMetricInput {
1084 name: "dead_export_pct",
1085 label: "Dead Exports",
1086 previous: prev_val,
1087 current: cur_val,
1088 unit: "%",
1089 higher_is_better: false,
1090 previous_count: Some(TrendCount {
1091 value: self.prev.counts.dead_exports,
1092 total: self.prev.counts.total_exports,
1093 }),
1094 current_count: Some(TrendCount {
1095 value: self.current_counts.dead_exports,
1096 total: self.current_counts.total_exports,
1097 }),
1098 });
1099 }
1100 }
1101
1102 fn add_complexity_metrics(&mut self) {
1103 self.push(TrendMetricInput {
1104 name: "avg_cyclomatic",
1105 label: "Avg Cyclomatic",
1106 previous: self.prev.vital_signs.avg_cyclomatic,
1107 current: self.current_vs.avg_cyclomatic,
1108 unit: "",
1109 higher_is_better: false,
1110 previous_count: None,
1111 current_count: None,
1112 });
1113
1114 if let (Some(prev_val), Some(cur_val)) = (
1115 self.prev.vital_signs.maintainability_avg,
1116 self.current_vs.maintainability_avg,
1117 ) {
1118 self.push(TrendMetricInput {
1119 name: "maintainability_avg",
1120 label: "Maintainability",
1121 previous: prev_val,
1122 current: cur_val,
1123 unit: "",
1124 higher_is_better: true,
1125 previous_count: None,
1126 current_count: None,
1127 });
1128 }
1129
1130 if let (Some(prev_profile), Some(cur_profile)) = (
1131 &self.prev.vital_signs.unit_size_profile,
1132 &self.current_vs.unit_size_profile,
1133 ) {
1134 self.push(TrendMetricInput {
1135 name: "unit_size_very_high_pct",
1136 label: "Oversized Fns",
1137 previous: prev_profile.very_high_risk,
1138 current: cur_profile.very_high_risk,
1139 unit: "%",
1140 higher_is_better: false,
1141 previous_count: None,
1142 current_count: None,
1143 });
1144 }
1145
1146 self.add_duplication_metric();
1147 }
1148
1149 fn add_duplication_metric(&mut self) {
1150 if let (Some(prev_val), Some(cur_val)) = (
1151 self.prev.vital_signs.duplication_pct,
1152 self.current_vs.duplication_pct,
1153 ) {
1154 self.push(TrendMetricInput {
1155 name: "duplication_pct",
1156 label: "Duplication",
1157 previous: prev_val,
1158 current: cur_val,
1159 unit: "%",
1160 higher_is_better: false,
1161 previous_count: self
1162 .prev
1163 .counts
1164 .duplicated_lines
1165 .zip(self.prev.counts.total_lines)
1166 .map(|(d, t)| TrendCount { value: d, total: t }),
1167 current_count: self
1168 .current_counts
1169 .duplicated_lines
1170 .zip(self.current_counts.total_lines)
1171 .map(|(d, t)| TrendCount { value: d, total: t }),
1172 });
1173 }
1174 }
1175
1176 fn add_dependency_metrics(&mut self) {
1177 if let (Some(prev_val), Some(cur_val)) = (
1178 self.prev.vital_signs.unused_dep_count,
1179 self.current_vs.unused_dep_count,
1180 ) {
1181 self.push(TrendMetricInput {
1182 name: "unused_dep_count",
1183 label: "Unused Deps",
1184 previous: f64::from(prev_val),
1185 current: f64::from(cur_val),
1186 unit: "",
1187 higher_is_better: false,
1188 previous_count: None,
1189 current_count: None,
1190 });
1191 }
1192 }
1193
1194 fn add_structure_metrics(&mut self) {
1195 if let (Some(prev_val), Some(cur_val)) = (
1196 self.prev.vital_signs.circular_dep_count,
1197 self.current_vs.circular_dep_count,
1198 ) {
1199 self.push(TrendMetricInput {
1200 name: "circular_dep_count",
1201 label: "Circular Deps",
1202 previous: f64::from(prev_val),
1203 current: f64::from(cur_val),
1204 unit: "",
1205 higher_is_better: false,
1206 previous_count: None,
1207 current_count: None,
1208 });
1209 }
1210
1211 if let (Some(prev_val), Some(cur_val)) = (
1212 self.prev.vital_signs.hotspot_count,
1213 self.current_vs.hotspot_count,
1214 ) {
1215 self.push(TrendMetricInput {
1216 name: "hotspot_count",
1217 label: "Hotspots",
1218 previous: f64::from(prev_val),
1219 current: f64::from(cur_val),
1220 unit: "",
1221 higher_is_better: false,
1222 previous_count: None,
1223 current_count: None,
1224 });
1225 }
1226
1227 if let (Some(prev_val), Some(cur_val)) =
1228 (self.prev.vital_signs.p95_fan_in, self.current_vs.p95_fan_in)
1229 {
1230 self.push(TrendMetricInput {
1231 name: "p95_fan_in",
1232 label: "P95 Fan-in",
1233 previous: f64::from(prev_val),
1234 current: f64::from(cur_val),
1235 unit: "",
1236 higher_is_better: false,
1237 previous_count: None,
1238 current_count: None,
1239 });
1240 }
1241 }
1242}
1243
1244struct TrendMetricInput {
1246 name: &'static str,
1247 label: &'static str,
1248 previous: f64,
1249 current: f64,
1250 unit: &'static str,
1251 higher_is_better: bool,
1252 previous_count: Option<TrendCount>,
1253 current_count: Option<TrendCount>,
1254}
1255
1256fn make_metric(input: TrendMetricInput) -> TrendMetric {
1257 let TrendMetricInput {
1258 name,
1259 label,
1260 previous,
1261 current,
1262 unit,
1263 higher_is_better,
1264 previous_count,
1265 current_count,
1266 } = input;
1267 let delta = (current - previous).round_to(1);
1268 let direction = if delta.abs() < TREND_TOLERANCE {
1269 TrendDirection::Stable
1270 } else if (higher_is_better && delta > 0.0) || (!higher_is_better && delta < 0.0) {
1271 TrendDirection::Improving
1272 } else {
1273 TrendDirection::Declining
1274 };
1275
1276 TrendMetric {
1277 name,
1278 label,
1279 previous,
1280 current,
1281 delta,
1282 direction,
1283 unit,
1284 previous_count,
1285 current_count,
1286 }
1287}
1288
1289trait RoundTo {
1291 fn round_to(self, decimals: u32) -> Self;
1292}
1293
1294impl RoundTo for f64 {
1295 fn round_to(self, decimals: u32) -> Self {
1296 let factor = 10_f64.powi(i32::try_from(decimals).unwrap_or(i32::MAX));
1297 (self * factor).round() / factor
1298 }
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303 use super::*;
1304
1305 fn make_module(id: u32, cyclomatic: u16) -> crate::source::ModuleInfo {
1306 crate::source::ModuleInfo {
1307 complexity: vec![fallow_types::extract::FunctionComplexity {
1308 name: format!("fn_{id}"),
1309 is_private_member: false,
1310 context_named: None,
1311 line: id + 1,
1312 col: 0,
1313 cyclomatic,
1314 cognitive: 0,
1315 line_count: 10,
1316 param_count: 0,
1317 react_hook_count: 0,
1318 react_jsx_max_depth: 0,
1319 react_prop_count: 0,
1320 source_hash: None,
1321 contributions: Vec::new(),
1322 }],
1323 ..crate::source::ModuleInfo::empty(crate::discover::FileId(id))
1324 }
1325 }
1326
1327 #[expect(
1328 clippy::cast_possible_truncation,
1329 reason = "test values are trivially small"
1330 )]
1331 fn make_modules() -> Vec<crate::source::ModuleInfo> {
1332 (0..10)
1333 .map(|i| make_module(i, (i as u16 + 1) * 2))
1334 .collect()
1335 }
1336
1337 fn assert_close(actual: f64, expected: f64) {
1338 assert!(
1339 (actual - expected).abs() < f64::EPSILON,
1340 "expected {expected}, got {actual}"
1341 );
1342 }
1343
1344 fn assert_some_close(actual: Option<f64>, expected: f64) {
1345 assert_close(actual.expect("expected metric to be present"), expected);
1346 }
1347
1348 #[test]
1349 fn compute_cyclomatic_stats() {
1350 let modules = make_modules();
1351 let input = VitalSignsInput {
1352 modules: &modules,
1353 module_filter: None,
1354 file_scores: None,
1355 hotspots: None,
1356 total_files: 10,
1357 analysis_counts: None,
1358 };
1359 let vs = compute_vital_signs(&input);
1360 assert!((vs.avg_cyclomatic - 11.0).abs() < f64::EPSILON);
1361 assert_eq!(vs.p90_cyclomatic, 18);
1362 }
1363
1364 #[test]
1365 fn cyclomatic_population_partitions_units_and_respects_module_filter() {
1366 let mut mixed = make_module(0, 1);
1367 let mut module_unit = mixed.complexity[0].clone();
1368 module_unit.name = "<module>".into();
1369 module_unit.cyclomatic = 31;
1370 let mut template_unit = module_unit.clone();
1371 template_unit.name = "<template>".into();
1372 template_unit.cyclomatic = 4;
1373 mixed.complexity.extend([module_unit, template_unit]);
1374 let modules = [mixed, make_module(1, 100)];
1375 let filter = rustc_hash::FxHashSet::from_iter([crate::discover::FileId(0)]);
1376 let input = VitalSignsInput {
1377 modules: &modules,
1378 module_filter: Some(&filter),
1379 file_scores: None,
1380 hotspots: None,
1381 total_files: 1,
1382 analysis_counts: None,
1383 };
1384 let vs = compute_vital_signs(&input);
1385 let population = vs.cyclomatic_population.unwrap();
1386 assert_eq!(population.functions.count, 1);
1387 assert_eq!(population.modules.count, 1);
1388 assert_eq!(population.templates.count, 1);
1389 assert_eq!(population.functions.sum, 1);
1390 assert_eq!(population.modules.sum, 31);
1391 assert_eq!(population.templates.sum, 4);
1392 assert_close(vs.avg_cyclomatic, 12.0);
1393 assert_eq!(vs.p90_cyclomatic, 31);
1394 }
1395
1396 #[test]
1397 fn compute_with_analysis_counts() {
1398 let modules = make_modules();
1399 let input = VitalSignsInput {
1400 modules: &modules,
1401 module_filter: None,
1402 file_scores: None,
1403 hotspots: None,
1404 total_files: 100,
1405 analysis_counts: Some(AnalysisCounts {
1406 total_exports: 500,
1407 dead_files: 5,
1408 dead_exports: 50,
1409 unused_deps: 3,
1410 circular_deps: 2,
1411 total_deps: 40,
1412 }),
1413 };
1414 let vs = compute_vital_signs(&input);
1415 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));
1418 assert_eq!(vs.circular_dep_count, Some(2));
1419 }
1420
1421 #[test]
1422 fn compute_hotspot_count_with_threshold() {
1423 let hotspots = vec![
1424 HotspotEntry {
1425 path: PathBuf::from("a.ts"),
1426 score: 80.0,
1427 commits: 10,
1428 weighted_commits: 8.0,
1429 lines_added: 100,
1430 lines_deleted: 50,
1431 complexity_density: 0.5,
1432 fan_in: 5,
1433 trend: crate::churn::ChurnTrend::Stable,
1434 median_files_per_commit: None,
1435 median_lines_per_commit: None,
1436 ownership: None,
1437 is_test_path: false,
1438 },
1439 HotspotEntry {
1440 path: PathBuf::from("b.ts"),
1441 score: 30.0, commits: 5,
1443 weighted_commits: 3.0,
1444 lines_added: 40,
1445 lines_deleted: 20,
1446 complexity_density: 0.2,
1447 fan_in: 2,
1448 trend: crate::churn::ChurnTrend::Cooling,
1449 median_files_per_commit: None,
1450 median_lines_per_commit: None,
1451 ownership: None,
1452 is_test_path: false,
1453 },
1454 HotspotEntry {
1455 path: PathBuf::from("c.ts"),
1456 score: 50.0, commits: 8,
1458 weighted_commits: 6.0,
1459 lines_added: 80,
1460 lines_deleted: 30,
1461 complexity_density: 0.4,
1462 fan_in: 3,
1463 trend: crate::churn::ChurnTrend::Accelerating,
1464 median_files_per_commit: None,
1465 median_lines_per_commit: None,
1466 ownership: None,
1467 is_test_path: false,
1468 },
1469 ];
1470 let modules = Vec::new();
1471 let input = VitalSignsInput {
1472 modules: &modules,
1473 module_filter: None,
1474 file_scores: None,
1475 hotspots: Some(&hotspots),
1476 total_files: 10,
1477 analysis_counts: None,
1478 };
1479 let vs = compute_vital_signs(&input);
1480 assert_eq!(vs.hotspot_count, Some(2)); assert_eq!(vs.hotspot_top_pct_count, Some(1)); }
1483
1484 #[test]
1485 fn hotspot_penalty_ignores_subthreshold_ranked_entries() {
1486 let hotspots: Vec<HotspotEntry> = [49.9, 0.1]
1487 .into_iter()
1488 .enumerate()
1489 .map(|(index, score)| HotspotEntry {
1490 path: PathBuf::from(format!("src/active-{index}.ts")),
1491 score,
1492 commits: 3,
1493 weighted_commits: 3.0,
1494 lines_added: 10,
1495 lines_deleted: 5,
1496 complexity_density: 0.1,
1497 fan_in: 0,
1498 trend: crate::churn::ChurnTrend::Stable,
1499 median_files_per_commit: None,
1500 median_lines_per_commit: None,
1501 ownership: None,
1502 is_test_path: false,
1503 })
1504 .collect();
1505 let input = VitalSignsInput {
1506 modules: &[],
1507 module_filter: None,
1508 file_scores: None,
1509 hotspots: Some(&hotspots),
1510 total_files: 200,
1511 analysis_counts: None,
1512 };
1513
1514 let vitals = compute_vital_signs(&input);
1515 let health = compute_health_score(&vitals, input.total_files);
1516
1517 assert_eq!(vitals.hotspot_count, Some(0));
1518 assert_eq!(vitals.hotspot_top_pct_count, Some(2));
1519 assert_some_close(health.penalties.hotspots, 0.0);
1520 }
1521
1522 #[test]
1523 fn empty_cyclomatic_population_is_measured_not_unknown() {
1524 let vs = compute_vital_signs(&VitalSignsInput {
1525 modules: &[],
1526 module_filter: None,
1527 file_scores: None,
1528 hotspots: None,
1529 total_files: 0,
1530 analysis_counts: None,
1531 });
1532 let population = vs.cyclomatic_population.unwrap();
1533 for group in [
1534 population.functions,
1535 population.modules,
1536 population.templates,
1537 ] {
1538 assert_eq!(group.count, 0);
1539 assert_eq!(group.sum, 0);
1540 assert_eq!(group.max, None);
1541 }
1542 assert_close(vs.avg_cyclomatic, 0.0);
1543 assert_eq!(vs.p90_cyclomatic, 0);
1544 assert_eq!(vs.critical_complexity_pct, None);
1545 }
1546
1547 #[test]
1548 fn compute_without_hotspots_gives_none() {
1549 let modules = Vec::new();
1550 let input = VitalSignsInput {
1551 modules: &modules,
1552 module_filter: None,
1553 file_scores: None,
1554 hotspots: None,
1555 total_files: 0,
1556 analysis_counts: None,
1557 };
1558 let vs = compute_vital_signs(&input);
1559 assert!(vs.hotspot_count.is_none());
1560 }
1561
1562 #[test]
1563 fn snapshot_save_and_load() {
1564 let dir = tempfile::tempdir().unwrap();
1565 let root = dir.path();
1566 let vs = VitalSigns {
1567 dead_file_pct: Some(3.2),
1568 dead_export_pct: Some(8.1),
1569 avg_cyclomatic: 4.7,
1570 p90_cyclomatic: 12,
1571 hotspot_count: Some(5),
1572 maintainability_avg: Some(72.4),
1573 unused_dep_count: Some(4),
1574 circular_dep_count: Some(2),
1575 ..Default::default()
1576 };
1577 let counts = VitalSignsCounts {
1578 total_files: 1200,
1579 total_exports: 5400,
1580 dead_files: 38,
1581 dead_exports: 437,
1582 files_scored: Some(1150),
1583 total_deps: 42,
1584 ..Default::default()
1585 };
1586 let health_score = compute_health_score(&vs, 1200);
1587 let snapshot = build_snapshot(
1588 vs,
1589 counts,
1590 root,
1591 false,
1592 Some(&health_score),
1593 None,
1594 fallow_types::semantic::SemanticAnalysisIdentity::default(),
1595 );
1596 let saved_path = save_snapshot(&snapshot, root, None).unwrap();
1597
1598 assert!(saved_path.exists());
1599 assert!(saved_path.starts_with(root.join(".fallow/snapshots")));
1600
1601 let content = std::fs::read_to_string(&saved_path).unwrap();
1602 let loaded: VitalSignsSnapshot = serde_json::from_str(&content).unwrap();
1603 assert_eq!(loaded.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
1604 assert!((loaded.vital_signs.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
1605 assert_eq!(loaded.counts.total_files, 1200);
1606 assert!(loaded.score.is_some());
1607 assert!(loaded.grade.is_some());
1608 }
1609
1610 #[test]
1611 fn snapshot_save_explicit_path() {
1612 let dir = tempfile::tempdir().unwrap();
1613 let root = dir.path();
1614 let explicit = root.join("my-snapshot.json");
1615 let vs = VitalSigns {
1616 avg_cyclomatic: 1.0,
1617 p90_cyclomatic: 2,
1618 ..Default::default()
1619 };
1620 let counts = VitalSignsCounts::default();
1621 let snapshot = build_snapshot(
1622 vs,
1623 counts,
1624 root,
1625 false,
1626 None,
1627 None,
1628 fallow_types::semantic::SemanticAnalysisIdentity::default(),
1629 );
1630 let saved = save_snapshot(&snapshot, root, Some(&explicit)).unwrap();
1631 assert_eq!(saved, explicit);
1632 assert!(explicit.exists());
1633 }
1634
1635 #[test]
1636 fn snapshot_save_creates_nested_dirs() {
1637 let dir = tempfile::tempdir().unwrap();
1638 let root = dir.path();
1639 let nested = root.join("a/b/c/snapshot.json");
1640 let vs = VitalSigns {
1641 avg_cyclomatic: 1.0,
1642 p90_cyclomatic: 2,
1643 ..Default::default()
1644 };
1645 let counts = VitalSignsCounts::default();
1646 let snapshot = build_snapshot(
1647 vs,
1648 counts,
1649 root,
1650 false,
1651 None,
1652 None,
1653 fallow_types::semantic::SemanticAnalysisIdentity::default(),
1654 );
1655 let saved = save_snapshot(&snapshot, root, Some(&nested)).unwrap();
1656 assert_eq!(saved, nested);
1657 assert!(nested.exists());
1658 }
1659
1660 #[test]
1661 fn days_to_ymd_epoch() {
1662 assert_eq!(days_to_ymd(0), (1970, 1, 1));
1663 }
1664
1665 #[test]
1666 fn days_to_ymd_known_date() {
1667 assert_eq!(days_to_ymd(20_537), (2026, 3, 25));
1668 }
1669
1670 #[test]
1671 fn health_score_perfect() {
1672 let vs = VitalSigns {
1673 dead_file_pct: Some(0.0),
1674 dead_export_pct: Some(0.0),
1675 avg_cyclomatic: 1.0,
1676 p90_cyclomatic: 2,
1677 hotspot_count: Some(0),
1678 maintainability_avg: Some(90.0),
1679 unused_dep_count: Some(0),
1680 circular_dep_count: Some(0),
1681 ..Default::default()
1682 };
1683 let score = compute_health_score(&vs, 100);
1684 assert!((score.score - 100.0).abs() < f64::EPSILON);
1685 assert_eq!(score.grade, "A");
1686 }
1687
1688 #[test]
1689 fn health_score_no_optional_metrics() {
1690 let vs = VitalSigns {
1691 avg_cyclomatic: 1.0,
1692 p90_cyclomatic: 2,
1693 ..Default::default()
1694 };
1695 let score = compute_health_score(&vs, 0);
1696 assert!((score.score - 100.0).abs() < f64::EPSILON);
1697 assert_eq!(score.grade, "A");
1698 assert!(score.penalties.dead_files.is_none());
1699 assert!(score.penalties.unused_deps.is_none());
1700 assert!(score.penalties.duplication.is_none());
1701 }
1702
1703 #[test]
1704 fn health_score_dead_code_penalty() {
1705 let vs = VitalSigns {
1706 dead_file_pct: Some(50.0),
1707 dead_export_pct: Some(30.0),
1708 avg_cyclomatic: 1.0,
1709 p90_cyclomatic: 2,
1710 ..Default::default()
1711 };
1712 let score = compute_health_score(&vs, 100);
1713 assert!((score.score - 84.0).abs() < 0.1);
1714 assert_eq!(score.grade, "B");
1715 }
1716
1717 #[test]
1718 fn health_score_complexity_penalty() {
1719 let vs = VitalSigns {
1720 avg_cyclomatic: 5.5,
1721 p90_cyclomatic: 15,
1722 ..Default::default()
1723 };
1724 let score = compute_health_score(&vs, 100);
1725 assert!((score.score - 75.0).abs() < 0.1);
1726 assert_eq!(score.grade, "B");
1727 }
1728
1729 #[test]
1730 fn health_score_prop_drilling_penalty_opt_in() {
1731 let base = || VitalSigns {
1732 avg_cyclomatic: 1.0,
1733 p90_cyclomatic: 2,
1734 ..Default::default()
1735 };
1736 let base_score = compute_health_score(&base(), 100).score;
1737
1738 let three = VitalSigns {
1740 prop_drilling_chain_count: Some(3),
1741 prop_drilling_max_depth: Some(5),
1742 ..base()
1743 };
1744 assert!((base_score - compute_health_score(&three, 100).score - 3.0).abs() < 0.1);
1745
1746 let many = VitalSigns {
1748 prop_drilling_chain_count: Some(20),
1749 ..base()
1750 };
1751 assert!((base_score - compute_health_score(&many, 100).score - 5.0).abs() < 0.1);
1752
1753 let off = VitalSigns {
1756 prop_drilling_chain_count: None,
1757 ..base()
1758 };
1759 assert!((base_score - compute_health_score(&off, 100).score).abs() < f64::EPSILON);
1760 }
1761
1762 #[test]
1763 fn health_score_clamped_at_zero() {
1764 let vs = VitalSigns {
1765 dead_file_pct: Some(100.0),
1766 dead_export_pct: Some(100.0),
1767 avg_cyclomatic: 10.0,
1768 p90_cyclomatic: 30,
1769 hotspot_count: Some(50),
1770 maintainability_avg: Some(20.0),
1771 unused_dep_count: Some(100),
1772 circular_dep_count: Some(50),
1773 ..Default::default()
1774 };
1775 let score = compute_health_score(&vs, 100);
1776 assert!((score.score).abs() < f64::EPSILON);
1777 assert_eq!(score.grade, "F");
1778 }
1779
1780 #[test]
1781 fn health_score_hotspot_normalized_by_files() {
1782 let vs = VitalSigns {
1783 avg_cyclomatic: 1.0,
1784 p90_cyclomatic: 2,
1785 hotspot_count: Some(5),
1786 ..Default::default()
1787 };
1788 let score_100 = compute_health_score(&vs, 100);
1789 let score_1000 = compute_health_score(&vs, 1000);
1790 assert!(score_1000.score > score_100.score);
1791 }
1792
1793 #[test]
1794 fn health_score_thresholded_hotspots_can_use_full_budget() {
1795 let vs = VitalSigns {
1796 avg_cyclomatic: 1.0,
1797 p90_cyclomatic: 2,
1798 hotspot_count: Some(250),
1799 hotspot_top_pct_count: Some(250),
1800 ..Default::default()
1801 };
1802
1803 let score = compute_health_score(&vs, 25_000);
1804
1805 assert_some_close(score.penalties.hotspots, 10.0);
1806 assert_close(score.score, 90.0);
1807 }
1808
1809 #[test]
1810 fn health_score_duplication_penalty() {
1811 let vs = VitalSigns {
1812 cyclomatic_population: None,
1813 dead_file_pct: None,
1814 dead_export_pct: None,
1815 avg_cyclomatic: 1.0,
1816 critical_complexity_pct: None,
1817 p90_cyclomatic: 2,
1818 duplication_pct: Some(10.0), hotspot_count: None,
1820 hotspot_top_pct_count: None,
1821 maintainability_avg: None,
1822 maintainability_low_pct: None,
1823 unused_dep_count: None,
1824 unused_deps_per_k_files: None,
1825 circular_dep_count: None,
1826 circular_deps_per_k_files: None,
1827 counts: None,
1828 unit_size_profile: None,
1829 functions_over_60_loc_per_k: None,
1830 unit_interfacing_profile: None,
1831 p95_fan_in: None,
1832 coupling_high_pct: None,
1833 prop_drilling_chain_count: None,
1834 prop_drilling_max_depth: None,
1835 p95_render_fan_in: None,
1836 render_fan_in_high_pct: None,
1837 max_render_fan_in: None,
1838 top_render_fan_in: Vec::new(),
1839 total_loc: 0,
1840 };
1841 let score = compute_health_score(&vs, 100);
1842 assert_eq!(score.penalties.duplication, Some(5.0));
1843
1844 let vs_low = VitalSigns {
1845 duplication_pct: Some(4.0),
1846 ..vs.clone()
1847 };
1848 let score_low = compute_health_score(&vs_low, 100);
1849 assert_eq!(score_low.penalties.duplication, Some(0.0));
1850
1851 let vs_high = VitalSigns {
1852 duplication_pct: Some(20.0),
1853 ..vs
1854 };
1855 let score_high = compute_health_score(&vs_high, 100);
1856 assert_eq!(score_high.penalties.duplication, Some(10.0));
1857 }
1858
1859 #[test]
1860 fn health_score_uses_scale_invariant_monorepo_signals() {
1861 let vs = VitalSigns {
1862 dead_file_pct: Some(4.0),
1863 dead_export_pct: Some(9.0),
1864 avg_cyclomatic: 2.3,
1865 critical_complexity_pct: Some(2.3),
1866 p90_cyclomatic: 4,
1867 duplication_pct: Some(6.0),
1868 hotspot_count: Some(250),
1869 hotspot_top_pct_count: Some(250),
1870 maintainability_avg: Some(91.0),
1871 maintainability_low_pct: Some(8.0),
1872 unused_dep_count: Some(180),
1873 unused_deps_per_k_files: Some(7.2),
1874 circular_dep_count: Some(450),
1875 circular_deps_per_k_files: Some(18.0),
1876 unit_size_profile: Some(RiskProfile {
1877 low_risk: 80.0,
1878 medium_risk: 12.7,
1879 high_risk: 5.0,
1880 very_high_risk: 2.3,
1881 }),
1882 functions_over_60_loc_per_k: Some(23.0),
1883 p95_fan_in: Some(7),
1884 coupling_high_pct: Some(4.0),
1885 ..Default::default()
1886 };
1887 let score = compute_health_score(&vs, 25_000);
1888 let penalties = &score.penalties;
1889
1890 assert_some_close(penalties.dead_files, 0.8);
1891 assert_some_close(penalties.dead_exports, 1.8);
1892 assert_close(penalties.complexity, 9.2);
1893 assert!((penalties.p90_complexity).abs() < f64::EPSILON);
1894 assert_some_close(penalties.maintainability, 12.0);
1895 assert_some_close(penalties.hotspots, 10.0);
1896 assert_some_close(penalties.unused_deps, 3.6);
1897 assert_some_close(penalties.circular_deps, 9.0);
1898 assert_some_close(penalties.unit_size, 10.0);
1899 assert_some_close(penalties.coupling, 2.0);
1900 assert_some_close(penalties.duplication, 1.0);
1901 assert_close(score.score, 40.6);
1902 assert_eq!(score.grade, "D");
1903 }
1904
1905 #[test]
1906 fn load_snapshots_empty_dir() {
1907 let dir = tempfile::tempdir().unwrap();
1908 let snaps = load_snapshots(dir.path());
1909 assert!(snaps.is_empty());
1910 }
1911
1912 #[test]
1913 fn load_snapshots_returns_sorted() {
1914 let dir = tempfile::tempdir().unwrap();
1915 let root = dir.path();
1916 let snap_dir = root.join(".fallow/snapshots");
1917 std::fs::create_dir_all(&snap_dir).unwrap();
1918
1919 let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
1920 let newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(78.0));
1921
1922 std::fs::write(
1923 snap_dir.join("2026-03-01T00-00-00Z.json"),
1924 serde_json::to_string(&newer).unwrap(),
1925 )
1926 .unwrap();
1927 std::fs::write(
1928 snap_dir.join("2026-01-01T00-00-00Z.json"),
1929 serde_json::to_string(&older).unwrap(),
1930 )
1931 .unwrap();
1932
1933 let loaded = load_snapshots(root);
1934 assert_eq!(loaded.len(), 2);
1935 assert_eq!(loaded[0].timestamp, "2026-01-01T00:00:00Z");
1936 assert_eq!(loaded[1].timestamp, "2026-03-01T00:00:00Z");
1937 }
1938
1939 #[test]
1940 fn load_snapshots_skips_corrupt_files() {
1941 let dir = tempfile::tempdir().unwrap();
1942 let root = dir.path();
1943 let snap_dir = root.join(".fallow/snapshots");
1944 std::fs::create_dir_all(&snap_dir).unwrap();
1945
1946 std::fs::write(snap_dir.join("corrupt.json"), "not valid json").unwrap();
1947 let good = make_test_snapshot("2026-02-01T00:00:00Z", Some(80.0));
1948 std::fs::write(
1949 snap_dir.join("good.json"),
1950 serde_json::to_string(&good).unwrap(),
1951 )
1952 .unwrap();
1953
1954 let loaded = load_snapshots(root);
1955 assert_eq!(loaded.len(), 1);
1956 assert_eq!(loaded[0].timestamp, "2026-02-01T00:00:00Z");
1957 }
1958
1959 #[test]
1960 fn load_snapshots_ignores_non_json() {
1961 let dir = tempfile::tempdir().unwrap();
1962 let root = dir.path();
1963 let snap_dir = root.join(".fallow/snapshots");
1964 std::fs::create_dir_all(&snap_dir).unwrap();
1965
1966 std::fs::write(snap_dir.join("readme.txt"), "not a snapshot").unwrap();
1967
1968 let loaded = load_snapshots(root);
1969 assert!(loaded.is_empty());
1970 }
1971
1972 #[test]
1973 fn hotspot_penalty_respects_score_threshold_and_scoped_population() {
1974 for (scores, total_files, expected) in [
1975 (vec![49.9, 0.1], 563, 0.0),
1976 (vec![50.0, 49.9, 0.1], 563, 1.7),
1977 (vec![50.0; 6], 563, 10.0),
1978 (vec![50.0; 7], 563, 10.0),
1979 (vec![50.0], 1, 10.0),
1980 (vec![50.0], 101, 5.0),
1981 (vec![50.0], 0, 0.0),
1982 (vec![], 563, 0.0),
1983 ] {
1984 let hotspots: Vec<HotspotEntry> = scores
1985 .into_iter()
1986 .enumerate()
1987 .map(|(index, score)| HotspotEntry {
1988 path: PathBuf::from(format!("src/scoped-{index}.ts")),
1989 score,
1990 commits: 3,
1991 weighted_commits: 3.0,
1992 lines_added: 10,
1993 lines_deleted: 5,
1994 complexity_density: 0.1,
1995 fan_in: 0,
1996 trend: crate::churn::ChurnTrend::Stable,
1997 median_files_per_commit: None,
1998 median_lines_per_commit: None,
1999 ownership: None,
2000 is_test_path: false,
2001 })
2002 .collect();
2003 let input = VitalSignsInput {
2004 modules: &[],
2005 module_filter: None,
2006 file_scores: None,
2007 hotspots: Some(&hotspots),
2008 total_files,
2009 analysis_counts: None,
2010 };
2011 let vitals = compute_vital_signs(&input);
2012 let score = compute_health_score(&vitals, total_files);
2013 assert_some_close(score.penalties.hotspots, expected);
2014 assert_eq!(score.formula_version, HEALTH_SCORE_FORMULA_VERSION);
2015 }
2016 }
2017
2018 #[test]
2019 fn hotspot_penalty_requires_thresholded_measurement_and_ignores_rank_diagnostic() {
2020 let mut vitals = VitalSigns {
2021 hotspot_top_pct_count: Some(6),
2022 ..Default::default()
2023 };
2024 assert!(
2025 compute_health_score(&vitals, 563)
2026 .penalties
2027 .hotspots
2028 .is_none()
2029 );
2030
2031 vitals.hotspot_count = Some(1);
2032 for diagnostic in [None, Some(0), Some(6)] {
2033 vitals.hotspot_top_pct_count = diagnostic;
2034 assert_some_close(compute_health_score(&vitals, 563).penalties.hotspots, 1.7);
2035 }
2036 }
2037
2038 #[test]
2039 fn snapshot_persists_the_computed_score_formula() {
2040 let dir = tempfile::tempdir().unwrap();
2041 let vitals = make_test_vital_signs();
2042 let counts = make_test_counts();
2043 let mut health = compute_health_score(&vitals, counts.total_files);
2044 health.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2045 let snapshot = build_snapshot(
2046 vitals,
2047 counts,
2048 dir.path(),
2049 false,
2050 Some(&health),
2051 None,
2052 fallow_types::semantic::SemanticAnalysisIdentity::default(),
2053 );
2054 assert_eq!(snapshot.score_formula_version, Some(health.formula_version));
2055 assert_eq!(snapshot.score, Some(health.score));
2056 assert_eq!(snapshot.grade.as_deref(), Some(health.grade));
2057
2058 let unscored = build_snapshot(
2059 snapshot.vital_signs,
2060 snapshot.counts,
2061 dir.path(),
2062 false,
2063 None,
2064 None,
2065 fallow_types::semantic::SemanticAnalysisIdentity::default(),
2066 );
2067 assert!(unscored.score_formula_version.is_none());
2068 }
2069
2070 #[test]
2071 fn trend_requires_both_scores_even_with_matching_formula_identity() {
2072 let current_vs = make_test_vital_signs();
2073 let counts = make_test_counts();
2074 let health = compute_health_score(¤t_vs, counts.total_files);
2075 for (previous_score, current_score) in [(None, Some(&health)), (Some(72.0), None)] {
2076 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", previous_score);
2077 previous.score_formula_version = Some(health.formula_version);
2078 let trend = compute_trend_against(&previous, 1, ¤t_vs, &counts, current_score);
2079 assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2080 assert!(
2081 trend
2082 .metrics
2083 .iter()
2084 .any(|metric| metric.name == "avg_cyclomatic")
2085 );
2086 assert!(
2087 fallow_output::health_score_comparison_note(
2088 previous.score_formula_version,
2089 Some(health.formula_version),
2090 )
2091 .is_none()
2092 );
2093 }
2094 }
2095
2096 #[test]
2097 fn trend_compares_only_known_matching_score_formulas() {
2098 let current_vs = make_test_vital_signs();
2099 let counts = make_test_counts();
2100 let current_score = compute_health_score(¤t_vs, counts.total_files);
2101 for previous_formula in [
2102 None,
2103 Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2104 Some(HEALTH_SCORE_FORMULA_VERSION),
2105 Some(HEALTH_SCORE_FORMULA_VERSION + 1),
2106 ] {
2107 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2108 previous.score_formula_version = previous_formula;
2109 let trend =
2110 compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(¤t_score));
2111 assert_eq!(
2112 trend.metrics.iter().any(|metric| metric.name == "score"),
2113 previous_formula == Some(current_score.formula_version),
2114 "previous formula: {previous_formula:?}"
2115 );
2116 assert!(
2117 trend
2118 .metrics
2119 .iter()
2120 .any(|metric| metric.name == "avg_cyclomatic")
2121 );
2122 assert_eq!(trend.compared_to.score, previous.score);
2123 assert_eq!(trend.compared_to.grade, previous.grade);
2124 assert_eq!(trend.compared_to.score_formula_version, previous_formula);
2125 }
2126 }
2127
2128 #[test]
2129 fn trend_uses_actual_current_formula_and_keeps_raw_direction_on_mismatch() {
2130 let current_vs = make_test_vital_signs();
2131 let counts = make_test_counts();
2132 let mut current_score = compute_health_score(¤t_vs, counts.total_files);
2133 current_score.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2134 current_score.score = 90.0;
2135 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2136 previous.vital_signs = current_vs.clone();
2137 previous.counts = counts.clone();
2138 previous.vital_signs.avg_cyclomatic = current_vs.avg_cyclomatic - 2.0;
2139
2140 let incompatible =
2141 compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(¤t_score));
2142 assert!(
2143 !incompatible
2144 .metrics
2145 .iter()
2146 .any(|metric| metric.name == "score")
2147 );
2148 assert_eq!(incompatible.overall_direction, TrendDirection::Declining);
2149
2150 previous.score_formula_version = Some(current_score.formula_version);
2151 let compatible =
2152 compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(¤t_score));
2153 assert!(
2154 compatible
2155 .metrics
2156 .iter()
2157 .any(|metric| metric.name == "score")
2158 );
2159 }
2160
2161 #[test]
2162 fn legacy_snapshot_retains_unknown_formula_without_tool_version_inference() {
2163 let mut value =
2164 serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2165 value["snapshot_schema_version"] = serde_json::json!(11);
2166 value["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
2167 value
2168 .as_object_mut()
2169 .unwrap()
2170 .remove("score_formula_version");
2171 let previous: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2172 assert!(previous.score_formula_version.is_none());
2173 let current_vs = make_test_vital_signs();
2174 let counts = make_test_counts();
2175 let health = compute_health_score(¤t_vs, counts.total_files);
2176 let trend = compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(&health));
2177 assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2178 assert!(!trend.metrics.is_empty());
2179 }
2180
2181 #[test]
2182 fn group_trend_uses_snapshot_level_formula_identity() {
2183 let current_vs = make_test_vital_signs();
2184 let counts = make_test_counts();
2185 let health = compute_health_score(¤t_vs, counts.total_files);
2186 let group = group_snapshot("@team/a", 70.0);
2187 for formula in [
2188 None,
2189 Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2190 Some(health.formula_version),
2191 ] {
2192 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2193 previous.score_formula_version = formula;
2194 let trend =
2195 compute_group_trend(&previous, 1, &group, ¤t_vs, &counts, Some(&health));
2196 assert_eq!(
2197 trend.metrics.iter().any(|metric| metric.name == "score"),
2198 formula == Some(health.formula_version),
2199 );
2200 assert_eq!(trend.compared_to.score, group.score);
2201 assert_eq!(trend.compared_to.grade, group.grade);
2202 assert_eq!(trend.compared_to.score_formula_version, formula);
2203 assert!(!trend.metrics.is_empty());
2204 }
2205 }
2206
2207 #[test]
2208 fn compute_trend_no_snapshots() {
2209 let vs = make_test_vital_signs();
2210 let counts = make_test_counts();
2211 assert!(compute_trend(&vs, &counts, Some(78.0), &[]).is_none());
2212 }
2213
2214 #[test]
2215 fn compute_trend_improving() {
2216 let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2217 let vs = VitalSigns {
2218 dead_file_pct: Some(2.8),
2219 dead_export_pct: Some(7.5),
2220 avg_cyclomatic: 4.1,
2221 p90_cyclomatic: 12,
2222 hotspot_count: Some(3),
2223 maintainability_avg: Some(75.0),
2224 unused_dep_count: Some(3),
2225 circular_dep_count: Some(1),
2226 ..Default::default()
2227 };
2228 let counts = VitalSignsCounts {
2229 total_files: 100,
2230 total_exports: 500,
2231 dead_files: 3,
2232 dead_exports: 38,
2233 files_scored: Some(95),
2234 total_deps: 40,
2235 ..Default::default()
2236 };
2237
2238 let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2239 assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2240 assert_eq!(trend.snapshots_loaded, 1);
2241 assert_eq!(trend.overall_direction, TrendDirection::Improving);
2242
2243 let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2244 assert_eq!(score_metric.direction, TrendDirection::Improving);
2245 assert!((score_metric.delta - 6.0).abs() < f64::EPSILON);
2246 }
2247
2248 #[test]
2249 fn compute_trend_stable_within_tolerance() {
2250 let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(78.0));
2251 let vs = make_test_vital_signs();
2252 let counts = make_test_counts();
2253
2254 let trend = compute_trend(&vs, &counts, Some(78.3), &[prev]).unwrap();
2255 let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2256 assert_eq!(score_metric.direction, TrendDirection::Stable);
2257 }
2258
2259 #[test]
2260 fn compute_trend_uses_most_recent_snapshot() {
2261 let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0));
2262 let vs = make_test_vital_signs();
2263 let counts = make_test_counts();
2264
2265 for newest_formula in [
2266 None,
2267 Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2268 Some(HEALTH_SCORE_FORMULA_VERSION),
2269 ] {
2270 let mut newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(72.0));
2271 newer.score_formula_version = newest_formula;
2272 let trend = compute_trend(&vs, &counts, Some(78.0), &[older.clone(), newer]).unwrap();
2273 assert_eq!(trend.compared_to.score, Some(72.0));
2274 assert_eq!(trend.snapshots_loaded, 2);
2275 assert_eq!(
2276 trend.metrics.iter().any(|metric| metric.name == "score"),
2277 newest_formula == Some(HEALTH_SCORE_FORMULA_VERSION)
2278 );
2279 }
2280 }
2281
2282 #[test]
2283 fn compute_trend_includes_raw_counts() {
2284 let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2285 let vs = make_test_vital_signs();
2286 let counts = make_test_counts();
2287
2288 let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2289 let dead_files = trend
2290 .metrics
2291 .iter()
2292 .find(|m| m.name == "dead_file_pct")
2293 .unwrap();
2294 assert!(dead_files.previous_count.is_some());
2295 assert!(dead_files.current_count.is_some());
2296 }
2297
2298 fn make_test_vital_signs() -> VitalSigns {
2299 VitalSigns {
2300 dead_file_pct: Some(3.2),
2301 dead_export_pct: Some(8.1),
2302 avg_cyclomatic: 4.2,
2303 p90_cyclomatic: 12,
2304 hotspot_count: Some(5),
2305 maintainability_avg: Some(72.4),
2306 unused_dep_count: Some(4),
2307 circular_dep_count: Some(2),
2308 ..Default::default()
2309 }
2310 }
2311
2312 fn make_test_counts() -> VitalSignsCounts {
2313 VitalSignsCounts {
2314 total_files: 100,
2315 total_exports: 500,
2316 dead_files: 3,
2317 dead_exports: 40,
2318 files_scored: Some(95),
2319 total_deps: 42,
2320 ..Default::default()
2321 }
2322 }
2323
2324 fn make_test_snapshot(timestamp: &str, score: Option<f64>) -> VitalSignsSnapshot {
2325 VitalSignsSnapshot {
2326 groups: None,
2327 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
2328 version: "2.5.5".into(),
2329 timestamp: timestamp.into(),
2330 git_sha: Some("abc1234".into()),
2331 git_branch: Some("main".into()),
2332 shallow_clone: false,
2333 vital_signs: VitalSigns {
2334 dead_file_pct: Some(3.2),
2335 dead_export_pct: Some(8.1),
2336 avg_cyclomatic: 4.7,
2337 p90_cyclomatic: 12,
2338 hotspot_count: Some(5),
2339 maintainability_avg: Some(72.4),
2340 unused_dep_count: Some(4),
2341 circular_dep_count: Some(2),
2342 ..Default::default()
2343 },
2344 counts: VitalSignsCounts {
2345 total_files: 100,
2346 total_exports: 500,
2347 dead_files: 3,
2348 dead_exports: 40,
2349 files_scored: Some(95),
2350 total_deps: 42,
2351 ..Default::default()
2352 },
2353 score,
2354 grade: score.map(|s| letter_grade(s).to_string()),
2355 score_formula_version: score.map(|_| HEALTH_SCORE_FORMULA_VERSION),
2356 coverage_model: None,
2357 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2358 }
2359 }
2360
2361 fn group_snapshot(key: &str, score: f64) -> fallow_output::GroupSnapshot {
2362 fallow_output::GroupSnapshot {
2363 key: key.to_owned(),
2364 files_analyzed: 10,
2365 vital_signs: VitalSigns {
2366 avg_cyclomatic: 3.0,
2367 p90_cyclomatic: 8,
2368 ..Default::default()
2369 },
2370 counts: VitalSignsCounts {
2371 total_files: 10,
2372 ..Default::default()
2373 },
2374 score: Some(score),
2375 grade: Some(letter_grade(score).to_string()),
2376 severity_critical_count: 2,
2377 hotspot_count: 1,
2378 }
2379 }
2380
2381 #[test]
2382 fn v10_snapshot_without_groups_still_deserializes() {
2383 let mut value =
2384 serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2385 value["snapshot_schema_version"] = serde_json::json!(10);
2386 value.as_object_mut().unwrap().remove("groups");
2387 let snapshot: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2388 assert_eq!(snapshot.snapshot_schema_version, 10);
2389 assert!(snapshot.groups.is_none());
2390 }
2391
2392 #[test]
2393 fn v11_snapshot_round_trips_group_data() {
2394 let mut snapshot = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2395 snapshot.groups = Some(fallow_output::SnapshotGrouping {
2396 grouped_by: "owner".to_owned(),
2397 group_filter: Some(vec!["@team/*".to_owned()]),
2398 groups: vec![group_snapshot("@team/a", 81.5)],
2399 });
2400 let json = serde_json::to_string(&snapshot).unwrap();
2401 let loaded: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
2402 let groups = loaded.groups.expect("groups survive the round trip");
2403 assert_eq!(groups.grouped_by, "owner");
2404 assert_eq!(groups.group_filter, Some(vec!["@team/*".to_owned()]));
2405 assert_eq!(groups.groups[0].key, "@team/a");
2406 assert_eq!(groups.groups[0].score, Some(81.5));
2407 assert_eq!(groups.groups[0].severity_critical_count, 2);
2408 let ungrouped =
2409 serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", None)).unwrap();
2410 assert!(
2411 ungrouped.get("groups").is_none(),
2412 "ungrouped snapshot omits groups"
2413 );
2414 }
2415
2416 #[test]
2417 fn explicit_trend_baseline_reads_that_file_only() {
2418 let dir = tempfile::tempdir().unwrap();
2419 let root = dir.path();
2420 let snap_dir = root.join(".fallow/snapshots");
2421 std::fs::create_dir_all(&snap_dir).unwrap();
2422 std::fs::write(
2423 snap_dir.join("newer.json"),
2424 serde_json::to_string(&make_test_snapshot("2026-09-01T00:00:00Z", Some(90.0))).unwrap(),
2425 )
2426 .unwrap();
2427 let explicit = root.join("restored.json");
2428 std::fs::write(
2429 &explicit,
2430 serde_json::to_string(&make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0))).unwrap(),
2431 )
2432 .unwrap();
2433
2434 let baseline = load_trend_baseline(root, Some(&explicit))
2435 .unwrap()
2436 .expect("explicit baseline");
2437 assert_eq!(baseline.snapshot.timestamp, "2026-01-01T00:00:00Z");
2438 assert_eq!(baseline.snapshots_loaded, 1);
2439 assert_eq!(baseline.path, explicit);
2440
2441 let directory = load_trend_baseline(root, None).unwrap().expect("newest");
2442 assert_eq!(directory.snapshot.timestamp, "2026-09-01T00:00:00Z");
2443 assert_eq!(directory.snapshots_loaded, 1);
2444 }
2445
2446 #[test]
2447 fn explicit_trend_baseline_fails_on_missing_or_invalid_file() {
2448 let dir = tempfile::tempdir().unwrap();
2449 let root = dir.path();
2450 let missing = load_trend_baseline(root, Some(&root.join("absent.json"))).unwrap_err();
2451 assert!(
2452 missing.contains("failed to read --trend-from snapshot"),
2453 "{missing}"
2454 );
2455 let invalid_path = root.join("invalid.json");
2456 std::fs::write(&invalid_path, "{\"not\": \"a snapshot\"}").unwrap();
2457 let invalid = load_trend_baseline(root, Some(&invalid_path)).unwrap_err();
2458 assert!(
2459 invalid.contains("is not a fallow health snapshot"),
2460 "{invalid}"
2461 );
2462 assert!(load_trend_baseline(root, None).unwrap().is_none());
2463 }
2464
2465 #[test]
2466 fn explicit_trend_baseline_error_does_not_echo_file_content() {
2467 let dir = tempfile::tempdir().unwrap();
2468 let root = dir.path();
2469 for (index, content) in [r#"{"timestamp": "private-value"}"#, r#"["private-value"]"#]
2470 .into_iter()
2471 .enumerate()
2472 {
2473 let path = root.join(format!("leak-{index}.json"));
2474 std::fs::write(&path, content).unwrap();
2475 let error = load_trend_baseline(root, Some(&path)).unwrap_err();
2476 assert!(error.contains("is not a fallow health snapshot"), "{error}");
2477 assert!(error.contains("line 1, column"), "{error}");
2478 assert!(!error.contains("private-value"), "{error}");
2479 }
2480 }
2481
2482 #[test]
2483 fn group_trend_compares_against_the_stored_group() {
2484 let baseline = TrendBaseline {
2485 snapshot: make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0)),
2486 path: PathBuf::from("baseline.json"),
2487 snapshots_loaded: 1,
2488 };
2489 let current = VitalSigns {
2490 avg_cyclomatic: 3.0,
2491 p90_cyclomatic: 8,
2492 ..Default::default()
2493 };
2494 let counts = VitalSignsCounts {
2495 total_files: 10,
2496 ..Default::default()
2497 };
2498 let current_score = HealthScore {
2499 score: 84.0,
2500 ..compute_health_score(¤t, counts.total_files)
2501 };
2502 let trend = compute_group_trend(
2503 &baseline.without_groups(),
2504 baseline.snapshots_loaded,
2505 &group_snapshot("@team/a", 80.0),
2506 ¤t,
2507 &counts,
2508 Some(¤t_score),
2509 );
2510 let score = trend
2511 .metrics
2512 .iter()
2513 .find(|metric| metric.name == "score")
2514 .expect("score metric");
2515 assert!((score.previous - 80.0).abs() < f64::EPSILON);
2516 assert!((score.delta - 4.0).abs() < 1e-9);
2517 assert_eq!(score.direction, TrendDirection::Improving);
2518 assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2519 assert_eq!(trend.compared_to.score, Some(80.0));
2520 }
2521}