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 = "Option::is_none")]
19 pub health_score: Option<HealthScore>,
20 #[serde(skip_serializing_if = "Vec::is_empty")]
22 pub file_scores: Vec<FileHealthScore>,
23 #[serde(skip_serializing_if = "Vec::is_empty")]
25 pub hotspots: Vec<HotspotEntry>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub hotspot_summary: Option<HotspotSummary>,
29 #[serde(skip_serializing_if = "Vec::is_empty")]
31 pub targets: Vec<RefactoringTarget>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub target_thresholds: Option<TargetThresholds>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub health_trend: Option<HealthTrend>,
38}
39
40#[derive(Debug, Clone, serde::Serialize)]
63pub struct HealthScore {
64 pub score: f64,
66 pub grade: &'static str,
68 pub penalties: HealthScorePenalties,
70}
71
72#[derive(Debug, Clone, serde::Serialize)]
77pub struct HealthScorePenalties {
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub dead_files: Option<f64>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub dead_exports: Option<f64>,
84 pub complexity: f64,
86 pub p90_complexity: f64,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub maintainability: Option<f64>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub hotspots: Option<f64>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub unused_deps: Option<f64>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub circular_deps: Option<f64>,
100}
101
102#[must_use]
104#[expect(
105 clippy::cast_possible_truncation,
106 reason = "score is 0-100, fits in u32"
107)]
108pub const fn letter_grade(score: f64) -> &'static str {
109 let s = score as u32;
112 if s >= 85 {
113 "A"
114 } else if s >= 70 {
115 "B"
116 } else if s >= 55 {
117 "C"
118 } else if s >= 40 {
119 "D"
120 } else {
121 "F"
122 }
123}
124
125#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131pub struct VitalSigns {
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub dead_file_pct: Option<f64>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub dead_export_pct: Option<f64>,
138 pub avg_cyclomatic: f64,
140 pub p90_cyclomatic: u32,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub duplication_pct: Option<f64>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub hotspot_count: Option<u32>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub maintainability_avg: Option<f64>,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub unused_dep_count: Option<u32>,
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub circular_dep_count: Option<u32>,
157}
158
159#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
164pub struct VitalSignsCounts {
165 pub total_files: usize,
166 pub total_exports: usize,
167 pub dead_files: usize,
168 pub dead_exports: usize,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub duplicated_lines: Option<usize>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub total_lines: Option<usize>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub files_scored: Option<usize>,
175 pub total_deps: usize,
176}
177
178#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
180pub struct VitalSignsSnapshot {
181 pub snapshot_schema_version: u32,
183 pub version: String,
185 pub timestamp: String,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub git_sha: Option<String>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub git_branch: Option<String>,
193 #[serde(default)]
195 pub shallow_clone: bool,
196 pub vital_signs: VitalSigns,
198 pub counts: VitalSignsCounts,
200 #[serde(skip_serializing_if = "Option::is_none", default)]
202 pub score: Option<f64>,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
205 pub grade: Option<String>,
206}
207
208pub const SNAPSHOT_SCHEMA_VERSION: u32 = 2;
211
212#[derive(Debug, Clone, serde::Serialize)]
218pub struct HealthTrend {
219 pub compared_to: TrendPoint,
221 pub metrics: Vec<TrendMetric>,
223 pub snapshots_loaded: usize,
225 pub overall_direction: TrendDirection,
227}
228
229#[derive(Debug, Clone, serde::Serialize)]
231pub struct TrendPoint {
232 pub timestamp: String,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub git_sha: Option<String>,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub score: Option<f64>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub grade: Option<String>,
243}
244
245#[derive(Debug, Clone, serde::Serialize)]
247pub struct TrendMetric {
248 pub name: &'static str,
250 pub label: &'static str,
252 pub previous: f64,
254 pub current: f64,
256 pub delta: f64,
258 pub direction: TrendDirection,
260 pub unit: &'static str,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub previous_count: Option<TrendCount>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub current_count: Option<TrendCount>,
268}
269
270#[derive(Debug, Clone, serde::Serialize)]
272pub struct TrendCount {
273 pub value: usize,
275 pub total: usize,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
281#[serde(rename_all = "snake_case")]
282pub enum TrendDirection {
283 Improving,
285 Declining,
287 Stable,
289}
290
291impl TrendDirection {
292 #[must_use]
294 pub const fn arrow(self) -> &'static str {
295 match self {
296 Self::Improving => "\u{2191}", Self::Declining => "\u{2193}", Self::Stable => "\u{2192}", }
300 }
301
302 #[must_use]
304 pub const fn label(self) -> &'static str {
305 match self {
306 Self::Improving => "improving",
307 Self::Declining => "declining",
308 Self::Stable => "stable",
309 }
310 }
311}
312
313pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
315
316#[derive(Debug, serde::Serialize)]
318pub struct HealthFinding {
319 pub path: std::path::PathBuf,
321 pub name: String,
323 pub line: u32,
325 pub col: u32,
327 pub cyclomatic: u16,
329 pub cognitive: u16,
331 pub line_count: u32,
333 pub exceeded: ExceededThreshold,
335}
336
337#[derive(Debug, serde::Serialize)]
339#[serde(rename_all = "snake_case")]
340pub enum ExceededThreshold {
341 Cyclomatic,
343 Cognitive,
345 Both,
347}
348
349#[derive(Debug, serde::Serialize)]
351pub struct HealthSummary {
352 pub files_analyzed: usize,
354 pub functions_analyzed: usize,
356 pub functions_above_threshold: usize,
358 pub max_cyclomatic_threshold: u16,
360 pub max_cognitive_threshold: u16,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub files_scored: Option<usize>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub average_maintainability: Option<f64>,
368}
369
370#[derive(Debug, Clone, serde::Serialize)]
390pub struct FileHealthScore {
391 pub path: std::path::PathBuf,
393 pub fan_in: usize,
395 pub fan_out: usize,
397 pub dead_code_ratio: f64,
401 pub complexity_density: f64,
403 pub maintainability_index: f64,
405 pub total_cyclomatic: u32,
407 pub total_cognitive: u32,
409 pub function_count: usize,
411 pub lines: u32,
413}
414
415#[derive(Debug, Clone, serde::Serialize)]
428pub struct HotspotEntry {
429 pub path: std::path::PathBuf,
431 pub score: f64,
433 pub commits: u32,
435 pub weighted_commits: f64,
437 pub lines_added: u32,
439 pub lines_deleted: u32,
441 pub complexity_density: f64,
443 pub fan_in: usize,
445 pub trend: fallow_core::churn::ChurnTrend,
447}
448
449#[derive(Debug, serde::Serialize)]
451pub struct HotspotSummary {
452 pub since: String,
454 pub min_commits: u32,
456 pub files_analyzed: usize,
458 pub files_excluded: usize,
460 pub shallow_clone: bool,
462}
463
464#[derive(Debug, Clone, serde::Serialize)]
469#[allow(clippy::struct_field_names)] pub struct TargetThresholds {
471 pub fan_in_p95: f64,
473 pub fan_in_p75: f64,
475 pub fan_out_p95: f64,
477 pub fan_out_p90: usize,
479}
480
481#[derive(Debug, Clone, serde::Serialize)]
483#[serde(rename_all = "snake_case")]
484pub enum RecommendationCategory {
485 UrgentChurnComplexity,
487 BreakCircularDependency,
489 SplitHighImpact,
491 RemoveDeadCode,
493 ExtractComplexFunctions,
495 ExtractDependencies,
497}
498
499impl RecommendationCategory {
500 #[must_use]
502 pub const fn label(&self) -> &'static str {
503 match self {
504 Self::UrgentChurnComplexity => "churn+complexity",
505 Self::BreakCircularDependency => "circular dep",
506 Self::SplitHighImpact => "high impact",
507 Self::RemoveDeadCode => "dead code",
508 Self::ExtractComplexFunctions => "complexity",
509 Self::ExtractDependencies => "coupling",
510 }
511 }
512
513 #[must_use]
515 pub const fn compact_label(&self) -> &'static str {
516 match self {
517 Self::UrgentChurnComplexity => "churn_complexity",
518 Self::BreakCircularDependency => "circular_dep",
519 Self::SplitHighImpact => "high_impact",
520 Self::RemoveDeadCode => "dead_code",
521 Self::ExtractComplexFunctions => "complexity",
522 Self::ExtractDependencies => "coupling",
523 }
524 }
525}
526
527#[derive(Debug, Clone, serde::Serialize)]
529pub struct ContributingFactor {
530 pub metric: &'static str,
532 pub value: f64,
534 pub threshold: f64,
536 pub detail: String,
538}
539
540#[derive(Debug, Clone, serde::Serialize)]
560#[serde(rename_all = "snake_case")]
561pub enum EffortEstimate {
562 Low,
564 Medium,
566 High,
568}
569
570impl EffortEstimate {
571 #[must_use]
573 pub const fn label(&self) -> &'static str {
574 match self {
575 Self::Low => "low",
576 Self::Medium => "medium",
577 Self::High => "high",
578 }
579 }
580
581 #[must_use]
583 pub const fn numeric(&self) -> f64 {
584 match self {
585 Self::Low => 1.0,
586 Self::Medium => 2.0,
587 Self::High => 3.0,
588 }
589 }
590}
591
592#[derive(Debug, Clone, serde::Serialize)]
599#[serde(rename_all = "snake_case")]
600pub enum Confidence {
601 High,
603 Medium,
605 Low,
607}
608
609impl Confidence {
610 #[must_use]
612 pub const fn label(&self) -> &'static str {
613 match self {
614 Self::High => "high",
615 Self::Medium => "medium",
616 Self::Low => "low",
617 }
618 }
619}
620
621#[derive(Debug, Clone, serde::Serialize)]
626pub struct TargetEvidence {
627 #[serde(skip_serializing_if = "Vec::is_empty")]
629 pub unused_exports: Vec<String>,
630 #[serde(skip_serializing_if = "Vec::is_empty")]
632 pub complex_functions: Vec<EvidenceFunction>,
633 #[serde(skip_serializing_if = "Vec::is_empty")]
635 pub cycle_path: Vec<String>,
636}
637
638#[derive(Debug, Clone, serde::Serialize)]
640pub struct EvidenceFunction {
641 pub name: String,
643 pub line: u32,
645 pub cognitive: u16,
647}
648
649#[derive(Debug, Clone, serde::Serialize)]
650pub struct RefactoringTarget {
651 pub path: std::path::PathBuf,
653 pub priority: f64,
655 pub efficiency: f64,
658 pub recommendation: String,
660 pub category: RecommendationCategory,
662 pub effort: EffortEstimate,
664 pub confidence: Confidence,
666 #[serde(skip_serializing_if = "Vec::is_empty")]
668 pub factors: Vec<ContributingFactor>,
669 #[serde(skip_serializing_if = "Option::is_none")]
671 pub evidence: Option<TargetEvidence>,
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
681 fn category_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!(!cat.label().is_empty(), "{cat:?} should have a label");
692 }
693 }
694
695 #[test]
696 fn category_labels_are_unique() {
697 let categories = [
698 RecommendationCategory::UrgentChurnComplexity,
699 RecommendationCategory::BreakCircularDependency,
700 RecommendationCategory::SplitHighImpact,
701 RecommendationCategory::RemoveDeadCode,
702 RecommendationCategory::ExtractComplexFunctions,
703 RecommendationCategory::ExtractDependencies,
704 ];
705 let labels: Vec<&str> = categories
706 .iter()
707 .map(super::RecommendationCategory::label)
708 .collect();
709 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
710 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
711 }
712
713 #[test]
716 fn category_serializes_as_snake_case() {
717 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
718 assert_eq!(json, r#""urgent_churn_complexity""#);
719
720 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
721 assert_eq!(json, r#""break_circular_dependency""#);
722 }
723
724 #[test]
725 fn exceeded_threshold_serializes_as_snake_case() {
726 let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
727 assert_eq!(json, r#""both""#);
728
729 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
730 assert_eq!(json, r#""cyclomatic""#);
731 }
732
733 #[test]
734 fn health_report_skips_empty_collections() {
735 let report = HealthReport {
736 findings: vec![],
737 summary: HealthSummary {
738 files_analyzed: 0,
739 functions_analyzed: 0,
740 functions_above_threshold: 0,
741 max_cyclomatic_threshold: 20,
742 max_cognitive_threshold: 15,
743 files_scored: None,
744 average_maintainability: None,
745 },
746 vital_signs: None,
747 health_score: None,
748 file_scores: vec![],
749 hotspots: vec![],
750 hotspot_summary: None,
751 targets: vec![],
752 target_thresholds: None,
753 health_trend: None,
754 };
755 let json = serde_json::to_string(&report).unwrap();
756 assert!(!json.contains("file_scores"));
758 assert!(!json.contains("hotspots"));
759 assert!(!json.contains("hotspot_summary"));
760 assert!(!json.contains("targets"));
761 assert!(!json.contains("vital_signs"));
762 assert!(!json.contains("health_score"));
763 }
764
765 #[test]
766 fn vital_signs_serialization_roundtrip() {
767 let vs = VitalSigns {
768 dead_file_pct: Some(3.2),
769 dead_export_pct: Some(8.1),
770 avg_cyclomatic: 4.7,
771 p90_cyclomatic: 12,
772 duplication_pct: None,
773 hotspot_count: Some(5),
774 maintainability_avg: Some(72.4),
775 unused_dep_count: Some(4),
776 circular_dep_count: Some(2),
777 };
778 let json = serde_json::to_string(&vs).unwrap();
779 let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
780 assert!((deserialized.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
781 assert_eq!(deserialized.p90_cyclomatic, 12);
782 assert_eq!(deserialized.hotspot_count, Some(5));
783 assert!(!json.contains("duplication_pct"));
785 assert!(deserialized.duplication_pct.is_none());
786 }
787
788 #[test]
789 fn vital_signs_snapshot_roundtrip() {
790 let snapshot = VitalSignsSnapshot {
791 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
792 version: "1.8.1".into(),
793 timestamp: "2026-03-25T14:30:00Z".into(),
794 git_sha: Some("abc1234".into()),
795 git_branch: Some("main".into()),
796 shallow_clone: false,
797 vital_signs: VitalSigns {
798 dead_file_pct: Some(3.2),
799 dead_export_pct: Some(8.1),
800 avg_cyclomatic: 4.7,
801 p90_cyclomatic: 12,
802 duplication_pct: None,
803 hotspot_count: None,
804 maintainability_avg: Some(72.4),
805 unused_dep_count: Some(4),
806 circular_dep_count: Some(2),
807 },
808 counts: VitalSignsCounts {
809 total_files: 1200,
810 total_exports: 5400,
811 dead_files: 38,
812 dead_exports: 437,
813 duplicated_lines: None,
814 total_lines: None,
815 files_scored: Some(1150),
816 total_deps: 42,
817 },
818 score: Some(78.5),
819 grade: Some("B".into()),
820 };
821 let json = serde_json::to_string_pretty(&snapshot).unwrap();
822 let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
823 assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
824 assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
825 assert_eq!(rt.counts.total_files, 1200);
826 assert_eq!(rt.counts.dead_exports, 437);
827 assert_eq!(rt.score, Some(78.5));
828 assert_eq!(rt.grade.as_deref(), Some("B"));
829 }
830
831 #[test]
832 fn refactoring_target_skips_empty_factors() {
833 let target = RefactoringTarget {
834 path: std::path::PathBuf::from("/src/foo.ts"),
835 priority: 75.0,
836 efficiency: 75.0,
837 recommendation: "Test recommendation".into(),
838 category: RecommendationCategory::RemoveDeadCode,
839 effort: EffortEstimate::Low,
840 confidence: Confidence::High,
841 factors: vec![],
842 evidence: None,
843 };
844 let json = serde_json::to_string(&target).unwrap();
845 assert!(!json.contains("factors"));
846 assert!(!json.contains("evidence"));
847 }
848
849 #[test]
850 fn effort_numeric_values() {
851 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
852 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
853 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
854 }
855
856 #[test]
857 fn confidence_labels_are_non_empty() {
858 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
859 for level in &levels {
860 assert!(!level.label().is_empty(), "{level:?} should have a label");
861 }
862 }
863
864 #[test]
865 fn confidence_serializes_as_snake_case() {
866 let json = serde_json::to_string(&Confidence::High).unwrap();
867 assert_eq!(json, r#""high""#);
868 let json = serde_json::to_string(&Confidence::Medium).unwrap();
869 assert_eq!(json, r#""medium""#);
870 let json = serde_json::to_string(&Confidence::Low).unwrap();
871 assert_eq!(json, r#""low""#);
872 }
873
874 #[test]
875 fn contributing_factor_serializes_correctly() {
876 let factor = ContributingFactor {
877 metric: "fan_in",
878 value: 15.0,
879 threshold: 10.0,
880 detail: "15 files depend on this".into(),
881 };
882 let json = serde_json::to_string(&factor).unwrap();
883 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
884 assert_eq!(parsed["metric"], "fan_in");
885 assert_eq!(parsed["value"], 15.0);
886 assert_eq!(parsed["threshold"], 10.0);
887 }
888
889 #[test]
892 fn category_compact_labels_are_non_empty() {
893 let categories = [
894 RecommendationCategory::UrgentChurnComplexity,
895 RecommendationCategory::BreakCircularDependency,
896 RecommendationCategory::SplitHighImpact,
897 RecommendationCategory::RemoveDeadCode,
898 RecommendationCategory::ExtractComplexFunctions,
899 RecommendationCategory::ExtractDependencies,
900 ];
901 for cat in &categories {
902 assert!(
903 !cat.compact_label().is_empty(),
904 "{cat:?} should have a compact_label"
905 );
906 }
907 }
908
909 #[test]
910 fn category_compact_labels_are_unique() {
911 let categories = [
912 RecommendationCategory::UrgentChurnComplexity,
913 RecommendationCategory::BreakCircularDependency,
914 RecommendationCategory::SplitHighImpact,
915 RecommendationCategory::RemoveDeadCode,
916 RecommendationCategory::ExtractComplexFunctions,
917 RecommendationCategory::ExtractDependencies,
918 ];
919 let labels: Vec<&str> = categories
920 .iter()
921 .map(RecommendationCategory::compact_label)
922 .collect();
923 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
924 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
925 }
926
927 #[test]
928 fn category_compact_labels_have_no_spaces() {
929 let categories = [
930 RecommendationCategory::UrgentChurnComplexity,
931 RecommendationCategory::BreakCircularDependency,
932 RecommendationCategory::SplitHighImpact,
933 RecommendationCategory::RemoveDeadCode,
934 RecommendationCategory::ExtractComplexFunctions,
935 RecommendationCategory::ExtractDependencies,
936 ];
937 for cat in &categories {
938 assert!(
939 !cat.compact_label().contains(' '),
940 "compact_label for {:?} should not contain spaces: '{}'",
941 cat,
942 cat.compact_label()
943 );
944 }
945 }
946
947 #[test]
950 fn effort_labels_are_non_empty() {
951 let efforts = [
952 EffortEstimate::Low,
953 EffortEstimate::Medium,
954 EffortEstimate::High,
955 ];
956 for effort in &efforts {
957 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
958 }
959 }
960
961 #[test]
962 fn effort_serializes_as_snake_case() {
963 assert_eq!(
964 serde_json::to_string(&EffortEstimate::Low).unwrap(),
965 r#""low""#
966 );
967 assert_eq!(
968 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
969 r#""medium""#
970 );
971 assert_eq!(
972 serde_json::to_string(&EffortEstimate::High).unwrap(),
973 r#""high""#
974 );
975 }
976
977 #[test]
980 fn vital_signs_all_none_optional_fields_omitted() {
981 let vs = VitalSigns {
982 dead_file_pct: None,
983 dead_export_pct: None,
984 avg_cyclomatic: 5.0,
985 p90_cyclomatic: 10,
986 duplication_pct: None,
987 hotspot_count: None,
988 maintainability_avg: None,
989 unused_dep_count: None,
990 circular_dep_count: None,
991 };
992 let json = serde_json::to_string(&vs).unwrap();
993 assert!(!json.contains("dead_file_pct"));
994 assert!(!json.contains("dead_export_pct"));
995 assert!(!json.contains("duplication_pct"));
996 assert!(!json.contains("hotspot_count"));
997 assert!(!json.contains("maintainability_avg"));
998 assert!(!json.contains("unused_dep_count"));
999 assert!(!json.contains("circular_dep_count"));
1000 assert!(json.contains("avg_cyclomatic"));
1002 assert!(json.contains("p90_cyclomatic"));
1003 }
1004
1005 #[test]
1008 fn exceeded_threshold_all_variants_serialize() {
1009 for variant in [
1010 ExceededThreshold::Cyclomatic,
1011 ExceededThreshold::Cognitive,
1012 ExceededThreshold::Both,
1013 ] {
1014 let json = serde_json::to_string(&variant).unwrap();
1015 assert!(!json.is_empty());
1016 }
1017 }
1018
1019 #[test]
1022 fn target_evidence_skips_empty_fields() {
1023 let evidence = TargetEvidence {
1024 unused_exports: vec![],
1025 complex_functions: vec![],
1026 cycle_path: vec![],
1027 };
1028 let json = serde_json::to_string(&evidence).unwrap();
1029 assert!(!json.contains("unused_exports"));
1030 assert!(!json.contains("complex_functions"));
1031 assert!(!json.contains("cycle_path"));
1032 }
1033
1034 #[test]
1035 fn target_evidence_with_data() {
1036 let evidence = TargetEvidence {
1037 unused_exports: vec!["foo".to_string(), "bar".to_string()],
1038 complex_functions: vec![EvidenceFunction {
1039 name: "processData".into(),
1040 line: 42,
1041 cognitive: 30,
1042 }],
1043 cycle_path: vec![],
1044 };
1045 let json = serde_json::to_string(&evidence).unwrap();
1046 assert!(json.contains("unused_exports"));
1047 assert!(json.contains("complex_functions"));
1048 assert!(json.contains("processData"));
1049 assert!(!json.contains("cycle_path"));
1050 }
1051
1052 #[test]
1055 fn snapshot_schema_version_is_two() {
1056 assert_eq!(SNAPSHOT_SCHEMA_VERSION, 2);
1057 }
1058
1059 #[test]
1060 fn hotspot_score_threshold_is_50() {
1061 assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
1062 }
1063
1064 #[test]
1065 fn snapshot_v1_deserializes_with_default_score_and_grade() {
1066 let json = r#"{
1068 "snapshot_schema_version": 1,
1069 "version": "1.5.0",
1070 "timestamp": "2025-01-01T00:00:00Z",
1071 "shallow_clone": false,
1072 "vital_signs": {
1073 "avg_cyclomatic": 2.0,
1074 "p90_cyclomatic": 5
1075 },
1076 "counts": {
1077 "total_files": 100,
1078 "total_exports": 500,
1079 "dead_files": 0,
1080 "dead_exports": 0,
1081 "total_deps": 20
1082 }
1083 }"#;
1084 let snap: VitalSignsSnapshot = serde_json::from_str(json).unwrap();
1085 assert!(snap.score.is_none());
1086 assert!(snap.grade.is_none());
1087 assert_eq!(snap.snapshot_schema_version, 1);
1088 }
1089
1090 #[test]
1093 fn letter_grade_boundaries() {
1094 assert_eq!(letter_grade(100.0), "A");
1095 assert_eq!(letter_grade(85.0), "A");
1096 assert_eq!(letter_grade(84.9), "B");
1097 assert_eq!(letter_grade(70.0), "B");
1098 assert_eq!(letter_grade(69.9), "C");
1099 assert_eq!(letter_grade(55.0), "C");
1100 assert_eq!(letter_grade(54.9), "D");
1101 assert_eq!(letter_grade(40.0), "D");
1102 assert_eq!(letter_grade(39.9), "F");
1103 assert_eq!(letter_grade(0.0), "F");
1104 }
1105
1106 #[test]
1109 fn health_score_serializes_correctly() {
1110 let score = HealthScore {
1111 score: 78.5,
1112 grade: "B",
1113 penalties: HealthScorePenalties {
1114 dead_files: Some(3.1),
1115 dead_exports: Some(6.0),
1116 complexity: 0.0,
1117 p90_complexity: 0.0,
1118 maintainability: None,
1119 hotspots: None,
1120 unused_deps: Some(5.0),
1121 circular_deps: Some(4.0),
1122 },
1123 };
1124 let json = serde_json::to_string(&score).unwrap();
1125 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1126 assert_eq!(parsed["score"], 78.5);
1127 assert_eq!(parsed["grade"], "B");
1128 assert_eq!(parsed["penalties"]["dead_files"], 3.1);
1129 assert!(!json.contains("maintainability"));
1131 assert!(!json.contains("hotspots"));
1132 }
1133
1134 #[test]
1135 fn health_score_none_skipped_in_report() {
1136 let report = HealthReport {
1137 findings: vec![],
1138 summary: HealthSummary {
1139 files_analyzed: 0,
1140 functions_analyzed: 0,
1141 functions_above_threshold: 0,
1142 max_cyclomatic_threshold: 20,
1143 max_cognitive_threshold: 15,
1144 files_scored: None,
1145 average_maintainability: None,
1146 },
1147 vital_signs: None,
1148 health_score: None,
1149 file_scores: vec![],
1150 hotspots: vec![],
1151 hotspot_summary: None,
1152 targets: vec![],
1153 target_thresholds: None,
1154 health_trend: None,
1155 };
1156 let json = serde_json::to_string(&report).unwrap();
1157 assert!(!json.contains("health_score"));
1158 }
1159}