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(
470 clippy::struct_field_names,
471 reason = "triggered in bin but not lib — #[expect] would be unfulfilled in lib"
472)]
473pub struct TargetThresholds {
474 pub fan_in_p95: f64,
476 pub fan_in_p75: f64,
478 pub fan_out_p95: f64,
480 pub fan_out_p90: usize,
482}
483
484#[derive(Debug, Clone, serde::Serialize)]
486#[serde(rename_all = "snake_case")]
487pub enum RecommendationCategory {
488 UrgentChurnComplexity,
490 BreakCircularDependency,
492 SplitHighImpact,
494 RemoveDeadCode,
496 ExtractComplexFunctions,
498 ExtractDependencies,
500}
501
502impl RecommendationCategory {
503 #[must_use]
505 pub const fn label(&self) -> &'static str {
506 match self {
507 Self::UrgentChurnComplexity => "churn+complexity",
508 Self::BreakCircularDependency => "circular dep",
509 Self::SplitHighImpact => "high impact",
510 Self::RemoveDeadCode => "dead code",
511 Self::ExtractComplexFunctions => "complexity",
512 Self::ExtractDependencies => "coupling",
513 }
514 }
515
516 #[must_use]
518 pub const fn compact_label(&self) -> &'static str {
519 match self {
520 Self::UrgentChurnComplexity => "churn_complexity",
521 Self::BreakCircularDependency => "circular_dep",
522 Self::SplitHighImpact => "high_impact",
523 Self::RemoveDeadCode => "dead_code",
524 Self::ExtractComplexFunctions => "complexity",
525 Self::ExtractDependencies => "coupling",
526 }
527 }
528}
529
530#[derive(Debug, Clone, serde::Serialize)]
532pub struct ContributingFactor {
533 pub metric: &'static str,
535 pub value: f64,
537 pub threshold: f64,
539 pub detail: String,
541}
542
543#[derive(Debug, Clone, serde::Serialize)]
563#[serde(rename_all = "snake_case")]
564pub enum EffortEstimate {
565 Low,
567 Medium,
569 High,
571}
572
573impl EffortEstimate {
574 #[must_use]
576 pub const fn label(&self) -> &'static str {
577 match self {
578 Self::Low => "low",
579 Self::Medium => "medium",
580 Self::High => "high",
581 }
582 }
583
584 #[must_use]
586 pub const fn numeric(&self) -> f64 {
587 match self {
588 Self::Low => 1.0,
589 Self::Medium => 2.0,
590 Self::High => 3.0,
591 }
592 }
593}
594
595#[derive(Debug, Clone, serde::Serialize)]
602#[serde(rename_all = "snake_case")]
603pub enum Confidence {
604 High,
606 Medium,
608 Low,
610}
611
612impl Confidence {
613 #[must_use]
615 pub const fn label(&self) -> &'static str {
616 match self {
617 Self::High => "high",
618 Self::Medium => "medium",
619 Self::Low => "low",
620 }
621 }
622}
623
624#[derive(Debug, Clone, serde::Serialize)]
629pub struct TargetEvidence {
630 #[serde(skip_serializing_if = "Vec::is_empty")]
632 pub unused_exports: Vec<String>,
633 #[serde(skip_serializing_if = "Vec::is_empty")]
635 pub complex_functions: Vec<EvidenceFunction>,
636 #[serde(skip_serializing_if = "Vec::is_empty")]
638 pub cycle_path: Vec<String>,
639}
640
641#[derive(Debug, Clone, serde::Serialize)]
643pub struct EvidenceFunction {
644 pub name: String,
646 pub line: u32,
648 pub cognitive: u16,
650}
651
652#[derive(Debug, Clone, serde::Serialize)]
653pub struct RefactoringTarget {
654 pub path: std::path::PathBuf,
656 pub priority: f64,
658 pub efficiency: f64,
661 pub recommendation: String,
663 pub category: RecommendationCategory,
665 pub effort: EffortEstimate,
667 pub confidence: Confidence,
669 #[serde(skip_serializing_if = "Vec::is_empty")]
671 pub factors: Vec<ContributingFactor>,
672 #[serde(skip_serializing_if = "Option::is_none")]
674 pub evidence: Option<TargetEvidence>,
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
684 fn category_labels_are_non_empty() {
685 let categories = [
686 RecommendationCategory::UrgentChurnComplexity,
687 RecommendationCategory::BreakCircularDependency,
688 RecommendationCategory::SplitHighImpact,
689 RecommendationCategory::RemoveDeadCode,
690 RecommendationCategory::ExtractComplexFunctions,
691 RecommendationCategory::ExtractDependencies,
692 ];
693 for cat in &categories {
694 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
695 }
696 }
697
698 #[test]
699 fn category_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
709 .iter()
710 .map(super::RecommendationCategory::label)
711 .collect();
712 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
713 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
714 }
715
716 #[test]
719 fn category_serializes_as_snake_case() {
720 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
721 assert_eq!(json, r#""urgent_churn_complexity""#);
722
723 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
724 assert_eq!(json, r#""break_circular_dependency""#);
725 }
726
727 #[test]
728 fn exceeded_threshold_serializes_as_snake_case() {
729 let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
730 assert_eq!(json, r#""both""#);
731
732 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
733 assert_eq!(json, r#""cyclomatic""#);
734 }
735
736 #[test]
737 fn health_report_skips_empty_collections() {
738 let report = HealthReport {
739 findings: vec![],
740 summary: HealthSummary {
741 files_analyzed: 0,
742 functions_analyzed: 0,
743 functions_above_threshold: 0,
744 max_cyclomatic_threshold: 20,
745 max_cognitive_threshold: 15,
746 files_scored: None,
747 average_maintainability: None,
748 },
749 vital_signs: None,
750 health_score: None,
751 file_scores: vec![],
752 hotspots: vec![],
753 hotspot_summary: None,
754 targets: vec![],
755 target_thresholds: None,
756 health_trend: None,
757 };
758 let json = serde_json::to_string(&report).unwrap();
759 assert!(!json.contains("file_scores"));
761 assert!(!json.contains("hotspots"));
762 assert!(!json.contains("hotspot_summary"));
763 assert!(!json.contains("targets"));
764 assert!(!json.contains("vital_signs"));
765 assert!(!json.contains("health_score"));
766 }
767
768 #[test]
769 fn vital_signs_serialization_roundtrip() {
770 let vs = VitalSigns {
771 dead_file_pct: Some(3.2),
772 dead_export_pct: Some(8.1),
773 avg_cyclomatic: 4.7,
774 p90_cyclomatic: 12,
775 duplication_pct: None,
776 hotspot_count: Some(5),
777 maintainability_avg: Some(72.4),
778 unused_dep_count: Some(4),
779 circular_dep_count: Some(2),
780 };
781 let json = serde_json::to_string(&vs).unwrap();
782 let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
783 assert!((deserialized.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
784 assert_eq!(deserialized.p90_cyclomatic, 12);
785 assert_eq!(deserialized.hotspot_count, Some(5));
786 assert!(!json.contains("duplication_pct"));
788 assert!(deserialized.duplication_pct.is_none());
789 }
790
791 #[test]
792 fn vital_signs_snapshot_roundtrip() {
793 let snapshot = VitalSignsSnapshot {
794 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
795 version: "1.8.1".into(),
796 timestamp: "2026-03-25T14:30:00Z".into(),
797 git_sha: Some("abc1234".into()),
798 git_branch: Some("main".into()),
799 shallow_clone: false,
800 vital_signs: VitalSigns {
801 dead_file_pct: Some(3.2),
802 dead_export_pct: Some(8.1),
803 avg_cyclomatic: 4.7,
804 p90_cyclomatic: 12,
805 duplication_pct: None,
806 hotspot_count: None,
807 maintainability_avg: Some(72.4),
808 unused_dep_count: Some(4),
809 circular_dep_count: Some(2),
810 },
811 counts: VitalSignsCounts {
812 total_files: 1200,
813 total_exports: 5400,
814 dead_files: 38,
815 dead_exports: 437,
816 duplicated_lines: None,
817 total_lines: None,
818 files_scored: Some(1150),
819 total_deps: 42,
820 },
821 score: Some(78.5),
822 grade: Some("B".into()),
823 };
824 let json = serde_json::to_string_pretty(&snapshot).unwrap();
825 let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
826 assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
827 assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
828 assert_eq!(rt.counts.total_files, 1200);
829 assert_eq!(rt.counts.dead_exports, 437);
830 assert_eq!(rt.score, Some(78.5));
831 assert_eq!(rt.grade.as_deref(), Some("B"));
832 }
833
834 #[test]
835 fn refactoring_target_skips_empty_factors() {
836 let target = RefactoringTarget {
837 path: std::path::PathBuf::from("/src/foo.ts"),
838 priority: 75.0,
839 efficiency: 75.0,
840 recommendation: "Test recommendation".into(),
841 category: RecommendationCategory::RemoveDeadCode,
842 effort: EffortEstimate::Low,
843 confidence: Confidence::High,
844 factors: vec![],
845 evidence: None,
846 };
847 let json = serde_json::to_string(&target).unwrap();
848 assert!(!json.contains("factors"));
849 assert!(!json.contains("evidence"));
850 }
851
852 #[test]
853 fn effort_numeric_values() {
854 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
855 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
856 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
857 }
858
859 #[test]
860 fn confidence_labels_are_non_empty() {
861 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
862 for level in &levels {
863 assert!(!level.label().is_empty(), "{level:?} should have a label");
864 }
865 }
866
867 #[test]
868 fn confidence_serializes_as_snake_case() {
869 let json = serde_json::to_string(&Confidence::High).unwrap();
870 assert_eq!(json, r#""high""#);
871 let json = serde_json::to_string(&Confidence::Medium).unwrap();
872 assert_eq!(json, r#""medium""#);
873 let json = serde_json::to_string(&Confidence::Low).unwrap();
874 assert_eq!(json, r#""low""#);
875 }
876
877 #[test]
878 fn contributing_factor_serializes_correctly() {
879 let factor = ContributingFactor {
880 metric: "fan_in",
881 value: 15.0,
882 threshold: 10.0,
883 detail: "15 files depend on this".into(),
884 };
885 let json = serde_json::to_string(&factor).unwrap();
886 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
887 assert_eq!(parsed["metric"], "fan_in");
888 assert_eq!(parsed["value"], 15.0);
889 assert_eq!(parsed["threshold"], 10.0);
890 }
891
892 #[test]
895 fn category_compact_labels_are_non_empty() {
896 let categories = [
897 RecommendationCategory::UrgentChurnComplexity,
898 RecommendationCategory::BreakCircularDependency,
899 RecommendationCategory::SplitHighImpact,
900 RecommendationCategory::RemoveDeadCode,
901 RecommendationCategory::ExtractComplexFunctions,
902 RecommendationCategory::ExtractDependencies,
903 ];
904 for cat in &categories {
905 assert!(
906 !cat.compact_label().is_empty(),
907 "{cat:?} should have a compact_label"
908 );
909 }
910 }
911
912 #[test]
913 fn category_compact_labels_are_unique() {
914 let categories = [
915 RecommendationCategory::UrgentChurnComplexity,
916 RecommendationCategory::BreakCircularDependency,
917 RecommendationCategory::SplitHighImpact,
918 RecommendationCategory::RemoveDeadCode,
919 RecommendationCategory::ExtractComplexFunctions,
920 RecommendationCategory::ExtractDependencies,
921 ];
922 let labels: Vec<&str> = categories
923 .iter()
924 .map(RecommendationCategory::compact_label)
925 .collect();
926 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
927 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
928 }
929
930 #[test]
931 fn category_compact_labels_have_no_spaces() {
932 let categories = [
933 RecommendationCategory::UrgentChurnComplexity,
934 RecommendationCategory::BreakCircularDependency,
935 RecommendationCategory::SplitHighImpact,
936 RecommendationCategory::RemoveDeadCode,
937 RecommendationCategory::ExtractComplexFunctions,
938 RecommendationCategory::ExtractDependencies,
939 ];
940 for cat in &categories {
941 assert!(
942 !cat.compact_label().contains(' '),
943 "compact_label for {:?} should not contain spaces: '{}'",
944 cat,
945 cat.compact_label()
946 );
947 }
948 }
949
950 #[test]
953 fn effort_labels_are_non_empty() {
954 let efforts = [
955 EffortEstimate::Low,
956 EffortEstimate::Medium,
957 EffortEstimate::High,
958 ];
959 for effort in &efforts {
960 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
961 }
962 }
963
964 #[test]
965 fn effort_serializes_as_snake_case() {
966 assert_eq!(
967 serde_json::to_string(&EffortEstimate::Low).unwrap(),
968 r#""low""#
969 );
970 assert_eq!(
971 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
972 r#""medium""#
973 );
974 assert_eq!(
975 serde_json::to_string(&EffortEstimate::High).unwrap(),
976 r#""high""#
977 );
978 }
979
980 #[test]
983 fn vital_signs_all_none_optional_fields_omitted() {
984 let vs = VitalSigns {
985 dead_file_pct: None,
986 dead_export_pct: None,
987 avg_cyclomatic: 5.0,
988 p90_cyclomatic: 10,
989 duplication_pct: None,
990 hotspot_count: None,
991 maintainability_avg: None,
992 unused_dep_count: None,
993 circular_dep_count: None,
994 };
995 let json = serde_json::to_string(&vs).unwrap();
996 assert!(!json.contains("dead_file_pct"));
997 assert!(!json.contains("dead_export_pct"));
998 assert!(!json.contains("duplication_pct"));
999 assert!(!json.contains("hotspot_count"));
1000 assert!(!json.contains("maintainability_avg"));
1001 assert!(!json.contains("unused_dep_count"));
1002 assert!(!json.contains("circular_dep_count"));
1003 assert!(json.contains("avg_cyclomatic"));
1005 assert!(json.contains("p90_cyclomatic"));
1006 }
1007
1008 #[test]
1011 fn exceeded_threshold_all_variants_serialize() {
1012 for variant in [
1013 ExceededThreshold::Cyclomatic,
1014 ExceededThreshold::Cognitive,
1015 ExceededThreshold::Both,
1016 ] {
1017 let json = serde_json::to_string(&variant).unwrap();
1018 assert!(!json.is_empty());
1019 }
1020 }
1021
1022 #[test]
1025 fn target_evidence_skips_empty_fields() {
1026 let evidence = TargetEvidence {
1027 unused_exports: vec![],
1028 complex_functions: vec![],
1029 cycle_path: vec![],
1030 };
1031 let json = serde_json::to_string(&evidence).unwrap();
1032 assert!(!json.contains("unused_exports"));
1033 assert!(!json.contains("complex_functions"));
1034 assert!(!json.contains("cycle_path"));
1035 }
1036
1037 #[test]
1038 fn target_evidence_with_data() {
1039 let evidence = TargetEvidence {
1040 unused_exports: vec!["foo".to_string(), "bar".to_string()],
1041 complex_functions: vec![EvidenceFunction {
1042 name: "processData".into(),
1043 line: 42,
1044 cognitive: 30,
1045 }],
1046 cycle_path: vec![],
1047 };
1048 let json = serde_json::to_string(&evidence).unwrap();
1049 assert!(json.contains("unused_exports"));
1050 assert!(json.contains("complex_functions"));
1051 assert!(json.contains("processData"));
1052 assert!(!json.contains("cycle_path"));
1053 }
1054
1055 #[test]
1058 fn snapshot_schema_version_is_two() {
1059 assert_eq!(SNAPSHOT_SCHEMA_VERSION, 2);
1060 }
1061
1062 #[test]
1063 fn hotspot_score_threshold_is_50() {
1064 assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
1065 }
1066
1067 #[test]
1068 fn snapshot_v1_deserializes_with_default_score_and_grade() {
1069 let json = r#"{
1071 "snapshot_schema_version": 1,
1072 "version": "1.5.0",
1073 "timestamp": "2025-01-01T00:00:00Z",
1074 "shallow_clone": false,
1075 "vital_signs": {
1076 "avg_cyclomatic": 2.0,
1077 "p90_cyclomatic": 5
1078 },
1079 "counts": {
1080 "total_files": 100,
1081 "total_exports": 500,
1082 "dead_files": 0,
1083 "dead_exports": 0,
1084 "total_deps": 20
1085 }
1086 }"#;
1087 let snap: VitalSignsSnapshot = serde_json::from_str(json).unwrap();
1088 assert!(snap.score.is_none());
1089 assert!(snap.grade.is_none());
1090 assert_eq!(snap.snapshot_schema_version, 1);
1091 }
1092
1093 #[test]
1096 fn letter_grade_boundaries() {
1097 assert_eq!(letter_grade(100.0), "A");
1098 assert_eq!(letter_grade(85.0), "A");
1099 assert_eq!(letter_grade(84.9), "B");
1100 assert_eq!(letter_grade(70.0), "B");
1101 assert_eq!(letter_grade(69.9), "C");
1102 assert_eq!(letter_grade(55.0), "C");
1103 assert_eq!(letter_grade(54.9), "D");
1104 assert_eq!(letter_grade(40.0), "D");
1105 assert_eq!(letter_grade(39.9), "F");
1106 assert_eq!(letter_grade(0.0), "F");
1107 }
1108
1109 #[test]
1112 fn health_score_serializes_correctly() {
1113 let score = HealthScore {
1114 score: 78.5,
1115 grade: "B",
1116 penalties: HealthScorePenalties {
1117 dead_files: Some(3.1),
1118 dead_exports: Some(6.0),
1119 complexity: 0.0,
1120 p90_complexity: 0.0,
1121 maintainability: None,
1122 hotspots: None,
1123 unused_deps: Some(5.0),
1124 circular_deps: Some(4.0),
1125 },
1126 };
1127 let json = serde_json::to_string(&score).unwrap();
1128 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1129 assert_eq!(parsed["score"], 78.5);
1130 assert_eq!(parsed["grade"], "B");
1131 assert_eq!(parsed["penalties"]["dead_files"], 3.1);
1132 assert!(!json.contains("maintainability"));
1134 assert!(!json.contains("hotspots"));
1135 }
1136
1137 #[test]
1138 fn health_score_none_skipped_in_report() {
1139 let report = HealthReport {
1140 findings: vec![],
1141 summary: HealthSummary {
1142 files_analyzed: 0,
1143 functions_analyzed: 0,
1144 functions_above_threshold: 0,
1145 max_cyclomatic_threshold: 20,
1146 max_cognitive_threshold: 15,
1147 files_scored: None,
1148 average_maintainability: None,
1149 },
1150 vital_signs: None,
1151 health_score: None,
1152 file_scores: vec![],
1153 hotspots: vec![],
1154 hotspot_summary: None,
1155 targets: vec![],
1156 target_thresholds: None,
1157 health_trend: None,
1158 };
1159 let json = serde_json::to_string(&report).unwrap();
1160 assert!(!json.contains("health_score"));
1161 }
1162}