1use std::path::{Path, PathBuf};
8
9use crate::git_env::clear_ambient_git_env;
10
11const SECS_PER_DAY: u64 = 86_400;
13
14const HOTSPOT_TOP_FILE_FRACTION: f64 = 0.01;
16
17const HOTSPOT_MAX_PENALTY: f64 = 10.0;
19
20use fallow_output::{
21 DEFAULT_CYCLOMATIC_CRITICAL, FileHealthScore, HEALTH_SCORE_FORMULA_VERSION,
22 HOTSPOT_SCORE_THRESHOLD, HealthScore, HealthScorePenalties, HealthTrend, HotspotEntry,
23 RiskProfile, SNAPSHOT_SCHEMA_VERSION, TrendCount, TrendDirection, TrendMetric, TrendPoint,
24 VitalSigns, VitalSignsCounts, VitalSignsSnapshot, letter_grade,
25};
26
27pub(crate) struct VitalSignsInput<'a> {
31 pub(crate) modules: &'a [crate::source::ModuleInfo],
33 pub(crate) module_filter: Option<&'a rustc_hash::FxHashSet<crate::discover::FileId>>,
39 pub(crate) file_scores: Option<&'a [FileHealthScore]>,
41 pub(crate) hotspots: Option<&'a [HotspotEntry]>,
43 pub(crate) total_files: usize,
45 pub(crate) analysis_counts: Option<AnalysisCounts>,
50}
51
52impl<'a> VitalSignsInput<'a> {
53 fn selected_modules(&self) -> impl Iterator<Item = &'a crate::source::ModuleInfo> + '_ {
55 let filter = self.module_filter;
56 self.modules
57 .iter()
58 .filter(move |m| filter.is_none_or(|set| set.contains(&m.file_id)))
59 }
60}
61
62#[derive(Clone, Copy)]
64pub struct AnalysisCounts {
65 pub(crate) total_exports: usize,
66 pub(crate) dead_files: usize,
67 pub(crate) dead_exports: usize,
68 pub(crate) unused_deps: usize,
69 pub(crate) circular_deps: usize,
70 pub(crate) total_deps: usize,
71}
72
73fn collect_sorted_cyclomatic(input: &VitalSignsInput<'_>) -> Vec<u16> {
78 let mut values: Vec<u16> = input
79 .selected_modules()
80 .flat_map(|m| m.complexity.iter().map(|c| c.cyclomatic))
81 .collect();
82 values.sort_unstable();
83 values
84}
85
86fn average_cyclomatic(all_cyclomatic: &[u16]) -> f64 {
87 if all_cyclomatic.is_empty() {
88 return 0.0;
89 }
90
91 let sum: u64 = all_cyclomatic.iter().map(|&c| u64::from(c)).sum();
92 (sum as f64 / all_cyclomatic.len() as f64 * 10.0).round() / 10.0
93}
94
95fn critical_complexity_pct(all_cyclomatic: &[u16]) -> Option<f64> {
96 if all_cyclomatic.is_empty() {
97 return None;
98 }
99
100 let critical_count = all_cyclomatic
101 .iter()
102 .filter(|&&c| c >= DEFAULT_CYCLOMATIC_CRITICAL)
103 .count();
104 Some((critical_count as f64 / all_cyclomatic.len() as f64 * 1000.0).round() / 10.0)
105}
106
107#[expect(
108 clippy::cast_sign_loss,
109 reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
110)]
111#[expect(
112 clippy::cast_possible_truncation,
113 reason = "percentile index is bounded by the cyclomatic collection length"
114)]
115fn p90_cyclomatic(all_cyclomatic: &[u16]) -> u32 {
116 if all_cyclomatic.is_empty() {
117 return 0;
118 }
119
120 let idx = (all_cyclomatic.len() as f64 * 0.9).ceil() as usize;
121 let idx = idx.min(all_cyclomatic.len()) - 1;
122 u32::from(all_cyclomatic[idx])
123}
124
125#[expect(
126 clippy::cast_possible_truncation,
127 reason = "analysis counts are bounded by project size and emitted as compact u32 metrics"
128)]
129fn analysis_count_vitals(
130 counts: Option<&AnalysisCounts>,
131 total_files: usize,
132) -> (Option<f64>, Option<f64>, Option<u32>, Option<u32>) {
133 let Some(counts) = counts else {
134 return (None, None, None, None);
135 };
136
137 let dead_file_pct = if total_files > 0 {
138 Some((counts.dead_files as f64 / total_files as f64 * 1000.0).round() / 10.0)
139 } else {
140 Some(0.0)
141 };
142 let dead_export_pct = if counts.total_exports > 0 {
143 Some((counts.dead_exports as f64 / counts.total_exports as f64 * 1000.0).round() / 10.0)
144 } else {
145 Some(0.0)
146 };
147
148 (
149 dead_file_pct,
150 dead_export_pct,
151 Some(counts.unused_deps as u32),
152 Some(counts.circular_deps as u32),
153 )
154}
155
156struct SelectedModuleMetrics {
157 total_loc: u64,
158 line_counts: Vec<u32>,
159 param_counts: Vec<u8>,
160 cyclomatic_population: fallow_output::CyclomaticPopulation,
161}
162
163fn selected_module_metrics(input: &VitalSignsInput<'_>) -> SelectedModuleMetrics {
164 let mut total_loc = 0;
165 let mut line_counts = Vec::new();
166 let mut param_counts = Vec::new();
167 let mut cyclomatic_population = fallow_output::CyclomaticPopulation::default();
168
169 for module in input.selected_modules() {
170 total_loc += module.line_offsets.len() as u64;
171 for unit in &module.complexity {
176 let is_module = fallow_types::extract::is_synthetic_module_unit(&unit.name);
177 let population = if is_module {
178 &mut cyclomatic_population.modules
179 } else if fallow_types::extract::is_synthetic_template_unit(&unit.name) {
180 &mut cyclomatic_population.templates
181 } else {
182 &mut cyclomatic_population.functions
183 };
184 population.count += 1;
185 population.sum += u64::from(unit.cyclomatic);
186 population.max = Some(population.max.unwrap_or_default().max(unit.cyclomatic));
187 if is_module {
188 continue;
189 }
190 line_counts.push(unit.line_count);
191 param_counts.push(unit.param_count);
192 }
193 }
194
195 SelectedModuleMetrics {
196 total_loc,
197 line_counts,
198 param_counts,
199 cyclomatic_population,
200 }
201}
202
203fn vital_sign_counts(input: &VitalSignsInput<'_>, total_loc: u64) -> Option<VitalSignsCounts> {
204 input.analysis_counts.as_ref().map(|ac| VitalSignsCounts {
205 total_files: input.total_files,
206 total_exports: ac.total_exports,
207 dead_files: ac.dead_files,
208 dead_exports: ac.dead_exports,
209 duplicated_lines: None,
210 total_lines: Some(total_loc as usize),
211 files_scored: input.file_scores.map(<[_]>::len),
212 total_deps: ac.total_deps,
213 })
214}
215
216pub(crate) fn compute_vital_signs(input: &VitalSignsInput<'_>) -> VitalSigns {
218 let all_cyclomatic = collect_sorted_cyclomatic(input);
219 let avg_cyclomatic = average_cyclomatic(&all_cyclomatic);
220 let critical_complexity_pct = critical_complexity_pct(&all_cyclomatic);
221 let p90_cyclomatic = p90_cyclomatic(&all_cyclomatic);
222
223 let (dead_file_pct, dead_export_pct, unused_dep_count, circular_dep_count) =
224 analysis_count_vitals(input.analysis_counts.as_ref(), input.total_files);
225 let unused_deps_per_k_files =
226 unused_dep_count.map(|count| per_k_files(count, input.total_files));
227 let circular_deps_per_k_files =
228 circular_dep_count.map(|count| per_k_files(count, input.total_files));
229
230 let (maintainability_avg, maintainability_low_pct) = maintainability_vitals(input.file_scores);
231
232 let (hotspot_count, hotspot_top_pct_count) = hotspot_vitals(input.hotspots, input.total_files);
233
234 let module_metrics = selected_module_metrics(input);
235 let counts = vital_sign_counts(input, module_metrics.total_loc);
236 let functions_over_60_loc_per_k = functions_over_60_loc_per_k(&module_metrics.line_counts);
237 let unit_size_profile = unit_size_profile(&module_metrics.line_counts);
238
239 let unit_interfacing_profile =
240 unit_interfacing_profile(&module_metrics.param_counts, &all_cyclomatic);
241
242 let (p95_fan_in, coupling_high_pct) = if let Some(scores) = input.file_scores {
243 compute_coupling_concentration(scores)
244 } else {
245 (None, None)
246 };
247
248 VitalSigns {
249 dead_file_pct,
250 dead_export_pct,
251 avg_cyclomatic,
252 critical_complexity_pct,
253 p90_cyclomatic,
254 cyclomatic_population: Some(module_metrics.cyclomatic_population),
255 duplication_pct: None, hotspot_count,
257 hotspot_top_pct_count,
258 maintainability_avg,
259 maintainability_low_pct,
260 unused_dep_count,
261 unused_deps_per_k_files,
262 circular_dep_count,
263 circular_deps_per_k_files,
264 counts,
265 unit_size_profile,
266 functions_over_60_loc_per_k,
267 unit_interfacing_profile,
268 p95_fan_in,
269 coupling_high_pct,
270 prop_drilling_chain_count: None,
273 prop_drilling_max_depth: None,
274 p95_render_fan_in: None,
278 render_fan_in_high_pct: None,
279 max_render_fan_in: None,
280 top_render_fan_in: Vec::new(),
281 total_loc: module_metrics.total_loc,
282 }
283}
284
285fn per_k_files(count: u32, total_files: usize) -> f64 {
286 if total_files == 0 {
287 0.0
288 } else {
289 (f64::from(count) / total_files as f64 * 10_000.0).round() / 10.0
290 }
291}
292
293fn maintainability_vitals(scores: Option<&[FileHealthScore]>) -> (Option<f64>, Option<f64>) {
294 let Some(scores) = scores.filter(|scores| !scores.is_empty()) else {
295 return (None, None);
296 };
297 let sum: f64 = scores.iter().map(|s| s.maintainability_index).sum();
298 let low_count = scores
299 .iter()
300 .filter(|s| s.maintainability_index < 70.0)
301 .count();
302 (
303 Some((sum / scores.len() as f64 * 10.0).round() / 10.0),
304 Some((low_count as f64 / scores.len() as f64 * 1000.0).round() / 10.0),
305 )
306}
307
308#[expect(
309 clippy::cast_sign_loss,
310 reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
311)]
312fn hotspot_vitals(
313 hotspots: Option<&[HotspotEntry]>,
314 total_files: usize,
315) -> (Option<u32>, Option<u32>) {
316 let hotspot_count = hotspots.map(|entries| {
317 entries
318 .iter()
319 .filter(|e| e.score >= HOTSPOT_SCORE_THRESHOLD)
320 .count() as u32
321 });
322 let hotspot_top_pct_count = hotspots.map(|entries| {
323 if total_files == 0 || entries.is_empty() {
324 return 0;
325 }
326 let top_count = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION).ceil() as usize;
327 entries
328 .iter()
329 .take(top_count.max(1))
330 .filter(|entry| entry.score > 0.0)
331 .count() as u32
332 });
333 (hotspot_count, hotspot_top_pct_count)
334}
335
336fn functions_over_60_loc_per_k(line_counts: &[u32]) -> Option<f64> {
337 if line_counts.is_empty() {
338 return None;
339 }
340 let over_60 = line_counts
341 .iter()
342 .filter(|&&line_count| line_count > 60)
343 .count();
344 Some((over_60 as f64 / line_counts.len() as f64 * 10_000.0).round() / 10.0)
345}
346
347fn unit_size_profile(line_counts: &[u32]) -> Option<RiskProfile> {
348 (!line_counts.is_empty()).then(|| compute_size_risk_profile(line_counts))
349}
350
351fn unit_interfacing_profile(param_counts: &[u8], all_cyclomatic: &[u16]) -> Option<RiskProfile> {
352 if all_cyclomatic.is_empty() {
353 return None;
354 }
355 Some(compute_interfacing_risk_profile(param_counts))
356}
357
358fn compute_size_risk_profile(line_counts: &[u32]) -> RiskProfile {
362 if line_counts.is_empty() {
363 return RiskProfile {
364 low_risk: 0.0,
365 medium_risk: 0.0,
366 high_risk: 0.0,
367 very_high_risk: 0.0,
368 };
369 }
370 let total = line_counts.len() as f64;
371 let low = line_counts.iter().filter(|&&lc| lc <= 15).count() as f64;
372 let medium = line_counts
373 .iter()
374 .filter(|&&lc| (16..=30).contains(&lc))
375 .count() as f64;
376 let high = line_counts
377 .iter()
378 .filter(|&&lc| (31..=60).contains(&lc))
379 .count() as f64;
380 let very_high = line_counts.iter().filter(|&&lc| lc > 60).count() as f64;
381 RiskProfile {
382 low_risk: (low / total * 1000.0).round() / 10.0,
383 medium_risk: (medium / total * 1000.0).round() / 10.0,
384 high_risk: (high / total * 1000.0).round() / 10.0,
385 very_high_risk: (very_high / total * 1000.0).round() / 10.0,
386 }
387}
388
389fn compute_interfacing_risk_profile(param_counts: &[u8]) -> RiskProfile {
393 if param_counts.is_empty() {
394 return RiskProfile {
395 low_risk: 0.0,
396 medium_risk: 0.0,
397 high_risk: 0.0,
398 very_high_risk: 0.0,
399 };
400 }
401 let total = param_counts.len() as f64;
402 let low = param_counts.iter().filter(|&&pc| pc <= 2).count() as f64;
403 let medium = param_counts
404 .iter()
405 .filter(|&&pc| (3..=4).contains(&pc))
406 .count() as f64;
407 let high = param_counts
408 .iter()
409 .filter(|&&pc| (5..=6).contains(&pc))
410 .count() as f64;
411 let very_high = param_counts.iter().filter(|&&pc| pc >= 7).count() as f64;
412 RiskProfile {
413 low_risk: (low / total * 1000.0).round() / 10.0,
414 medium_risk: (medium / total * 1000.0).round() / 10.0,
415 high_risk: (high / total * 1000.0).round() / 10.0,
416 very_high_risk: (very_high / total * 1000.0).round() / 10.0,
417 }
418}
419
420#[expect(
433 clippy::cast_sign_loss,
434 reason = "percentile indexes of non-negative counts; float-to-int casts saturate at zero"
435)]
436#[expect(
437 clippy::cast_possible_truncation,
438 reason = "fan-in values are bounded by project size"
439)]
440fn compute_coupling_concentration(scores: &[FileHealthScore]) -> (Option<u32>, Option<f64>) {
441 if scores.is_empty() {
442 return (None, None);
443 }
444 let mut fan_ins: Vec<usize> = scores.iter().map(|s| s.fan_in).collect();
445 fan_ins.sort_unstable();
446 let idx = (fan_ins.len() as f64 * 0.95).ceil() as usize;
447 let idx = idx.min(fan_ins.len()) - 1;
448 let p95 = fan_ins[idx] as u32;
449
450 let threshold = (p95 as usize).max(10);
451 let high_count = fan_ins.iter().filter(|&&fi| fi > threshold).count();
452 let high_pct = (high_count as f64 / fan_ins.len() as f64 * 1000.0).round() / 10.0;
453
454 (Some(p95), Some(high_pct))
455}
456
457pub(crate) fn compute_health_score(vs: &VitalSigns, total_files: usize) -> HealthScore {
463 let penalties = compute_health_score_penalties(vs, total_files);
464 let score = apply_health_score_penalties(&penalties);
465 let grade = letter_grade(score);
466
467 HealthScore {
468 formula_version: HEALTH_SCORE_FORMULA_VERSION,
469 score,
470 grade,
471 penalties,
472 }
473}
474
475fn compute_health_score_penalties(vs: &VitalSigns, total_files: usize) -> HealthScorePenalties {
476 HealthScorePenalties {
477 dead_files: vs.dead_file_pct.map(|pct| round1((pct * 0.2).min(15.0))),
478 dead_exports: vs.dead_export_pct.map(|pct| round1((pct * 0.2).min(15.0))),
479 complexity: complexity_penalty(vs),
480 p90_complexity: p90_complexity_penalty(vs),
481 maintainability: maintainability_penalty(vs),
482 hotspots: hotspot_penalty(vs, total_files),
483 unused_deps: dependency_count_penalty(
484 vs.unused_deps_per_k_files,
485 vs.unused_dep_count,
486 25.0,
487 10.0,
488 ),
489 circular_deps: dependency_count_penalty(
490 vs.circular_deps_per_k_files,
491 vs.circular_dep_count,
492 25.0,
493 10.0,
494 ),
495 unit_size: unit_size_penalty(vs),
496 coupling: coupling_penalty(vs),
497 duplication: vs
498 .duplication_pct
499 .map(|dp| round1((dp - 5.0).clamp(0.0, 10.0))),
500 prop_drilling: prop_drilling_penalty(vs),
501 }
502}
503
504fn apply_health_score_penalties(penalties: &HealthScorePenalties) -> f64 {
505 let mut score = 100.0_f64;
506
507 subtract_optional_penalty(&mut score, penalties.dead_files);
508 subtract_optional_penalty(&mut score, penalties.dead_exports);
509 score -= penalties.complexity;
510 score -= penalties.p90_complexity;
511 subtract_optional_penalty(&mut score, penalties.maintainability);
512 subtract_optional_penalty(&mut score, penalties.hotspots);
513 subtract_optional_penalty(&mut score, penalties.unused_deps);
514 subtract_optional_penalty(&mut score, penalties.circular_deps);
515 subtract_optional_penalty(&mut score, penalties.unit_size);
516 subtract_optional_penalty(&mut score, penalties.coupling);
517 subtract_optional_penalty(&mut score, penalties.duplication);
518 subtract_optional_penalty(&mut score, penalties.prop_drilling);
519
520 round1(score).clamp(0.0, 100.0)
521}
522
523fn prop_drilling_penalty(vs: &VitalSigns) -> Option<f64> {
529 vs.prop_drilling_chain_count
530 .map(|count| round1((f64::from(count) * 1.0).min(5.0)))
531}
532
533fn round1(value: f64) -> f64 {
534 (value * 10.0).round() / 10.0
535}
536
537fn subtract_optional_penalty(score: &mut f64, penalty: Option<f64>) {
538 if let Some(penalty) = penalty {
539 *score -= penalty;
540 }
541}
542
543fn complexity_penalty(vs: &VitalSigns) -> f64 {
544 if let Some(critical_pct) = vs.critical_complexity_pct {
545 round1((critical_pct * 4.0).min(20.0))
546 } else {
547 round1(((vs.avg_cyclomatic - 1.5).max(0.0) * 5.0).min(20.0))
548 }
549}
550
551fn p90_complexity_penalty(vs: &VitalSigns) -> f64 {
552 if vs.critical_complexity_pct.is_some() {
553 0.0
554 } else {
555 round1((f64::from(vs.p90_cyclomatic) - 10.0).clamp(0.0, 10.0))
556 }
557}
558
559fn maintainability_penalty(vs: &VitalSigns) -> Option<f64> {
560 if let Some(low_pct) = vs.maintainability_low_pct {
561 Some(round1((low_pct * 1.5).min(15.0)))
562 } else {
563 vs.maintainability_avg
564 .map(|mi| round1(((70.0 - mi).max(0.0) * 0.5).min(15.0)))
565 }
566}
567
568fn hotspot_penalty(vs: &VitalSigns, total_files: usize) -> Option<f64> {
569 vs.hotspot_count.map(|count| {
570 if total_files == 0 {
571 return 0.0;
572 }
573 let top_pct_bucket = (total_files as f64 * HOTSPOT_TOP_FILE_FRACTION)
574 .ceil()
575 .max(1.0);
576 round1((f64::from(count) / top_pct_bucket * HOTSPOT_MAX_PENALTY).min(HOTSPOT_MAX_PENALTY))
577 })
578}
579
580fn dependency_count_penalty(
581 per_k: Option<f64>,
582 count: Option<u32>,
583 per_k_cap: f64,
584 count_cap: f64,
585) -> Option<f64> {
586 if let Some(per_k) = per_k {
587 Some(round1((per_k * 0.5).min(per_k_cap)))
588 } else {
589 count.map(|count| round1(f64::from(count).min(count_cap)))
590 }
591}
592
593fn unit_size_penalty(vs: &VitalSigns) -> Option<f64> {
594 if let Some(per_k) = vs.functions_over_60_loc_per_k {
595 Some(round1((per_k * 0.5).min(10.0)))
596 } else {
597 vs.unit_size_profile
598 .as_ref()
599 .map(|profile| round1(((profile.very_high_risk - 5.0).max(0.0) * 0.5).min(10.0)))
600 }
601}
602
603fn coupling_penalty(vs: &VitalSigns) -> Option<f64> {
604 if let Some(high_pct) = vs.coupling_high_pct {
605 Some(round1((high_pct * 0.5).min(5.0)))
606 } else {
607 vs.p95_fan_in
608 .map(|p95| round1(((f64::from(p95) - 30.0).max(0.0) * 0.25).min(5.0)))
609 }
610}
611
612pub(crate) fn build_counts(input: &VitalSignsInput<'_>) -> VitalSignsCounts {
614 let (total_exports, dead_files, dead_exports, total_deps) = input
615 .analysis_counts
616 .as_ref()
617 .map_or((0, 0, 0, 0), |counts| {
618 (
619 counts.total_exports,
620 counts.dead_files,
621 counts.dead_exports,
622 counts.total_deps,
623 )
624 });
625
626 let total_lines: usize = input.selected_modules().map(|m| m.line_offsets.len()).sum();
627
628 VitalSignsCounts {
629 total_files: input.total_files,
630 total_exports,
631 dead_files,
632 dead_exports,
633 duplicated_lines: None,
634 total_lines: Some(total_lines),
635 files_scored: input.file_scores.map(<[_]>::len),
636 total_deps,
637 }
638}
639
640#[expect(
642 clippy::disallowed_methods,
643 reason = "trusted git spawn with ambient repo-state env stripped, matching the core git spawn policy"
644)]
645fn git_branch(root: &Path) -> Option<String> {
646 let mut command = std::process::Command::new("git");
647 command
648 .args(["rev-parse", "--abbrev-ref", "HEAD"])
649 .current_dir(root);
650 clear_ambient_git_env(&mut command);
651 command
652 .output()
653 .ok()
654 .filter(|o| o.status.success())
655 .and_then(|o| {
656 let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
657 if name == "HEAD" { None } else { Some(name) }
658 })
659}
660
661#[expect(
663 clippy::too_many_arguments,
664 reason = "snapshot construction keeps every persisted compatibility input explicit"
665)]
666pub(crate) fn build_snapshot(
667 vital_signs: VitalSigns,
668 counts: VitalSignsCounts,
669 root: &Path,
670 shallow_clone: bool,
671 health_score: Option<&HealthScore>,
672 coverage_model: Option<fallow_output::CoverageModel>,
673 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
674) -> VitalSignsSnapshot {
675 let now = chrono_timestamp();
676
677 VitalSignsSnapshot {
678 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
679 version: env!("CARGO_PKG_VERSION").to_string(),
680 timestamp: now,
681 git_sha: crate::repo_refs::short_head_sha(root),
682 git_branch: git_branch(root),
683 shallow_clone,
684 vital_signs,
685 counts,
686 score: health_score.map(|s| s.score),
687 grade: health_score.map(|s| s.grade.to_string()),
688 score_formula_version: health_score.map(|s| s.formula_version),
689 coverage_model,
690 analysis_identity,
691 groups: None,
692 }
693}
694
695pub fn chrono_timestamp() -> String {
697 use std::time::SystemTime;
698 let now = SystemTime::now()
699 .duration_since(SystemTime::UNIX_EPOCH)
700 .unwrap_or_default();
701 let secs = now.as_secs();
702
703 let days = secs / SECS_PER_DAY;
704 let time_secs = secs % SECS_PER_DAY;
705 let hours = time_secs / 3600;
706 let minutes = (time_secs % 3600) / 60;
707 let seconds = time_secs % 60;
708
709 let (year, month, day) = days_to_ymd(days);
710
711 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
712}
713
714const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
716 let z = days + 719_468;
717 let era = z / 146_097;
718 let doe = z - era * 146_097;
719 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
720 let y = yoe + era * 400;
721 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
722 let mp = (5 * doy + 2) / 153;
723 let d = doy - (153 * mp + 2) / 5 + 1;
724 let m = if mp < 10 { mp + 3 } else { mp - 9 };
725 let y = if m <= 2 { y + 1 } else { y };
726 (y, m, d)
727}
728
729pub(crate) fn save_snapshot(
734 snapshot: &VitalSignsSnapshot,
735 root: &Path,
736 explicit_path: Option<&Path>,
737) -> Result<PathBuf, String> {
738 let path = explicit_path.map_or_else(
739 || {
740 let dir = root.join(".fallow").join("snapshots");
741 let filename = snapshot.timestamp.replace(':', "-");
742 dir.join(format!("{filename}.json"))
743 },
744 Path::to_path_buf,
745 );
746
747 let json =
748 serde_json::to_string_pretty(snapshot).map_err(|e| format!("failed to serialize: {e}"))?;
749 crate::write_guard::write_file(
750 &path,
751 json.as_bytes(),
752 crate::write_guard::WriteTarget::Path,
753 )
754 .map_err(|e| {
755 if e.is_directory() {
756 format!("failed to create snapshot directory: {e}")
757 } else {
758 format!("failed to write snapshot: {e}")
759 }
760 })?;
761
762 Ok(path)
763}
764
765#[cfg(test)]
770pub(crate) fn load_snapshots(root: &Path) -> Vec<VitalSignsSnapshot> {
771 load_snapshots_with_paths(root)
772 .into_iter()
773 .map(|(_, snapshot)| snapshot)
774 .collect()
775}
776
777#[expect(
781 clippy::print_stderr,
782 reason = "corrupt-snapshot warnings to stderr, preserved verbatim from the CLI health path"
783)]
784fn load_snapshots_with_paths(root: &Path) -> Vec<(PathBuf, VitalSignsSnapshot)> {
785 let dir = root.join(".fallow").join("snapshots");
786 let Ok(entries) = std::fs::read_dir(&dir) else {
787 return Vec::new();
788 };
789
790 let mut snapshots = Vec::new();
791 for entry in entries {
792 let Ok(entry) = entry else { continue };
793 let path = entry.path();
794 if path.extension().is_some_and(|ext| ext == "json") {
795 match std::fs::read_to_string(&path) {
796 Ok(content) => match serde_json::from_str::<VitalSignsSnapshot>(&content) {
797 Ok(snap) => snapshots.push((path, snap)),
798 Err(e) => {
799 eprintln!("warning: skipping corrupt snapshot {}: {e}", path.display());
800 record_unreadable_snapshot(root, &path, &e.to_string());
801 }
802 },
803 Err(e) => {
804 eprintln!("warning: could not read snapshot {}: {e}", path.display());
805 record_unreadable_snapshot(root, &path, &e.to_string());
806 }
807 }
808 }
809 }
810
811 snapshots.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
812 snapshots
813}
814
815#[derive(Debug, Clone)]
817pub(crate) struct TrendBaseline {
818 pub(crate) snapshot: VitalSignsSnapshot,
820 pub(crate) path: PathBuf,
822 pub(crate) snapshots_loaded: usize,
825}
826
827impl TrendBaseline {
828 pub(crate) fn without_groups(&self) -> VitalSignsSnapshot {
833 let mut snapshot = self.snapshot.clone();
834 snapshot.groups = None;
835 snapshot
836 }
837}
838
839pub(crate) fn load_trend_baseline(
849 root: &Path,
850 explicit: Option<&Path>,
851) -> Result<Option<TrendBaseline>, String> {
852 if let Some(path) = explicit {
853 let content = std::fs::read_to_string(path).map_err(|e| {
854 format!(
855 "failed to read --trend-from snapshot {}: {e}",
856 path.display()
857 )
858 })?;
859 let snapshot = serde_json::from_str::<VitalSignsSnapshot>(&content).map_err(|e| {
862 format!(
863 "--trend-from file {} is not a fallow health snapshot (parse error at line {}, column {})",
864 path.display(),
865 e.line(),
866 e.column()
867 )
868 })?;
869 return Ok(Some(TrendBaseline {
870 snapshot,
871 path: path.to_path_buf(),
872 snapshots_loaded: 1,
873 }));
874 }
875 let mut snapshots = load_snapshots_with_paths(root);
876 let snapshots_loaded = snapshots.len();
877 Ok(snapshots.pop().map(|(path, snapshot)| TrendBaseline {
878 snapshot,
879 path,
880 snapshots_loaded,
881 }))
882}
883
884fn record_unreadable_snapshot(root: &Path, path: &Path, error: &str) {
887 crate::health::diagnostics::record_health_diagnostic(
888 root,
889 Some(path),
890 fallow_types::workspace::WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
891 error: error.to_owned(),
892 },
893 );
894}
895
896const TREND_TOLERANCE: f64 = 0.5;
898
899fn trend_point_from_snapshot(prev: &VitalSignsSnapshot) -> TrendPoint {
900 TrendPoint {
901 timestamp: prev.timestamp.clone(),
902 git_sha: prev.git_sha.clone(),
903 score: prev.score,
904 grade: prev.grade.clone(),
905 score_formula_version: prev.score_formula_version,
906 coverage_model: prev.coverage_model.clone(),
907 snapshot_schema_version: Some(prev.snapshot_schema_version),
908 }
909}
910
911fn overall_trend_direction(metrics: &[TrendMetric]) -> TrendDirection {
912 let (improving, declining) = metrics.iter().fold((0usize, 0usize), |(imp, dec), metric| {
913 match metric.direction {
914 TrendDirection::Improving => (imp + 1, dec),
915 TrendDirection::Declining => (imp, dec + 1),
916 TrendDirection::Stable => (imp, dec),
917 }
918 });
919
920 match improving.cmp(&declining) {
921 std::cmp::Ordering::Greater => TrendDirection::Improving,
922 std::cmp::Ordering::Less => TrendDirection::Declining,
923 std::cmp::Ordering::Equal => TrendDirection::Stable,
924 }
925}
926
927#[cfg(test)]
932pub(crate) fn compute_trend(
933 current_vs: &VitalSigns,
934 current_counts: &VitalSignsCounts,
935 current_score: Option<f64>,
936 snapshots: &[VitalSignsSnapshot],
937) -> Option<HealthTrend> {
938 let prev = snapshots.last()?;
939 let current_score = current_score.map(|score| HealthScore {
940 score,
941 ..compute_health_score(current_vs, current_counts.total_files)
942 });
943 Some(compute_trend_against(
944 prev,
945 snapshots.len(),
946 current_vs,
947 current_counts,
948 current_score.as_ref(),
949 ))
950}
951
952pub(crate) fn compute_trend_against(
956 prev: &VitalSignsSnapshot,
957 snapshots_loaded: usize,
958 current_vs: &VitalSigns,
959 current_counts: &VitalSignsCounts,
960 current_score: Option<&HealthScore>,
961) -> HealthTrend {
962 let compared_to = trend_point_from_snapshot(prev);
963
964 let metrics = TrendBuilder::new(prev, current_vs, current_counts, current_score).build();
965
966 let overall_direction = overall_trend_direction(&metrics);
967
968 HealthTrend {
969 compared_to,
970 metrics,
971 snapshots_loaded,
972 overall_direction,
973 }
974}
975
976pub(crate) fn compute_group_trend(
984 base: &VitalSignsSnapshot,
985 snapshots_loaded: usize,
986 previous: &fallow_output::GroupSnapshot,
987 current_vs: &VitalSigns,
988 current_counts: &VitalSignsCounts,
989 current_score: Option<&HealthScore>,
990) -> HealthTrend {
991 let prev = VitalSignsSnapshot {
992 vital_signs: previous.vital_signs.clone(),
993 counts: previous.counts.clone(),
994 score: previous.score,
995 grade: previous.grade.clone(),
996 ..base.clone()
997 };
998 compute_trend_against(
999 &prev,
1000 snapshots_loaded,
1001 current_vs,
1002 current_counts,
1003 current_score,
1004 )
1005}
1006
1007struct TrendBuilder<'a> {
1008 prev: &'a VitalSignsSnapshot,
1009 current_vs: &'a VitalSigns,
1010 current_counts: &'a VitalSignsCounts,
1011 current_score: Option<&'a HealthScore>,
1012 metrics: Vec<TrendMetric>,
1013}
1014
1015impl TrendBuilder<'_> {
1016 fn new<'a>(
1017 prev: &'a VitalSignsSnapshot,
1018 current_vs: &'a VitalSigns,
1019 current_counts: &'a VitalSignsCounts,
1020 current_score: Option<&'a HealthScore>,
1021 ) -> TrendBuilder<'a> {
1022 TrendBuilder {
1023 prev,
1024 current_vs,
1025 current_counts,
1026 current_score,
1027 metrics: Vec::new(),
1028 }
1029 }
1030
1031 fn build(mut self) -> Vec<TrendMetric> {
1032 self.add_score_metric();
1033 self.add_dead_code_metrics();
1034 self.add_complexity_metrics();
1035 self.add_dependency_metrics();
1036 self.add_structure_metrics();
1037 self.metrics
1038 }
1039
1040 fn push(&mut self, input: TrendMetricInput) {
1041 self.metrics.push(make_metric(input));
1042 }
1043
1044 fn add_score_metric(&mut self) {
1045 let Some(current) = self.current_score else {
1046 return;
1047 };
1048 if self.prev.score_formula_version != Some(current.formula_version) {
1049 return;
1050 }
1051 if let Some(prev_score) = self.prev.score {
1052 self.push(TrendMetricInput {
1053 name: "score",
1054 label: "Health Score",
1055 previous: prev_score,
1056 current: current.score,
1057 unit: "",
1058 higher_is_better: true,
1059 previous_count: None,
1060 current_count: None,
1061 });
1062 }
1063 }
1064
1065 fn add_dead_code_metrics(&mut self) {
1066 if let (Some(prev_val), Some(cur_val)) = (
1067 self.prev.vital_signs.dead_file_pct,
1068 self.current_vs.dead_file_pct,
1069 ) {
1070 self.push(TrendMetricInput {
1071 name: "dead_file_pct",
1072 label: "Dead Files",
1073 previous: prev_val,
1074 current: cur_val,
1075 unit: "%",
1076 higher_is_better: false,
1077 previous_count: Some(TrendCount {
1078 value: self.prev.counts.dead_files,
1079 total: self.prev.counts.total_files,
1080 }),
1081 current_count: Some(TrendCount {
1082 value: self.current_counts.dead_files,
1083 total: self.current_counts.total_files,
1084 }),
1085 });
1086 }
1087
1088 if let (Some(prev_val), Some(cur_val)) = (
1089 self.prev.vital_signs.dead_export_pct,
1090 self.current_vs.dead_export_pct,
1091 ) {
1092 self.push(TrendMetricInput {
1093 name: "dead_export_pct",
1094 label: "Dead Exports",
1095 previous: prev_val,
1096 current: cur_val,
1097 unit: "%",
1098 higher_is_better: false,
1099 previous_count: Some(TrendCount {
1100 value: self.prev.counts.dead_exports,
1101 total: self.prev.counts.total_exports,
1102 }),
1103 current_count: Some(TrendCount {
1104 value: self.current_counts.dead_exports,
1105 total: self.current_counts.total_exports,
1106 }),
1107 });
1108 }
1109 }
1110
1111 fn add_complexity_metrics(&mut self) {
1112 self.push(TrendMetricInput {
1113 name: "avg_cyclomatic",
1114 label: "Avg Cyclomatic",
1115 previous: self.prev.vital_signs.avg_cyclomatic,
1116 current: self.current_vs.avg_cyclomatic,
1117 unit: "",
1118 higher_is_better: false,
1119 previous_count: None,
1120 current_count: None,
1121 });
1122
1123 if let (Some(prev_val), Some(cur_val)) = (
1124 self.prev.vital_signs.maintainability_avg,
1125 self.current_vs.maintainability_avg,
1126 ) {
1127 self.push(TrendMetricInput {
1128 name: "maintainability_avg",
1129 label: "Maintainability",
1130 previous: prev_val,
1131 current: cur_val,
1132 unit: "",
1133 higher_is_better: true,
1134 previous_count: None,
1135 current_count: None,
1136 });
1137 }
1138
1139 if let (Some(prev_profile), Some(cur_profile)) = (
1140 &self.prev.vital_signs.unit_size_profile,
1141 &self.current_vs.unit_size_profile,
1142 ) {
1143 self.push(TrendMetricInput {
1144 name: "unit_size_very_high_pct",
1145 label: "Oversized Fns",
1146 previous: prev_profile.very_high_risk,
1147 current: cur_profile.very_high_risk,
1148 unit: "%",
1149 higher_is_better: false,
1150 previous_count: None,
1151 current_count: None,
1152 });
1153 }
1154
1155 self.add_duplication_metric();
1156 }
1157
1158 fn add_duplication_metric(&mut self) {
1159 if let (Some(prev_val), Some(cur_val)) = (
1160 self.prev.vital_signs.duplication_pct,
1161 self.current_vs.duplication_pct,
1162 ) {
1163 self.push(TrendMetricInput {
1164 name: "duplication_pct",
1165 label: "Duplication",
1166 previous: prev_val,
1167 current: cur_val,
1168 unit: "%",
1169 higher_is_better: false,
1170 previous_count: self
1171 .prev
1172 .counts
1173 .duplicated_lines
1174 .zip(self.prev.counts.total_lines)
1175 .map(|(d, t)| TrendCount { value: d, total: t }),
1176 current_count: self
1177 .current_counts
1178 .duplicated_lines
1179 .zip(self.current_counts.total_lines)
1180 .map(|(d, t)| TrendCount { value: d, total: t }),
1181 });
1182 }
1183 }
1184
1185 fn add_dependency_metrics(&mut self) {
1186 if let (Some(prev_val), Some(cur_val)) = (
1187 self.prev.vital_signs.unused_dep_count,
1188 self.current_vs.unused_dep_count,
1189 ) {
1190 self.push(TrendMetricInput {
1191 name: "unused_dep_count",
1192 label: "Unused Deps",
1193 previous: f64::from(prev_val),
1194 current: f64::from(cur_val),
1195 unit: "",
1196 higher_is_better: false,
1197 previous_count: None,
1198 current_count: None,
1199 });
1200 }
1201 }
1202
1203 fn add_structure_metrics(&mut self) {
1204 if let (Some(prev_val), Some(cur_val)) = (
1205 self.prev.vital_signs.circular_dep_count,
1206 self.current_vs.circular_dep_count,
1207 ) {
1208 self.push(TrendMetricInput {
1209 name: "circular_dep_count",
1210 label: "Circular Deps",
1211 previous: f64::from(prev_val),
1212 current: f64::from(cur_val),
1213 unit: "",
1214 higher_is_better: false,
1215 previous_count: None,
1216 current_count: None,
1217 });
1218 }
1219
1220 if let (Some(prev_val), Some(cur_val)) = (
1221 self.prev.vital_signs.hotspot_count,
1222 self.current_vs.hotspot_count,
1223 ) {
1224 self.push(TrendMetricInput {
1225 name: "hotspot_count",
1226 label: "Hotspots",
1227 previous: f64::from(prev_val),
1228 current: f64::from(cur_val),
1229 unit: "",
1230 higher_is_better: false,
1231 previous_count: None,
1232 current_count: None,
1233 });
1234 }
1235
1236 if let (Some(prev_val), Some(cur_val)) =
1237 (self.prev.vital_signs.p95_fan_in, self.current_vs.p95_fan_in)
1238 {
1239 self.push(TrendMetricInput {
1240 name: "p95_fan_in",
1241 label: "P95 Fan-in",
1242 previous: f64::from(prev_val),
1243 current: f64::from(cur_val),
1244 unit: "",
1245 higher_is_better: false,
1246 previous_count: None,
1247 current_count: None,
1248 });
1249 }
1250 }
1251}
1252
1253struct TrendMetricInput {
1255 name: &'static str,
1256 label: &'static str,
1257 previous: f64,
1258 current: f64,
1259 unit: &'static str,
1260 higher_is_better: bool,
1261 previous_count: Option<TrendCount>,
1262 current_count: Option<TrendCount>,
1263}
1264
1265fn make_metric(input: TrendMetricInput) -> TrendMetric {
1266 let TrendMetricInput {
1267 name,
1268 label,
1269 previous,
1270 current,
1271 unit,
1272 higher_is_better,
1273 previous_count,
1274 current_count,
1275 } = input;
1276 let delta = (current - previous).round_to(1);
1277 let direction = if delta.abs() < TREND_TOLERANCE {
1278 TrendDirection::Stable
1279 } else if (higher_is_better && delta > 0.0) || (!higher_is_better && delta < 0.0) {
1280 TrendDirection::Improving
1281 } else {
1282 TrendDirection::Declining
1283 };
1284
1285 TrendMetric {
1286 name,
1287 label,
1288 previous,
1289 current,
1290 delta,
1291 direction,
1292 unit,
1293 previous_count,
1294 current_count,
1295 }
1296}
1297
1298trait RoundTo {
1300 fn round_to(self, decimals: u32) -> Self;
1301}
1302
1303impl RoundTo for f64 {
1304 fn round_to(self, decimals: u32) -> Self {
1305 let factor = 10_f64.powi(i32::try_from(decimals).unwrap_or(i32::MAX));
1306 (self * factor).round() / factor
1307 }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 fn make_module(id: u32, cyclomatic: u16) -> crate::source::ModuleInfo {
1315 crate::source::ModuleInfo {
1316 complexity: vec![fallow_types::extract::FunctionComplexity {
1317 name: format!("fn_{id}"),
1318 is_private_member: false,
1319 line: id + 1,
1320 col: 0,
1321 cyclomatic,
1322 cognitive: 0,
1323 line_count: 10,
1324 param_count: 0,
1325 react_hook_count: 0,
1326 react_jsx_max_depth: 0,
1327 react_prop_count: 0,
1328 source_hash: None,
1329 contributions: Vec::new(),
1330 }],
1331 ..crate::source::ModuleInfo::empty(crate::discover::FileId(id))
1332 }
1333 }
1334
1335 #[expect(
1336 clippy::cast_possible_truncation,
1337 reason = "test values are trivially small"
1338 )]
1339 fn make_modules() -> Vec<crate::source::ModuleInfo> {
1340 (0..10)
1341 .map(|i| make_module(i, (i as u16 + 1) * 2))
1342 .collect()
1343 }
1344
1345 fn assert_close(actual: f64, expected: f64) {
1346 assert!(
1347 (actual - expected).abs() < f64::EPSILON,
1348 "expected {expected}, got {actual}"
1349 );
1350 }
1351
1352 fn assert_some_close(actual: Option<f64>, expected: f64) {
1353 assert_close(actual.expect("expected metric to be present"), expected);
1354 }
1355
1356 #[test]
1357 fn compute_cyclomatic_stats() {
1358 let modules = make_modules();
1359 let input = VitalSignsInput {
1360 modules: &modules,
1361 module_filter: None,
1362 file_scores: None,
1363 hotspots: None,
1364 total_files: 10,
1365 analysis_counts: None,
1366 };
1367 let vs = compute_vital_signs(&input);
1368 assert!((vs.avg_cyclomatic - 11.0).abs() < f64::EPSILON);
1369 assert_eq!(vs.p90_cyclomatic, 18);
1370 }
1371
1372 #[test]
1373 fn cyclomatic_population_partitions_units_and_respects_module_filter() {
1374 let mut mixed = make_module(0, 1);
1375 let mut module_unit = mixed.complexity[0].clone();
1376 module_unit.name = "<module>".into();
1377 module_unit.cyclomatic = 31;
1378 let mut template_unit = module_unit.clone();
1379 template_unit.name = "<template>".into();
1380 template_unit.cyclomatic = 4;
1381 mixed.complexity.extend([module_unit, template_unit]);
1382 let modules = [mixed, make_module(1, 100)];
1383 let filter = rustc_hash::FxHashSet::from_iter([crate::discover::FileId(0)]);
1384 let input = VitalSignsInput {
1385 modules: &modules,
1386 module_filter: Some(&filter),
1387 file_scores: None,
1388 hotspots: None,
1389 total_files: 1,
1390 analysis_counts: None,
1391 };
1392 let vs = compute_vital_signs(&input);
1393 let population = vs.cyclomatic_population.unwrap();
1394 assert_eq!(population.functions.count, 1);
1395 assert_eq!(population.modules.count, 1);
1396 assert_eq!(population.templates.count, 1);
1397 assert_eq!(population.functions.sum, 1);
1398 assert_eq!(population.modules.sum, 31);
1399 assert_eq!(population.templates.sum, 4);
1400 assert_close(vs.avg_cyclomatic, 12.0);
1401 assert_eq!(vs.p90_cyclomatic, 31);
1402 }
1403
1404 #[test]
1405 fn compute_with_analysis_counts() {
1406 let modules = make_modules();
1407 let input = VitalSignsInput {
1408 modules: &modules,
1409 module_filter: None,
1410 file_scores: None,
1411 hotspots: None,
1412 total_files: 100,
1413 analysis_counts: Some(AnalysisCounts {
1414 total_exports: 500,
1415 dead_files: 5,
1416 dead_exports: 50,
1417 unused_deps: 3,
1418 circular_deps: 2,
1419 total_deps: 40,
1420 }),
1421 };
1422 let vs = compute_vital_signs(&input);
1423 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));
1426 assert_eq!(vs.circular_dep_count, Some(2));
1427 }
1428
1429 #[test]
1430 fn compute_hotspot_count_with_threshold() {
1431 let hotspots = vec![
1432 HotspotEntry {
1433 path: PathBuf::from("a.ts"),
1434 score: 80.0,
1435 commits: 10,
1436 weighted_commits: 8.0,
1437 lines_added: 100,
1438 lines_deleted: 50,
1439 complexity_density: 0.5,
1440 fan_in: 5,
1441 trend: crate::churn::ChurnTrend::Stable,
1442 ownership: None,
1443 is_test_path: false,
1444 },
1445 HotspotEntry {
1446 path: PathBuf::from("b.ts"),
1447 score: 30.0, commits: 5,
1449 weighted_commits: 3.0,
1450 lines_added: 40,
1451 lines_deleted: 20,
1452 complexity_density: 0.2,
1453 fan_in: 2,
1454 trend: crate::churn::ChurnTrend::Cooling,
1455 ownership: None,
1456 is_test_path: false,
1457 },
1458 HotspotEntry {
1459 path: PathBuf::from("c.ts"),
1460 score: 50.0, commits: 8,
1462 weighted_commits: 6.0,
1463 lines_added: 80,
1464 lines_deleted: 30,
1465 complexity_density: 0.4,
1466 fan_in: 3,
1467 trend: crate::churn::ChurnTrend::Accelerating,
1468 ownership: None,
1469 is_test_path: false,
1470 },
1471 ];
1472 let modules = Vec::new();
1473 let input = VitalSignsInput {
1474 modules: &modules,
1475 module_filter: None,
1476 file_scores: None,
1477 hotspots: Some(&hotspots),
1478 total_files: 10,
1479 analysis_counts: None,
1480 };
1481 let vs = compute_vital_signs(&input);
1482 assert_eq!(vs.hotspot_count, Some(2)); assert_eq!(vs.hotspot_top_pct_count, Some(1)); }
1485
1486 #[test]
1487 fn hotspot_penalty_ignores_subthreshold_ranked_entries() {
1488 let hotspots: Vec<HotspotEntry> = [49.9, 0.1]
1489 .into_iter()
1490 .enumerate()
1491 .map(|(index, score)| HotspotEntry {
1492 path: PathBuf::from(format!("src/active-{index}.ts")),
1493 score,
1494 commits: 3,
1495 weighted_commits: 3.0,
1496 lines_added: 10,
1497 lines_deleted: 5,
1498 complexity_density: 0.1,
1499 fan_in: 0,
1500 trend: crate::churn::ChurnTrend::Stable,
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 ownership: None,
1998 is_test_path: false,
1999 })
2000 .collect();
2001 let input = VitalSignsInput {
2002 modules: &[],
2003 module_filter: None,
2004 file_scores: None,
2005 hotspots: Some(&hotspots),
2006 total_files,
2007 analysis_counts: None,
2008 };
2009 let vitals = compute_vital_signs(&input);
2010 let score = compute_health_score(&vitals, total_files);
2011 assert_some_close(score.penalties.hotspots, expected);
2012 assert_eq!(score.formula_version, HEALTH_SCORE_FORMULA_VERSION);
2013 }
2014 }
2015
2016 #[test]
2017 fn hotspot_penalty_requires_thresholded_measurement_and_ignores_rank_diagnostic() {
2018 let mut vitals = VitalSigns {
2019 hotspot_top_pct_count: Some(6),
2020 ..Default::default()
2021 };
2022 assert!(
2023 compute_health_score(&vitals, 563)
2024 .penalties
2025 .hotspots
2026 .is_none()
2027 );
2028
2029 vitals.hotspot_count = Some(1);
2030 for diagnostic in [None, Some(0), Some(6)] {
2031 vitals.hotspot_top_pct_count = diagnostic;
2032 assert_some_close(compute_health_score(&vitals, 563).penalties.hotspots, 1.7);
2033 }
2034 }
2035
2036 #[test]
2037 fn snapshot_persists_the_computed_score_formula() {
2038 let dir = tempfile::tempdir().unwrap();
2039 let vitals = make_test_vital_signs();
2040 let counts = make_test_counts();
2041 let mut health = compute_health_score(&vitals, counts.total_files);
2042 health.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2043 let snapshot = build_snapshot(
2044 vitals,
2045 counts,
2046 dir.path(),
2047 false,
2048 Some(&health),
2049 None,
2050 fallow_types::semantic::SemanticAnalysisIdentity::default(),
2051 );
2052 assert_eq!(snapshot.score_formula_version, Some(health.formula_version));
2053 assert_eq!(snapshot.score, Some(health.score));
2054 assert_eq!(snapshot.grade.as_deref(), Some(health.grade));
2055
2056 let unscored = build_snapshot(
2057 snapshot.vital_signs,
2058 snapshot.counts,
2059 dir.path(),
2060 false,
2061 None,
2062 None,
2063 fallow_types::semantic::SemanticAnalysisIdentity::default(),
2064 );
2065 assert!(unscored.score_formula_version.is_none());
2066 }
2067
2068 #[test]
2069 fn trend_requires_both_scores_even_with_matching_formula_identity() {
2070 let current_vs = make_test_vital_signs();
2071 let counts = make_test_counts();
2072 let health = compute_health_score(¤t_vs, counts.total_files);
2073 for (previous_score, current_score) in [(None, Some(&health)), (Some(72.0), None)] {
2074 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", previous_score);
2075 previous.score_formula_version = Some(health.formula_version);
2076 let trend = compute_trend_against(&previous, 1, ¤t_vs, &counts, current_score);
2077 assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2078 assert!(
2079 trend
2080 .metrics
2081 .iter()
2082 .any(|metric| metric.name == "avg_cyclomatic")
2083 );
2084 assert!(
2085 fallow_output::health_score_comparison_note(
2086 previous.score_formula_version,
2087 Some(health.formula_version),
2088 )
2089 .is_none()
2090 );
2091 }
2092 }
2093
2094 #[test]
2095 fn trend_compares_only_known_matching_score_formulas() {
2096 let current_vs = make_test_vital_signs();
2097 let counts = make_test_counts();
2098 let current_score = compute_health_score(¤t_vs, counts.total_files);
2099 for previous_formula in [
2100 None,
2101 Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2102 Some(HEALTH_SCORE_FORMULA_VERSION),
2103 Some(HEALTH_SCORE_FORMULA_VERSION + 1),
2104 ] {
2105 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2106 previous.score_formula_version = previous_formula;
2107 let trend =
2108 compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(¤t_score));
2109 assert_eq!(
2110 trend.metrics.iter().any(|metric| metric.name == "score"),
2111 previous_formula == Some(current_score.formula_version),
2112 "previous formula: {previous_formula:?}"
2113 );
2114 assert!(
2115 trend
2116 .metrics
2117 .iter()
2118 .any(|metric| metric.name == "avg_cyclomatic")
2119 );
2120 assert_eq!(trend.compared_to.score, previous.score);
2121 assert_eq!(trend.compared_to.grade, previous.grade);
2122 assert_eq!(trend.compared_to.score_formula_version, previous_formula);
2123 }
2124 }
2125
2126 #[test]
2127 fn trend_uses_actual_current_formula_and_keeps_raw_direction_on_mismatch() {
2128 let current_vs = make_test_vital_signs();
2129 let counts = make_test_counts();
2130 let mut current_score = compute_health_score(¤t_vs, counts.total_files);
2131 current_score.formula_version = HEALTH_SCORE_FORMULA_VERSION + 1;
2132 current_score.score = 90.0;
2133 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2134 previous.vital_signs = current_vs.clone();
2135 previous.counts = counts.clone();
2136 previous.vital_signs.avg_cyclomatic = current_vs.avg_cyclomatic - 2.0;
2137
2138 let incompatible =
2139 compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(¤t_score));
2140 assert!(
2141 !incompatible
2142 .metrics
2143 .iter()
2144 .any(|metric| metric.name == "score")
2145 );
2146 assert_eq!(incompatible.overall_direction, TrendDirection::Declining);
2147
2148 previous.score_formula_version = Some(current_score.formula_version);
2149 let compatible =
2150 compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(¤t_score));
2151 assert!(
2152 compatible
2153 .metrics
2154 .iter()
2155 .any(|metric| metric.name == "score")
2156 );
2157 }
2158
2159 #[test]
2160 fn legacy_snapshot_retains_unknown_formula_without_tool_version_inference() {
2161 let mut value =
2162 serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2163 value["snapshot_schema_version"] = serde_json::json!(11);
2164 value["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
2165 value
2166 .as_object_mut()
2167 .unwrap()
2168 .remove("score_formula_version");
2169 let previous: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2170 assert!(previous.score_formula_version.is_none());
2171 let current_vs = make_test_vital_signs();
2172 let counts = make_test_counts();
2173 let health = compute_health_score(¤t_vs, counts.total_files);
2174 let trend = compute_trend_against(&previous, 1, ¤t_vs, &counts, Some(&health));
2175 assert!(!trend.metrics.iter().any(|metric| metric.name == "score"));
2176 assert!(!trend.metrics.is_empty());
2177 }
2178
2179 #[test]
2180 fn group_trend_uses_snapshot_level_formula_identity() {
2181 let current_vs = make_test_vital_signs();
2182 let counts = make_test_counts();
2183 let health = compute_health_score(¤t_vs, counts.total_files);
2184 let group = group_snapshot("@team/a", 70.0);
2185 for formula in [
2186 None,
2187 Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2188 Some(health.formula_version),
2189 ] {
2190 let mut previous = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2191 previous.score_formula_version = formula;
2192 let trend =
2193 compute_group_trend(&previous, 1, &group, ¤t_vs, &counts, Some(&health));
2194 assert_eq!(
2195 trend.metrics.iter().any(|metric| metric.name == "score"),
2196 formula == Some(health.formula_version),
2197 );
2198 assert_eq!(trend.compared_to.score, group.score);
2199 assert_eq!(trend.compared_to.grade, group.grade);
2200 assert_eq!(trend.compared_to.score_formula_version, formula);
2201 assert!(!trend.metrics.is_empty());
2202 }
2203 }
2204
2205 #[test]
2206 fn compute_trend_no_snapshots() {
2207 let vs = make_test_vital_signs();
2208 let counts = make_test_counts();
2209 assert!(compute_trend(&vs, &counts, Some(78.0), &[]).is_none());
2210 }
2211
2212 #[test]
2213 fn compute_trend_improving() {
2214 let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2215 let vs = VitalSigns {
2216 dead_file_pct: Some(2.8),
2217 dead_export_pct: Some(7.5),
2218 avg_cyclomatic: 4.1,
2219 p90_cyclomatic: 12,
2220 hotspot_count: Some(3),
2221 maintainability_avg: Some(75.0),
2222 unused_dep_count: Some(3),
2223 circular_dep_count: Some(1),
2224 ..Default::default()
2225 };
2226 let counts = VitalSignsCounts {
2227 total_files: 100,
2228 total_exports: 500,
2229 dead_files: 3,
2230 dead_exports: 38,
2231 files_scored: Some(95),
2232 total_deps: 40,
2233 ..Default::default()
2234 };
2235
2236 let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2237 assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2238 assert_eq!(trend.snapshots_loaded, 1);
2239 assert_eq!(trend.overall_direction, TrendDirection::Improving);
2240
2241 let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2242 assert_eq!(score_metric.direction, TrendDirection::Improving);
2243 assert!((score_metric.delta - 6.0).abs() < f64::EPSILON);
2244 }
2245
2246 #[test]
2247 fn compute_trend_stable_within_tolerance() {
2248 let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(78.0));
2249 let vs = make_test_vital_signs();
2250 let counts = make_test_counts();
2251
2252 let trend = compute_trend(&vs, &counts, Some(78.3), &[prev]).unwrap();
2253 let score_metric = trend.metrics.iter().find(|m| m.name == "score").unwrap();
2254 assert_eq!(score_metric.direction, TrendDirection::Stable);
2255 }
2256
2257 #[test]
2258 fn compute_trend_uses_most_recent_snapshot() {
2259 let older = make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0));
2260 let vs = make_test_vital_signs();
2261 let counts = make_test_counts();
2262
2263 for newest_formula in [
2264 None,
2265 Some(HEALTH_SCORE_FORMULA_VERSION - 1),
2266 Some(HEALTH_SCORE_FORMULA_VERSION),
2267 ] {
2268 let mut newer = make_test_snapshot("2026-03-01T00:00:00Z", Some(72.0));
2269 newer.score_formula_version = newest_formula;
2270 let trend = compute_trend(&vs, &counts, Some(78.0), &[older.clone(), newer]).unwrap();
2271 assert_eq!(trend.compared_to.score, Some(72.0));
2272 assert_eq!(trend.snapshots_loaded, 2);
2273 assert_eq!(
2274 trend.metrics.iter().any(|metric| metric.name == "score"),
2275 newest_formula == Some(HEALTH_SCORE_FORMULA_VERSION)
2276 );
2277 }
2278 }
2279
2280 #[test]
2281 fn compute_trend_includes_raw_counts() {
2282 let prev = make_test_snapshot("2026-01-01T00:00:00Z", Some(72.0));
2283 let vs = make_test_vital_signs();
2284 let counts = make_test_counts();
2285
2286 let trend = compute_trend(&vs, &counts, Some(78.0), &[prev]).unwrap();
2287 let dead_files = trend
2288 .metrics
2289 .iter()
2290 .find(|m| m.name == "dead_file_pct")
2291 .unwrap();
2292 assert!(dead_files.previous_count.is_some());
2293 assert!(dead_files.current_count.is_some());
2294 }
2295
2296 fn make_test_vital_signs() -> VitalSigns {
2297 VitalSigns {
2298 dead_file_pct: Some(3.2),
2299 dead_export_pct: Some(8.1),
2300 avg_cyclomatic: 4.2,
2301 p90_cyclomatic: 12,
2302 hotspot_count: Some(5),
2303 maintainability_avg: Some(72.4),
2304 unused_dep_count: Some(4),
2305 circular_dep_count: Some(2),
2306 ..Default::default()
2307 }
2308 }
2309
2310 fn make_test_counts() -> VitalSignsCounts {
2311 VitalSignsCounts {
2312 total_files: 100,
2313 total_exports: 500,
2314 dead_files: 3,
2315 dead_exports: 40,
2316 files_scored: Some(95),
2317 total_deps: 42,
2318 ..Default::default()
2319 }
2320 }
2321
2322 fn make_test_snapshot(timestamp: &str, score: Option<f64>) -> VitalSignsSnapshot {
2323 VitalSignsSnapshot {
2324 groups: None,
2325 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
2326 version: "2.5.5".into(),
2327 timestamp: timestamp.into(),
2328 git_sha: Some("abc1234".into()),
2329 git_branch: Some("main".into()),
2330 shallow_clone: false,
2331 vital_signs: VitalSigns {
2332 dead_file_pct: Some(3.2),
2333 dead_export_pct: Some(8.1),
2334 avg_cyclomatic: 4.7,
2335 p90_cyclomatic: 12,
2336 hotspot_count: Some(5),
2337 maintainability_avg: Some(72.4),
2338 unused_dep_count: Some(4),
2339 circular_dep_count: Some(2),
2340 ..Default::default()
2341 },
2342 counts: VitalSignsCounts {
2343 total_files: 100,
2344 total_exports: 500,
2345 dead_files: 3,
2346 dead_exports: 40,
2347 files_scored: Some(95),
2348 total_deps: 42,
2349 ..Default::default()
2350 },
2351 score,
2352 grade: score.map(|s| letter_grade(s).to_string()),
2353 score_formula_version: score.map(|_| HEALTH_SCORE_FORMULA_VERSION),
2354 coverage_model: None,
2355 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
2356 }
2357 }
2358
2359 fn group_snapshot(key: &str, score: f64) -> fallow_output::GroupSnapshot {
2360 fallow_output::GroupSnapshot {
2361 key: key.to_owned(),
2362 files_analyzed: 10,
2363 vital_signs: VitalSigns {
2364 avg_cyclomatic: 3.0,
2365 p90_cyclomatic: 8,
2366 ..Default::default()
2367 },
2368 counts: VitalSignsCounts {
2369 total_files: 10,
2370 ..Default::default()
2371 },
2372 score: Some(score),
2373 grade: Some(letter_grade(score).to_string()),
2374 severity_critical_count: 2,
2375 hotspot_count: 1,
2376 }
2377 }
2378
2379 #[test]
2380 fn v10_snapshot_without_groups_still_deserializes() {
2381 let mut value =
2382 serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0))).unwrap();
2383 value["snapshot_schema_version"] = serde_json::json!(10);
2384 value.as_object_mut().unwrap().remove("groups");
2385 let snapshot: VitalSignsSnapshot = serde_json::from_value(value).unwrap();
2386 assert_eq!(snapshot.snapshot_schema_version, 10);
2387 assert!(snapshot.groups.is_none());
2388 }
2389
2390 #[test]
2391 fn v11_snapshot_round_trips_group_data() {
2392 let mut snapshot = make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0));
2393 snapshot.groups = Some(fallow_output::SnapshotGrouping {
2394 grouped_by: "owner".to_owned(),
2395 group_filter: Some(vec!["@team/*".to_owned()]),
2396 groups: vec![group_snapshot("@team/a", 81.5)],
2397 });
2398 let json = serde_json::to_string(&snapshot).unwrap();
2399 let loaded: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
2400 let groups = loaded.groups.expect("groups survive the round trip");
2401 assert_eq!(groups.grouped_by, "owner");
2402 assert_eq!(groups.group_filter, Some(vec!["@team/*".to_owned()]));
2403 assert_eq!(groups.groups[0].key, "@team/a");
2404 assert_eq!(groups.groups[0].score, Some(81.5));
2405 assert_eq!(groups.groups[0].severity_critical_count, 2);
2406 let ungrouped =
2407 serde_json::to_value(make_test_snapshot("2026-01-01T00:00:00Z", None)).unwrap();
2408 assert!(
2409 ungrouped.get("groups").is_none(),
2410 "ungrouped snapshot omits groups"
2411 );
2412 }
2413
2414 #[test]
2415 fn explicit_trend_baseline_reads_that_file_only() {
2416 let dir = tempfile::tempdir().unwrap();
2417 let root = dir.path();
2418 let snap_dir = root.join(".fallow/snapshots");
2419 std::fs::create_dir_all(&snap_dir).unwrap();
2420 std::fs::write(
2421 snap_dir.join("newer.json"),
2422 serde_json::to_string(&make_test_snapshot("2026-09-01T00:00:00Z", Some(90.0))).unwrap(),
2423 )
2424 .unwrap();
2425 let explicit = root.join("restored.json");
2426 std::fs::write(
2427 &explicit,
2428 serde_json::to_string(&make_test_snapshot("2026-01-01T00:00:00Z", Some(60.0))).unwrap(),
2429 )
2430 .unwrap();
2431
2432 let baseline = load_trend_baseline(root, Some(&explicit))
2433 .unwrap()
2434 .expect("explicit baseline");
2435 assert_eq!(baseline.snapshot.timestamp, "2026-01-01T00:00:00Z");
2436 assert_eq!(baseline.snapshots_loaded, 1);
2437 assert_eq!(baseline.path, explicit);
2438
2439 let directory = load_trend_baseline(root, None).unwrap().expect("newest");
2440 assert_eq!(directory.snapshot.timestamp, "2026-09-01T00:00:00Z");
2441 assert_eq!(directory.snapshots_loaded, 1);
2442 }
2443
2444 #[test]
2445 fn explicit_trend_baseline_fails_on_missing_or_invalid_file() {
2446 let dir = tempfile::tempdir().unwrap();
2447 let root = dir.path();
2448 let missing = load_trend_baseline(root, Some(&root.join("absent.json"))).unwrap_err();
2449 assert!(
2450 missing.contains("failed to read --trend-from snapshot"),
2451 "{missing}"
2452 );
2453 let invalid_path = root.join("invalid.json");
2454 std::fs::write(&invalid_path, "{\"not\": \"a snapshot\"}").unwrap();
2455 let invalid = load_trend_baseline(root, Some(&invalid_path)).unwrap_err();
2456 assert!(
2457 invalid.contains("is not a fallow health snapshot"),
2458 "{invalid}"
2459 );
2460 assert!(load_trend_baseline(root, None).unwrap().is_none());
2461 }
2462
2463 #[test]
2464 fn explicit_trend_baseline_error_does_not_echo_file_content() {
2465 let dir = tempfile::tempdir().unwrap();
2466 let root = dir.path();
2467 for (index, content) in [r#"{"timestamp": "private-value"}"#, r#"["private-value"]"#]
2468 .into_iter()
2469 .enumerate()
2470 {
2471 let path = root.join(format!("leak-{index}.json"));
2472 std::fs::write(&path, content).unwrap();
2473 let error = load_trend_baseline(root, Some(&path)).unwrap_err();
2474 assert!(error.contains("is not a fallow health snapshot"), "{error}");
2475 assert!(error.contains("line 1, column"), "{error}");
2476 assert!(!error.contains("private-value"), "{error}");
2477 }
2478 }
2479
2480 #[test]
2481 fn group_trend_compares_against_the_stored_group() {
2482 let baseline = TrendBaseline {
2483 snapshot: make_test_snapshot("2026-01-01T00:00:00Z", Some(70.0)),
2484 path: PathBuf::from("baseline.json"),
2485 snapshots_loaded: 1,
2486 };
2487 let current = VitalSigns {
2488 avg_cyclomatic: 3.0,
2489 p90_cyclomatic: 8,
2490 ..Default::default()
2491 };
2492 let counts = VitalSignsCounts {
2493 total_files: 10,
2494 ..Default::default()
2495 };
2496 let current_score = HealthScore {
2497 score: 84.0,
2498 ..compute_health_score(¤t, counts.total_files)
2499 };
2500 let trend = compute_group_trend(
2501 &baseline.without_groups(),
2502 baseline.snapshots_loaded,
2503 &group_snapshot("@team/a", 80.0),
2504 ¤t,
2505 &counts,
2506 Some(¤t_score),
2507 );
2508 let score = trend
2509 .metrics
2510 .iter()
2511 .find(|metric| metric.name == "score")
2512 .expect("score metric");
2513 assert!((score.previous - 80.0).abs() < f64::EPSILON);
2514 assert!((score.delta - 4.0).abs() < 1e-9);
2515 assert_eq!(score.direction, TrendDirection::Improving);
2516 assert_eq!(trend.compared_to.timestamp, "2026-01-01T00:00:00Z");
2517 assert_eq!(trend.compared_to.score, Some(80.0));
2518 }
2519}