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}
36
37#[derive(Debug, Clone, serde::Serialize)]
60pub struct HealthScore {
61 pub score: f64,
63 pub grade: &'static str,
65 pub penalties: HealthScorePenalties,
67}
68
69#[derive(Debug, Clone, serde::Serialize)]
74pub struct HealthScorePenalties {
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub dead_files: Option<f64>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub dead_exports: Option<f64>,
81 pub complexity: f64,
83 pub p90_complexity: f64,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub maintainability: Option<f64>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub hotspots: Option<f64>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub unused_deps: Option<f64>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub circular_deps: Option<f64>,
97}
98
99pub const fn letter_grade(score: f64) -> &'static str {
101 let s = score as u32;
104 if s >= 85 {
105 "A"
106 } else if s >= 70 {
107 "B"
108 } else if s >= 55 {
109 "C"
110 } else if s >= 40 {
111 "D"
112 } else {
113 "F"
114 }
115}
116
117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
123pub struct VitalSigns {
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub dead_file_pct: Option<f64>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub dead_export_pct: Option<f64>,
130 pub avg_cyclomatic: f64,
132 pub p90_cyclomatic: u32,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub duplication_pct: Option<f64>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub hotspot_count: Option<u32>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub maintainability_avg: Option<f64>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub unused_dep_count: Option<u32>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub circular_dep_count: Option<u32>,
149}
150
151#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
156pub struct VitalSignsCounts {
157 pub total_files: usize,
158 pub total_exports: usize,
159 pub dead_files: usize,
160 pub dead_exports: usize,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub duplicated_lines: Option<usize>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub total_lines: Option<usize>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub files_scored: Option<usize>,
167 pub total_deps: usize,
168}
169
170#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
172pub struct VitalSignsSnapshot {
173 pub snapshot_schema_version: u32,
175 pub version: String,
177 pub timestamp: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub git_sha: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub git_branch: Option<String>,
185 #[serde(default)]
187 pub shallow_clone: bool,
188 pub vital_signs: VitalSigns,
190 pub counts: VitalSignsCounts,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub score: Option<f64>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
197 pub grade: Option<String>,
198}
199
200pub const SNAPSHOT_SCHEMA_VERSION: u32 = 2;
203
204pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
206
207#[derive(Debug, serde::Serialize)]
209pub struct HealthFinding {
210 pub path: std::path::PathBuf,
212 pub name: String,
214 pub line: u32,
216 pub col: u32,
218 pub cyclomatic: u16,
220 pub cognitive: u16,
222 pub line_count: u32,
224 pub exceeded: ExceededThreshold,
226}
227
228#[derive(Debug, serde::Serialize)]
230#[serde(rename_all = "snake_case")]
231pub enum ExceededThreshold {
232 Cyclomatic,
234 Cognitive,
236 Both,
238}
239
240#[derive(Debug, serde::Serialize)]
242pub struct HealthSummary {
243 pub files_analyzed: usize,
245 pub functions_analyzed: usize,
247 pub functions_above_threshold: usize,
249 pub max_cyclomatic_threshold: u16,
251 pub max_cognitive_threshold: u16,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub files_scored: Option<usize>,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub average_maintainability: Option<f64>,
259}
260
261#[derive(Debug, Clone, serde::Serialize)]
281pub struct FileHealthScore {
282 pub path: std::path::PathBuf,
284 pub fan_in: usize,
286 pub fan_out: usize,
288 pub dead_code_ratio: f64,
292 pub complexity_density: f64,
294 pub maintainability_index: f64,
296 pub total_cyclomatic: u32,
298 pub total_cognitive: u32,
300 pub function_count: usize,
302 pub lines: u32,
304}
305
306#[derive(Debug, Clone, serde::Serialize)]
319pub struct HotspotEntry {
320 pub path: std::path::PathBuf,
322 pub score: f64,
324 pub commits: u32,
326 pub weighted_commits: f64,
328 pub lines_added: u32,
330 pub lines_deleted: u32,
332 pub complexity_density: f64,
334 pub fan_in: usize,
336 pub trend: fallow_core::churn::ChurnTrend,
338}
339
340#[derive(Debug, serde::Serialize)]
342pub struct HotspotSummary {
343 pub since: String,
345 pub min_commits: u32,
347 pub files_analyzed: usize,
349 pub files_excluded: usize,
351 pub shallow_clone: bool,
353}
354
355#[derive(Debug, Clone, serde::Serialize)]
360#[allow(clippy::struct_field_names)] pub struct TargetThresholds {
362 pub fan_in_p95: f64,
364 pub fan_in_p75: f64,
366 pub fan_out_p95: f64,
368 pub fan_out_p90: usize,
370}
371
372#[derive(Debug, Clone, serde::Serialize)]
374#[serde(rename_all = "snake_case")]
375pub enum RecommendationCategory {
376 UrgentChurnComplexity,
378 BreakCircularDependency,
380 SplitHighImpact,
382 RemoveDeadCode,
384 ExtractComplexFunctions,
386 ExtractDependencies,
388}
389
390impl RecommendationCategory {
391 pub const fn label(&self) -> &'static str {
393 match self {
394 Self::UrgentChurnComplexity => "churn+complexity",
395 Self::BreakCircularDependency => "circular dep",
396 Self::SplitHighImpact => "high impact",
397 Self::RemoveDeadCode => "dead code",
398 Self::ExtractComplexFunctions => "complexity",
399 Self::ExtractDependencies => "coupling",
400 }
401 }
402
403 pub const fn compact_label(&self) -> &'static str {
405 match self {
406 Self::UrgentChurnComplexity => "churn_complexity",
407 Self::BreakCircularDependency => "circular_dep",
408 Self::SplitHighImpact => "high_impact",
409 Self::RemoveDeadCode => "dead_code",
410 Self::ExtractComplexFunctions => "complexity",
411 Self::ExtractDependencies => "coupling",
412 }
413 }
414}
415
416#[derive(Debug, Clone, serde::Serialize)]
418pub struct ContributingFactor {
419 pub metric: &'static str,
421 pub value: f64,
423 pub threshold: f64,
425 pub detail: String,
427}
428
429#[derive(Debug, Clone, serde::Serialize)]
449#[serde(rename_all = "snake_case")]
450pub enum EffortEstimate {
451 Low,
453 Medium,
455 High,
457}
458
459impl EffortEstimate {
460 pub const fn label(&self) -> &'static str {
462 match self {
463 Self::Low => "low",
464 Self::Medium => "medium",
465 Self::High => "high",
466 }
467 }
468
469 pub const fn numeric(&self) -> f64 {
471 match self {
472 Self::Low => 1.0,
473 Self::Medium => 2.0,
474 Self::High => 3.0,
475 }
476 }
477}
478
479#[derive(Debug, Clone, serde::Serialize)]
486#[serde(rename_all = "snake_case")]
487pub enum Confidence {
488 High,
490 Medium,
492 Low,
494}
495
496impl Confidence {
497 pub const fn label(&self) -> &'static str {
499 match self {
500 Self::High => "high",
501 Self::Medium => "medium",
502 Self::Low => "low",
503 }
504 }
505}
506
507#[derive(Debug, Clone, serde::Serialize)]
512pub struct TargetEvidence {
513 #[serde(skip_serializing_if = "Vec::is_empty")]
515 pub unused_exports: Vec<String>,
516 #[serde(skip_serializing_if = "Vec::is_empty")]
518 pub complex_functions: Vec<EvidenceFunction>,
519 #[serde(skip_serializing_if = "Vec::is_empty")]
521 pub cycle_path: Vec<String>,
522}
523
524#[derive(Debug, Clone, serde::Serialize)]
526pub struct EvidenceFunction {
527 pub name: String,
529 pub line: u32,
531 pub cognitive: u16,
533}
534
535#[derive(Debug, Clone, serde::Serialize)]
536pub struct RefactoringTarget {
537 pub path: std::path::PathBuf,
539 pub priority: f64,
541 pub efficiency: f64,
544 pub recommendation: String,
546 pub category: RecommendationCategory,
548 pub effort: EffortEstimate,
550 pub confidence: Confidence,
552 #[serde(skip_serializing_if = "Vec::is_empty")]
554 pub factors: Vec<ContributingFactor>,
555 #[serde(skip_serializing_if = "Option::is_none")]
557 pub evidence: Option<TargetEvidence>,
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
567 fn category_labels_are_non_empty() {
568 let categories = [
569 RecommendationCategory::UrgentChurnComplexity,
570 RecommendationCategory::BreakCircularDependency,
571 RecommendationCategory::SplitHighImpact,
572 RecommendationCategory::RemoveDeadCode,
573 RecommendationCategory::ExtractComplexFunctions,
574 RecommendationCategory::ExtractDependencies,
575 ];
576 for cat in &categories {
577 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
578 }
579 }
580
581 #[test]
582 fn category_labels_are_unique() {
583 let categories = [
584 RecommendationCategory::UrgentChurnComplexity,
585 RecommendationCategory::BreakCircularDependency,
586 RecommendationCategory::SplitHighImpact,
587 RecommendationCategory::RemoveDeadCode,
588 RecommendationCategory::ExtractComplexFunctions,
589 RecommendationCategory::ExtractDependencies,
590 ];
591 let labels: Vec<&str> = categories
592 .iter()
593 .map(super::RecommendationCategory::label)
594 .collect();
595 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
596 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
597 }
598
599 #[test]
602 fn category_serializes_as_snake_case() {
603 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
604 assert_eq!(json, r#""urgent_churn_complexity""#);
605
606 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
607 assert_eq!(json, r#""break_circular_dependency""#);
608 }
609
610 #[test]
611 fn exceeded_threshold_serializes_as_snake_case() {
612 let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
613 assert_eq!(json, r#""both""#);
614
615 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
616 assert_eq!(json, r#""cyclomatic""#);
617 }
618
619 #[test]
620 fn health_report_skips_empty_collections() {
621 let report = HealthReport {
622 findings: vec![],
623 summary: HealthSummary {
624 files_analyzed: 0,
625 functions_analyzed: 0,
626 functions_above_threshold: 0,
627 max_cyclomatic_threshold: 20,
628 max_cognitive_threshold: 15,
629 files_scored: None,
630 average_maintainability: None,
631 },
632 vital_signs: None,
633 health_score: None,
634 file_scores: vec![],
635 hotspots: vec![],
636 hotspot_summary: None,
637 targets: vec![],
638 target_thresholds: None,
639 };
640 let json = serde_json::to_string(&report).unwrap();
641 assert!(!json.contains("file_scores"));
643 assert!(!json.contains("hotspots"));
644 assert!(!json.contains("hotspot_summary"));
645 assert!(!json.contains("targets"));
646 assert!(!json.contains("vital_signs"));
647 assert!(!json.contains("health_score"));
648 }
649
650 #[test]
651 fn vital_signs_serialization_roundtrip() {
652 let vs = VitalSigns {
653 dead_file_pct: Some(3.2),
654 dead_export_pct: Some(8.1),
655 avg_cyclomatic: 4.7,
656 p90_cyclomatic: 12,
657 duplication_pct: None,
658 hotspot_count: Some(5),
659 maintainability_avg: Some(72.4),
660 unused_dep_count: Some(4),
661 circular_dep_count: Some(2),
662 };
663 let json = serde_json::to_string(&vs).unwrap();
664 let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
665 assert!((deserialized.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
666 assert_eq!(deserialized.p90_cyclomatic, 12);
667 assert_eq!(deserialized.hotspot_count, Some(5));
668 assert!(!json.contains("duplication_pct"));
670 assert!(deserialized.duplication_pct.is_none());
671 }
672
673 #[test]
674 fn vital_signs_snapshot_roundtrip() {
675 let snapshot = VitalSignsSnapshot {
676 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
677 version: "1.8.1".into(),
678 timestamp: "2026-03-25T14:30:00Z".into(),
679 git_sha: Some("abc1234".into()),
680 git_branch: Some("main".into()),
681 shallow_clone: false,
682 vital_signs: VitalSigns {
683 dead_file_pct: Some(3.2),
684 dead_export_pct: Some(8.1),
685 avg_cyclomatic: 4.7,
686 p90_cyclomatic: 12,
687 duplication_pct: None,
688 hotspot_count: None,
689 maintainability_avg: Some(72.4),
690 unused_dep_count: Some(4),
691 circular_dep_count: Some(2),
692 },
693 counts: VitalSignsCounts {
694 total_files: 1200,
695 total_exports: 5400,
696 dead_files: 38,
697 dead_exports: 437,
698 duplicated_lines: None,
699 total_lines: None,
700 files_scored: Some(1150),
701 total_deps: 42,
702 },
703 score: Some(78.5),
704 grade: Some("B".into()),
705 };
706 let json = serde_json::to_string_pretty(&snapshot).unwrap();
707 let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
708 assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
709 assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
710 assert_eq!(rt.counts.total_files, 1200);
711 assert_eq!(rt.counts.dead_exports, 437);
712 assert_eq!(rt.score, Some(78.5));
713 assert_eq!(rt.grade.as_deref(), Some("B"));
714 }
715
716 #[test]
717 fn refactoring_target_skips_empty_factors() {
718 let target = RefactoringTarget {
719 path: std::path::PathBuf::from("/src/foo.ts"),
720 priority: 75.0,
721 efficiency: 75.0,
722 recommendation: "Test recommendation".into(),
723 category: RecommendationCategory::RemoveDeadCode,
724 effort: EffortEstimate::Low,
725 confidence: Confidence::High,
726 factors: vec![],
727 evidence: None,
728 };
729 let json = serde_json::to_string(&target).unwrap();
730 assert!(!json.contains("factors"));
731 assert!(!json.contains("evidence"));
732 }
733
734 #[test]
735 fn effort_numeric_values() {
736 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
737 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
738 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
739 }
740
741 #[test]
742 fn confidence_labels_are_non_empty() {
743 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
744 for level in &levels {
745 assert!(!level.label().is_empty(), "{level:?} should have a label");
746 }
747 }
748
749 #[test]
750 fn confidence_serializes_as_snake_case() {
751 let json = serde_json::to_string(&Confidence::High).unwrap();
752 assert_eq!(json, r#""high""#);
753 let json = serde_json::to_string(&Confidence::Medium).unwrap();
754 assert_eq!(json, r#""medium""#);
755 let json = serde_json::to_string(&Confidence::Low).unwrap();
756 assert_eq!(json, r#""low""#);
757 }
758
759 #[test]
760 fn contributing_factor_serializes_correctly() {
761 let factor = ContributingFactor {
762 metric: "fan_in",
763 value: 15.0,
764 threshold: 10.0,
765 detail: "15 files depend on this".into(),
766 };
767 let json = serde_json::to_string(&factor).unwrap();
768 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
769 assert_eq!(parsed["metric"], "fan_in");
770 assert_eq!(parsed["value"], 15.0);
771 assert_eq!(parsed["threshold"], 10.0);
772 }
773
774 #[test]
777 fn category_compact_labels_are_non_empty() {
778 let categories = [
779 RecommendationCategory::UrgentChurnComplexity,
780 RecommendationCategory::BreakCircularDependency,
781 RecommendationCategory::SplitHighImpact,
782 RecommendationCategory::RemoveDeadCode,
783 RecommendationCategory::ExtractComplexFunctions,
784 RecommendationCategory::ExtractDependencies,
785 ];
786 for cat in &categories {
787 assert!(
788 !cat.compact_label().is_empty(),
789 "{cat:?} should have a compact_label"
790 );
791 }
792 }
793
794 #[test]
795 fn category_compact_labels_are_unique() {
796 let categories = [
797 RecommendationCategory::UrgentChurnComplexity,
798 RecommendationCategory::BreakCircularDependency,
799 RecommendationCategory::SplitHighImpact,
800 RecommendationCategory::RemoveDeadCode,
801 RecommendationCategory::ExtractComplexFunctions,
802 RecommendationCategory::ExtractDependencies,
803 ];
804 let labels: Vec<&str> = categories.iter().map(|c| c.compact_label()).collect();
805 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
806 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
807 }
808
809 #[test]
810 fn category_compact_labels_have_no_spaces() {
811 let categories = [
812 RecommendationCategory::UrgentChurnComplexity,
813 RecommendationCategory::BreakCircularDependency,
814 RecommendationCategory::SplitHighImpact,
815 RecommendationCategory::RemoveDeadCode,
816 RecommendationCategory::ExtractComplexFunctions,
817 RecommendationCategory::ExtractDependencies,
818 ];
819 for cat in &categories {
820 assert!(
821 !cat.compact_label().contains(' '),
822 "compact_label for {:?} should not contain spaces: '{}'",
823 cat,
824 cat.compact_label()
825 );
826 }
827 }
828
829 #[test]
832 fn effort_labels_are_non_empty() {
833 let efforts = [
834 EffortEstimate::Low,
835 EffortEstimate::Medium,
836 EffortEstimate::High,
837 ];
838 for effort in &efforts {
839 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
840 }
841 }
842
843 #[test]
844 fn effort_serializes_as_snake_case() {
845 assert_eq!(
846 serde_json::to_string(&EffortEstimate::Low).unwrap(),
847 r#""low""#
848 );
849 assert_eq!(
850 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
851 r#""medium""#
852 );
853 assert_eq!(
854 serde_json::to_string(&EffortEstimate::High).unwrap(),
855 r#""high""#
856 );
857 }
858
859 #[test]
862 fn vital_signs_all_none_optional_fields_omitted() {
863 let vs = VitalSigns {
864 dead_file_pct: None,
865 dead_export_pct: None,
866 avg_cyclomatic: 5.0,
867 p90_cyclomatic: 10,
868 duplication_pct: None,
869 hotspot_count: None,
870 maintainability_avg: None,
871 unused_dep_count: None,
872 circular_dep_count: None,
873 };
874 let json = serde_json::to_string(&vs).unwrap();
875 assert!(!json.contains("dead_file_pct"));
876 assert!(!json.contains("dead_export_pct"));
877 assert!(!json.contains("duplication_pct"));
878 assert!(!json.contains("hotspot_count"));
879 assert!(!json.contains("maintainability_avg"));
880 assert!(!json.contains("unused_dep_count"));
881 assert!(!json.contains("circular_dep_count"));
882 assert!(json.contains("avg_cyclomatic"));
884 assert!(json.contains("p90_cyclomatic"));
885 }
886
887 #[test]
890 fn exceeded_threshold_all_variants_serialize() {
891 for variant in [
892 ExceededThreshold::Cyclomatic,
893 ExceededThreshold::Cognitive,
894 ExceededThreshold::Both,
895 ] {
896 let json = serde_json::to_string(&variant).unwrap();
897 assert!(!json.is_empty());
898 }
899 }
900
901 #[test]
904 fn target_evidence_skips_empty_fields() {
905 let evidence = TargetEvidence {
906 unused_exports: vec![],
907 complex_functions: vec![],
908 cycle_path: vec![],
909 };
910 let json = serde_json::to_string(&evidence).unwrap();
911 assert!(!json.contains("unused_exports"));
912 assert!(!json.contains("complex_functions"));
913 assert!(!json.contains("cycle_path"));
914 }
915
916 #[test]
917 fn target_evidence_with_data() {
918 let evidence = TargetEvidence {
919 unused_exports: vec!["foo".to_string(), "bar".to_string()],
920 complex_functions: vec![EvidenceFunction {
921 name: "processData".into(),
922 line: 42,
923 cognitive: 30,
924 }],
925 cycle_path: vec![],
926 };
927 let json = serde_json::to_string(&evidence).unwrap();
928 assert!(json.contains("unused_exports"));
929 assert!(json.contains("complex_functions"));
930 assert!(json.contains("processData"));
931 assert!(!json.contains("cycle_path"));
932 }
933
934 #[test]
937 fn snapshot_schema_version_is_two() {
938 assert_eq!(SNAPSHOT_SCHEMA_VERSION, 2);
939 }
940
941 #[test]
942 fn hotspot_score_threshold_is_50() {
943 assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
944 }
945
946 #[test]
947 fn snapshot_v1_deserializes_with_default_score_and_grade() {
948 let json = r#"{
950 "snapshot_schema_version": 1,
951 "version": "1.5.0",
952 "timestamp": "2025-01-01T00:00:00Z",
953 "shallow_clone": false,
954 "vital_signs": {
955 "avg_cyclomatic": 2.0,
956 "p90_cyclomatic": 5
957 },
958 "counts": {
959 "total_files": 100,
960 "total_exports": 500,
961 "dead_files": 0,
962 "dead_exports": 0,
963 "total_deps": 20
964 }
965 }"#;
966 let snap: VitalSignsSnapshot = serde_json::from_str(json).unwrap();
967 assert!(snap.score.is_none());
968 assert!(snap.grade.is_none());
969 assert_eq!(snap.snapshot_schema_version, 1);
970 }
971
972 #[test]
975 fn letter_grade_boundaries() {
976 assert_eq!(letter_grade(100.0), "A");
977 assert_eq!(letter_grade(85.0), "A");
978 assert_eq!(letter_grade(84.9), "B");
979 assert_eq!(letter_grade(70.0), "B");
980 assert_eq!(letter_grade(69.9), "C");
981 assert_eq!(letter_grade(55.0), "C");
982 assert_eq!(letter_grade(54.9), "D");
983 assert_eq!(letter_grade(40.0), "D");
984 assert_eq!(letter_grade(39.9), "F");
985 assert_eq!(letter_grade(0.0), "F");
986 }
987
988 #[test]
991 fn health_score_serializes_correctly() {
992 let score = HealthScore {
993 score: 78.5,
994 grade: "B",
995 penalties: HealthScorePenalties {
996 dead_files: Some(3.1),
997 dead_exports: Some(6.0),
998 complexity: 0.0,
999 p90_complexity: 0.0,
1000 maintainability: None,
1001 hotspots: None,
1002 unused_deps: Some(5.0),
1003 circular_deps: Some(4.0),
1004 },
1005 };
1006 let json = serde_json::to_string(&score).unwrap();
1007 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1008 assert_eq!(parsed["score"], 78.5);
1009 assert_eq!(parsed["grade"], "B");
1010 assert_eq!(parsed["penalties"]["dead_files"], 3.1);
1011 assert!(!json.contains("maintainability"));
1013 assert!(!json.contains("hotspots"));
1014 }
1015
1016 #[test]
1017 fn health_score_none_skipped_in_report() {
1018 let report = HealthReport {
1019 findings: vec![],
1020 summary: HealthSummary {
1021 files_analyzed: 0,
1022 functions_analyzed: 0,
1023 functions_above_threshold: 0,
1024 max_cyclomatic_threshold: 20,
1025 max_cognitive_threshold: 15,
1026 files_scored: None,
1027 average_maintainability: None,
1028 },
1029 vital_signs: None,
1030 health_score: None,
1031 file_scores: vec![],
1032 hotspots: vec![],
1033 hotspot_summary: None,
1034 targets: vec![],
1035 target_thresholds: None,
1036 };
1037 let json = serde_json::to_string(&report).unwrap();
1038 assert!(!json.contains("health_score"));
1039 }
1040}