1#[derive(Debug, serde::Serialize)]
9pub struct HealthReport {
10 pub findings: Vec<HealthFinding>,
12 pub summary: HealthSummary,
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub vital_signs: Option<VitalSigns>,
17 #[serde(skip_serializing_if = "Vec::is_empty")]
19 pub file_scores: Vec<FileHealthScore>,
20 #[serde(skip_serializing_if = "Vec::is_empty")]
22 pub hotspots: Vec<HotspotEntry>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub hotspot_summary: Option<HotspotSummary>,
26 #[serde(skip_serializing_if = "Vec::is_empty")]
28 pub targets: Vec<RefactoringTarget>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub target_thresholds: Option<TargetThresholds>,
32}
33
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct VitalSigns {
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub dead_file_pct: Option<f64>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub dead_export_pct: Option<f64>,
47 pub avg_cyclomatic: f64,
49 pub p90_cyclomatic: u32,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub duplication_pct: Option<f64>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub hotspot_count: Option<u32>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub maintainability_avg: Option<f64>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub unused_dep_count: Option<u32>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub circular_dep_count: Option<u32>,
66}
67
68#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
73pub struct VitalSignsCounts {
74 pub total_files: usize,
75 pub total_exports: usize,
76 pub dead_files: usize,
77 pub dead_exports: usize,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub duplicated_lines: Option<usize>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub total_lines: Option<usize>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub files_scored: Option<usize>,
84 pub total_deps: usize,
85}
86
87#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
89pub struct VitalSignsSnapshot {
90 pub snapshot_schema_version: u32,
92 pub version: String,
94 pub timestamp: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub git_sha: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub git_branch: Option<String>,
102 #[serde(default)]
104 pub shallow_clone: bool,
105 pub vital_signs: VitalSigns,
107 pub counts: VitalSignsCounts,
109}
110
111pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
113
114pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
116
117#[derive(Debug, serde::Serialize)]
119pub struct HealthFinding {
120 pub path: std::path::PathBuf,
122 pub name: String,
124 pub line: u32,
126 pub col: u32,
128 pub cyclomatic: u16,
130 pub cognitive: u16,
132 pub line_count: u32,
134 pub exceeded: ExceededThreshold,
136}
137
138#[derive(Debug, serde::Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ExceededThreshold {
142 Cyclomatic,
144 Cognitive,
146 Both,
148}
149
150#[derive(Debug, serde::Serialize)]
152pub struct HealthSummary {
153 pub files_analyzed: usize,
155 pub functions_analyzed: usize,
157 pub functions_above_threshold: usize,
159 pub max_cyclomatic_threshold: u16,
161 pub max_cognitive_threshold: u16,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub files_scored: Option<usize>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub average_maintainability: Option<f64>,
169}
170
171#[derive(Debug, Clone, serde::Serialize)]
191pub struct FileHealthScore {
192 pub path: std::path::PathBuf,
194 pub fan_in: usize,
196 pub fan_out: usize,
198 pub dead_code_ratio: f64,
202 pub complexity_density: f64,
204 pub maintainability_index: f64,
206 pub total_cyclomatic: u32,
208 pub total_cognitive: u32,
210 pub function_count: usize,
212 pub lines: u32,
214}
215
216#[derive(Debug, Clone, serde::Serialize)]
229pub struct HotspotEntry {
230 pub path: std::path::PathBuf,
232 pub score: f64,
234 pub commits: u32,
236 pub weighted_commits: f64,
238 pub lines_added: u32,
240 pub lines_deleted: u32,
242 pub complexity_density: f64,
244 pub fan_in: usize,
246 pub trend: fallow_core::churn::ChurnTrend,
248}
249
250#[derive(Debug, serde::Serialize)]
252pub struct HotspotSummary {
253 pub since: String,
255 pub min_commits: u32,
257 pub files_analyzed: usize,
259 pub files_excluded: usize,
261 pub shallow_clone: bool,
263}
264
265#[derive(Debug, Clone, serde::Serialize)]
270#[allow(clippy::struct_field_names)] pub struct TargetThresholds {
272 pub fan_in_p95: f64,
274 pub fan_in_p75: f64,
276 pub fan_out_p95: f64,
278 pub fan_out_p90: usize,
280}
281
282#[derive(Debug, Clone, serde::Serialize)]
284#[serde(rename_all = "snake_case")]
285pub enum RecommendationCategory {
286 UrgentChurnComplexity,
288 BreakCircularDependency,
290 SplitHighImpact,
292 RemoveDeadCode,
294 ExtractComplexFunctions,
296 ExtractDependencies,
298}
299
300impl RecommendationCategory {
301 pub const fn label(&self) -> &'static str {
303 match self {
304 Self::UrgentChurnComplexity => "churn+complexity",
305 Self::BreakCircularDependency => "circular dep",
306 Self::SplitHighImpact => "high impact",
307 Self::RemoveDeadCode => "dead code",
308 Self::ExtractComplexFunctions => "complexity",
309 Self::ExtractDependencies => "coupling",
310 }
311 }
312
313 pub const fn compact_label(&self) -> &'static str {
315 match self {
316 Self::UrgentChurnComplexity => "churn_complexity",
317 Self::BreakCircularDependency => "circular_dep",
318 Self::SplitHighImpact => "high_impact",
319 Self::RemoveDeadCode => "dead_code",
320 Self::ExtractComplexFunctions => "complexity",
321 Self::ExtractDependencies => "coupling",
322 }
323 }
324}
325
326#[derive(Debug, Clone, serde::Serialize)]
328pub struct ContributingFactor {
329 pub metric: &'static str,
331 pub value: f64,
333 pub threshold: f64,
335 pub detail: String,
337}
338
339#[derive(Debug, Clone, serde::Serialize)]
359#[serde(rename_all = "snake_case")]
360pub enum EffortEstimate {
361 Low,
363 Medium,
365 High,
367}
368
369impl EffortEstimate {
370 pub const fn label(&self) -> &'static str {
372 match self {
373 Self::Low => "low",
374 Self::Medium => "medium",
375 Self::High => "high",
376 }
377 }
378
379 pub const fn numeric(&self) -> f64 {
381 match self {
382 Self::Low => 1.0,
383 Self::Medium => 2.0,
384 Self::High => 3.0,
385 }
386 }
387}
388
389#[derive(Debug, Clone, serde::Serialize)]
396#[serde(rename_all = "snake_case")]
397pub enum Confidence {
398 High,
400 Medium,
402 Low,
404}
405
406impl Confidence {
407 pub const fn label(&self) -> &'static str {
409 match self {
410 Self::High => "high",
411 Self::Medium => "medium",
412 Self::Low => "low",
413 }
414 }
415}
416
417#[derive(Debug, Clone, serde::Serialize)]
422pub struct TargetEvidence {
423 #[serde(skip_serializing_if = "Vec::is_empty")]
425 pub unused_exports: Vec<String>,
426 #[serde(skip_serializing_if = "Vec::is_empty")]
428 pub complex_functions: Vec<EvidenceFunction>,
429 #[serde(skip_serializing_if = "Vec::is_empty")]
431 pub cycle_path: Vec<String>,
432}
433
434#[derive(Debug, Clone, serde::Serialize)]
436pub struct EvidenceFunction {
437 pub name: String,
439 pub line: u32,
441 pub cognitive: u16,
443}
444
445#[derive(Debug, Clone, serde::Serialize)]
446pub struct RefactoringTarget {
447 pub path: std::path::PathBuf,
449 pub priority: f64,
451 pub efficiency: f64,
454 pub recommendation: String,
456 pub category: RecommendationCategory,
458 pub effort: EffortEstimate,
460 pub confidence: Confidence,
462 #[serde(skip_serializing_if = "Vec::is_empty")]
464 pub factors: Vec<ContributingFactor>,
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub evidence: Option<TargetEvidence>,
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
477 fn category_labels_are_non_empty() {
478 let categories = [
479 RecommendationCategory::UrgentChurnComplexity,
480 RecommendationCategory::BreakCircularDependency,
481 RecommendationCategory::SplitHighImpact,
482 RecommendationCategory::RemoveDeadCode,
483 RecommendationCategory::ExtractComplexFunctions,
484 RecommendationCategory::ExtractDependencies,
485 ];
486 for cat in &categories {
487 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
488 }
489 }
490
491 #[test]
492 fn category_labels_are_unique() {
493 let categories = [
494 RecommendationCategory::UrgentChurnComplexity,
495 RecommendationCategory::BreakCircularDependency,
496 RecommendationCategory::SplitHighImpact,
497 RecommendationCategory::RemoveDeadCode,
498 RecommendationCategory::ExtractComplexFunctions,
499 RecommendationCategory::ExtractDependencies,
500 ];
501 let labels: Vec<&str> = categories
502 .iter()
503 .map(super::RecommendationCategory::label)
504 .collect();
505 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
506 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
507 }
508
509 #[test]
512 fn category_serializes_as_snake_case() {
513 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
514 assert_eq!(json, r#""urgent_churn_complexity""#);
515
516 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
517 assert_eq!(json, r#""break_circular_dependency""#);
518 }
519
520 #[test]
521 fn exceeded_threshold_serializes_as_snake_case() {
522 let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
523 assert_eq!(json, r#""both""#);
524
525 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
526 assert_eq!(json, r#""cyclomatic""#);
527 }
528
529 #[test]
530 fn health_report_skips_empty_collections() {
531 let report = HealthReport {
532 findings: vec![],
533 summary: HealthSummary {
534 files_analyzed: 0,
535 functions_analyzed: 0,
536 functions_above_threshold: 0,
537 max_cyclomatic_threshold: 20,
538 max_cognitive_threshold: 15,
539 files_scored: None,
540 average_maintainability: None,
541 },
542 vital_signs: None,
543 file_scores: vec![],
544 hotspots: vec![],
545 hotspot_summary: None,
546 targets: vec![],
547 target_thresholds: None,
548 };
549 let json = serde_json::to_string(&report).unwrap();
550 assert!(!json.contains("file_scores"));
552 assert!(!json.contains("hotspots"));
553 assert!(!json.contains("hotspot_summary"));
554 assert!(!json.contains("targets"));
555 assert!(!json.contains("vital_signs"));
556 }
557
558 #[test]
559 fn vital_signs_serialization_roundtrip() {
560 let vs = VitalSigns {
561 dead_file_pct: Some(3.2),
562 dead_export_pct: Some(8.1),
563 avg_cyclomatic: 4.7,
564 p90_cyclomatic: 12,
565 duplication_pct: None,
566 hotspot_count: Some(5),
567 maintainability_avg: Some(72.4),
568 unused_dep_count: Some(4),
569 circular_dep_count: Some(2),
570 };
571 let json = serde_json::to_string(&vs).unwrap();
572 let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
573 assert!((deserialized.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
574 assert_eq!(deserialized.p90_cyclomatic, 12);
575 assert_eq!(deserialized.hotspot_count, Some(5));
576 assert!(!json.contains("duplication_pct"));
578 assert!(deserialized.duplication_pct.is_none());
579 }
580
581 #[test]
582 fn vital_signs_snapshot_roundtrip() {
583 let snapshot = VitalSignsSnapshot {
584 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
585 version: "1.8.1".into(),
586 timestamp: "2026-03-25T14:30:00Z".into(),
587 git_sha: Some("abc1234".into()),
588 git_branch: Some("main".into()),
589 shallow_clone: false,
590 vital_signs: VitalSigns {
591 dead_file_pct: Some(3.2),
592 dead_export_pct: Some(8.1),
593 avg_cyclomatic: 4.7,
594 p90_cyclomatic: 12,
595 duplication_pct: None,
596 hotspot_count: None,
597 maintainability_avg: Some(72.4),
598 unused_dep_count: Some(4),
599 circular_dep_count: Some(2),
600 },
601 counts: VitalSignsCounts {
602 total_files: 1200,
603 total_exports: 5400,
604 dead_files: 38,
605 dead_exports: 437,
606 duplicated_lines: None,
607 total_lines: None,
608 files_scored: Some(1150),
609 total_deps: 42,
610 },
611 };
612 let json = serde_json::to_string_pretty(&snapshot).unwrap();
613 let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
614 assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
615 assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
616 assert_eq!(rt.counts.total_files, 1200);
617 assert_eq!(rt.counts.dead_exports, 437);
618 }
619
620 #[test]
621 fn refactoring_target_skips_empty_factors() {
622 let target = RefactoringTarget {
623 path: std::path::PathBuf::from("/src/foo.ts"),
624 priority: 75.0,
625 efficiency: 75.0,
626 recommendation: "Test recommendation".into(),
627 category: RecommendationCategory::RemoveDeadCode,
628 effort: EffortEstimate::Low,
629 confidence: Confidence::High,
630 factors: vec![],
631 evidence: None,
632 };
633 let json = serde_json::to_string(&target).unwrap();
634 assert!(!json.contains("factors"));
635 assert!(!json.contains("evidence"));
636 }
637
638 #[test]
639 fn effort_numeric_values() {
640 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
641 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
642 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
643 }
644
645 #[test]
646 fn confidence_labels_are_non_empty() {
647 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
648 for level in &levels {
649 assert!(!level.label().is_empty(), "{level:?} should have a label");
650 }
651 }
652
653 #[test]
654 fn confidence_serializes_as_snake_case() {
655 let json = serde_json::to_string(&Confidence::High).unwrap();
656 assert_eq!(json, r#""high""#);
657 let json = serde_json::to_string(&Confidence::Medium).unwrap();
658 assert_eq!(json, r#""medium""#);
659 let json = serde_json::to_string(&Confidence::Low).unwrap();
660 assert_eq!(json, r#""low""#);
661 }
662
663 #[test]
664 fn contributing_factor_serializes_correctly() {
665 let factor = ContributingFactor {
666 metric: "fan_in",
667 value: 15.0,
668 threshold: 10.0,
669 detail: "15 files depend on this".into(),
670 };
671 let json = serde_json::to_string(&factor).unwrap();
672 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
673 assert_eq!(parsed["metric"], "fan_in");
674 assert_eq!(parsed["value"], 15.0);
675 assert_eq!(parsed["threshold"], 10.0);
676 }
677
678 #[test]
681 fn category_compact_labels_are_non_empty() {
682 let categories = [
683 RecommendationCategory::UrgentChurnComplexity,
684 RecommendationCategory::BreakCircularDependency,
685 RecommendationCategory::SplitHighImpact,
686 RecommendationCategory::RemoveDeadCode,
687 RecommendationCategory::ExtractComplexFunctions,
688 RecommendationCategory::ExtractDependencies,
689 ];
690 for cat in &categories {
691 assert!(
692 !cat.compact_label().is_empty(),
693 "{cat:?} should have a compact_label"
694 );
695 }
696 }
697
698 #[test]
699 fn category_compact_labels_are_unique() {
700 let categories = [
701 RecommendationCategory::UrgentChurnComplexity,
702 RecommendationCategory::BreakCircularDependency,
703 RecommendationCategory::SplitHighImpact,
704 RecommendationCategory::RemoveDeadCode,
705 RecommendationCategory::ExtractComplexFunctions,
706 RecommendationCategory::ExtractDependencies,
707 ];
708 let labels: Vec<&str> = categories.iter().map(|c| c.compact_label()).collect();
709 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
710 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
711 }
712
713 #[test]
714 fn category_compact_labels_have_no_spaces() {
715 let categories = [
716 RecommendationCategory::UrgentChurnComplexity,
717 RecommendationCategory::BreakCircularDependency,
718 RecommendationCategory::SplitHighImpact,
719 RecommendationCategory::RemoveDeadCode,
720 RecommendationCategory::ExtractComplexFunctions,
721 RecommendationCategory::ExtractDependencies,
722 ];
723 for cat in &categories {
724 assert!(
725 !cat.compact_label().contains(' '),
726 "compact_label for {:?} should not contain spaces: '{}'",
727 cat,
728 cat.compact_label()
729 );
730 }
731 }
732
733 #[test]
736 fn effort_labels_are_non_empty() {
737 let efforts = [
738 EffortEstimate::Low,
739 EffortEstimate::Medium,
740 EffortEstimate::High,
741 ];
742 for effort in &efforts {
743 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
744 }
745 }
746
747 #[test]
748 fn effort_serializes_as_snake_case() {
749 assert_eq!(
750 serde_json::to_string(&EffortEstimate::Low).unwrap(),
751 r#""low""#
752 );
753 assert_eq!(
754 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
755 r#""medium""#
756 );
757 assert_eq!(
758 serde_json::to_string(&EffortEstimate::High).unwrap(),
759 r#""high""#
760 );
761 }
762
763 #[test]
766 fn vital_signs_all_none_optional_fields_omitted() {
767 let vs = VitalSigns {
768 dead_file_pct: None,
769 dead_export_pct: None,
770 avg_cyclomatic: 5.0,
771 p90_cyclomatic: 10,
772 duplication_pct: None,
773 hotspot_count: None,
774 maintainability_avg: None,
775 unused_dep_count: None,
776 circular_dep_count: None,
777 };
778 let json = serde_json::to_string(&vs).unwrap();
779 assert!(!json.contains("dead_file_pct"));
780 assert!(!json.contains("dead_export_pct"));
781 assert!(!json.contains("duplication_pct"));
782 assert!(!json.contains("hotspot_count"));
783 assert!(!json.contains("maintainability_avg"));
784 assert!(!json.contains("unused_dep_count"));
785 assert!(!json.contains("circular_dep_count"));
786 assert!(json.contains("avg_cyclomatic"));
788 assert!(json.contains("p90_cyclomatic"));
789 }
790
791 #[test]
794 fn exceeded_threshold_all_variants_serialize() {
795 for variant in [
796 ExceededThreshold::Cyclomatic,
797 ExceededThreshold::Cognitive,
798 ExceededThreshold::Both,
799 ] {
800 let json = serde_json::to_string(&variant).unwrap();
801 assert!(!json.is_empty());
802 }
803 }
804
805 #[test]
808 fn target_evidence_skips_empty_fields() {
809 let evidence = TargetEvidence {
810 unused_exports: vec![],
811 complex_functions: vec![],
812 cycle_path: vec![],
813 };
814 let json = serde_json::to_string(&evidence).unwrap();
815 assert!(!json.contains("unused_exports"));
816 assert!(!json.contains("complex_functions"));
817 assert!(!json.contains("cycle_path"));
818 }
819
820 #[test]
821 fn target_evidence_with_data() {
822 let evidence = TargetEvidence {
823 unused_exports: vec!["foo".to_string(), "bar".to_string()],
824 complex_functions: vec![EvidenceFunction {
825 name: "processData".into(),
826 line: 42,
827 cognitive: 30,
828 }],
829 cycle_path: vec![],
830 };
831 let json = serde_json::to_string(&evidence).unwrap();
832 assert!(json.contains("unused_exports"));
833 assert!(json.contains("complex_functions"));
834 assert!(json.contains("processData"));
835 assert!(!json.contains("cycle_path"));
836 }
837
838 #[test]
841 fn snapshot_schema_version_is_one() {
842 assert_eq!(SNAPSHOT_SCHEMA_VERSION, 1);
843 }
844
845 #[test]
846 fn hotspot_score_threshold_is_50() {
847 assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
848 }
849}