1use crate::CoverageModel;
4
5pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
6
7pub const COGNITIVE_EXTRACTION_THRESHOLD: u16 = 30;
8
9pub const DEFAULT_COGNITIVE_HIGH: u16 = 25;
10
11pub const DEFAULT_COGNITIVE_CRITICAL: u16 = 40;
12
13pub const DEFAULT_CYCLOMATIC_HIGH: u16 = 30;
14
15pub const DEFAULT_CYCLOMATIC_CRITICAL: u16 = 50;
16
17pub const MI_DENSITY_MIN_LINES: f64 = 50.0;
19
20pub const HEALTH_SCORE_FORMULA_VERSION: u32 = 2;
21
22#[expect(
25 clippy::trivially_copy_pass_by_ref,
26 reason = "serde skip_serializing_if requires a by-reference predicate"
27)]
28fn is_zero_u16(value: &u16) -> bool {
29 *value == 0
30}
31
32#[derive(Debug, Clone, serde::Serialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34pub struct HealthScore {
35 pub formula_version: u32,
36 pub score: f64,
37 pub grade: &'static str,
38 pub penalties: HealthScorePenalties,
39}
40
41#[derive(Debug, Clone, serde::Serialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44pub struct HealthScorePenalties {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub dead_files: Option<f64>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub dead_exports: Option<f64>,
49 pub complexity: f64,
50 pub p90_complexity: f64,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub maintainability: Option<f64>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub hotspots: Option<f64>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub unused_deps: Option<f64>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub circular_deps: Option<f64>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub unit_size: Option<f64>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub coupling: Option<f64>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub duplication: Option<f64>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub prop_drilling: Option<f64>,
69}
70
71#[must_use]
73#[expect(
74 clippy::cast_possible_truncation,
75 reason = "score is 0-100, fits in u32"
76)]
77pub const fn letter_grade(score: f64) -> &'static str {
78 let s = score as u32;
79 if s >= 85 {
80 "A"
81 } else if s >= 70 {
82 "B"
83 } else if s >= 55 {
84 "C"
85 } else if s >= 40 {
86 "D"
87 } else {
88 "F"
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[serde(rename_all = "snake_case")]
96pub enum CoverageTier {
97 None,
98 Partial,
99 High,
100}
101
102const HIGH_COVERAGE_WATERMARK: f64 = 70.0;
104
105impl CoverageTier {
106 #[must_use]
108 pub fn from_pct(pct: f64) -> Self {
109 if pct <= 0.0 {
110 Self::None
111 } else if pct >= HIGH_COVERAGE_WATERMARK {
112 Self::High
113 } else {
114 Self::Partial
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122#[serde(rename_all = "snake_case")]
123pub enum CoverageSource {
124 Istanbul,
125 Estimated,
126 EstimatedComponentInherited,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132#[serde(rename_all = "snake_case")]
133pub enum CoverageSourceConsistency {
134 Uniform,
135 Mixed,
136}
137
138#[must_use]
140pub fn summarize_coverage_source_consistency(
141 sources: impl IntoIterator<Item = CoverageSource>,
142) -> Option<CoverageSourceConsistency> {
143 let mut first = None;
144 for source in sources {
145 match first {
146 None => first = Some(source),
147 Some(existing) if existing != source => {
148 return Some(CoverageSourceConsistency::Mixed);
149 }
150 Some(_) => {}
151 }
152 }
153 first.map(|_| CoverageSourceConsistency::Uniform)
154}
155
156#[derive(Debug, Clone, serde::Serialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct ReactHookProfile {
171 pub state: u16,
173 pub effect: u16,
175 pub memo: u16,
177 pub callback: u16,
179 pub custom: u16,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub max_effect_dep_arity: Option<u32>,
187}
188
189impl ReactHookProfile {
190 #[must_use]
193 pub fn total(&self) -> u16 {
194 self.state
195 .saturating_add(self.effect)
196 .saturating_add(self.memo)
197 .saturating_add(self.callback)
198 .saturating_add(self.custom)
199 }
200
201 #[must_use]
203 pub fn is_empty(&self) -> bool {
204 self.total() == 0
205 }
206}
207
208#[derive(Debug, Clone, serde::Serialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct ComplexityViolation {
212 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
213 pub path: std::path::PathBuf,
214 pub name: String,
215 pub line: u32,
216 pub col: u32,
217 pub cyclomatic: u16,
218 pub cognitive: u16,
219 pub line_count: u32,
220 pub param_count: u8,
221 #[serde(default, skip_serializing_if = "is_zero_u16")]
225 pub react_hook_count: u16,
226 #[serde(default, skip_serializing_if = "is_zero_u16")]
229 pub react_jsx_max_depth: u16,
230 #[serde(default, skip_serializing_if = "is_zero_u16")]
233 pub react_prop_count: u16,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub react_hook_profile: Option<ReactHookProfile>,
241 pub exceeded: ExceededThreshold,
242 pub severity: FindingSeverity,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub crap: Option<f64>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub coverage_pct: Option<f64>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub coverage_tier: Option<CoverageTier>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub coverage_source: Option<CoverageSource>,
251 #[serde(
252 default,
253 serialize_with = "fallow_types::serde_path::serialize_option",
254 skip_serializing_if = "Option::is_none"
255 )]
256 pub inherited_from: Option<std::path::PathBuf>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub component_rollup: Option<ComponentRollup>,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 pub contributions: Vec<fallow_types::extract::ComplexityContribution>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub effective_thresholds: Option<HealthEffectiveThresholds>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub threshold_source: Option<ThresholdSource>,
272}
273
274#[derive(Debug, Clone, Copy, serde::Serialize)]
276#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
277#[allow(
278 clippy::struct_field_names,
279 reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
280)]
281pub struct HealthEffectiveThresholds {
282 pub max_cyclomatic: u16,
283 pub max_cognitive: u16,
284 pub max_crap: f64,
285}
286
287#[derive(Debug, Clone, Copy, serde::Serialize)]
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
290#[allow(
291 clippy::struct_field_names,
292 reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
293)]
294pub struct HealthConfiguredThresholds {
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub max_cyclomatic: Option<u16>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub max_cognitive: Option<u16>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub max_crap: Option<f64>,
301}
302
303#[derive(Debug, Clone, Copy, serde::Serialize)]
305#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
306#[serde(rename_all = "snake_case")]
307pub enum ThresholdSource {
308 Override,
309}
310
311#[derive(Debug, Clone, Copy, serde::Serialize)]
313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
314#[serde(rename_all = "snake_case")]
315pub enum ThresholdOverrideStatus {
316 Active,
317 Stale,
318 NoMatch,
319}
320
321#[derive(Debug, Clone, Copy, serde::Serialize)]
323#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
324pub struct ThresholdOverrideMetrics {
325 pub cyclomatic: u16,
326 pub cognitive: u16,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub crap: Option<f64>,
329}
330
331#[derive(Debug, Clone, serde::Serialize)]
334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
335pub struct ThresholdOverrideState {
336 pub status: ThresholdOverrideStatus,
337 pub override_index: usize,
338 #[serde(
339 default,
340 serialize_with = "fallow_types::serde_path::serialize_option",
341 skip_serializing_if = "Option::is_none"
342 )]
343 pub path: Option<std::path::PathBuf>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub function: Option<String>,
346 pub configured_thresholds: HealthConfiguredThresholds,
347 pub effective_thresholds: HealthEffectiveThresholds,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub metrics: Option<ThresholdOverrideMetrics>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub reason: Option<String>,
352}
353
354#[derive(Debug, Clone, serde::Serialize)]
355#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
356pub struct ComponentRollup {
357 pub component: String,
358 pub class_worst_function: String,
359 pub class_cyclomatic: u16,
360 pub class_cognitive: u16,
361 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
362 pub template_path: std::path::PathBuf,
363 pub template_cyclomatic: u16,
364 pub template_cognitive: u16,
365}
366
367#[derive(Debug, Clone, Copy, serde::Serialize)]
369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
370#[serde(rename_all = "snake_case")]
371pub enum ExceededThreshold {
372 Cyclomatic,
374 Cognitive,
376 Both,
378 Crap,
380 CyclomaticCrap,
382 CognitiveCrap,
384 All,
386}
387
388impl ExceededThreshold {
389 #[must_use]
395 pub fn from_bools(cyclomatic: bool, cognitive: bool, crap: bool) -> Self {
396 match (cyclomatic, cognitive, crap) {
397 (true, true, true) => Self::All,
398 (true, true, false) => Self::Both,
399 (true, false, true) => Self::CyclomaticCrap,
400 (false, true, true) => Self::CognitiveCrap,
401 (true, false, false) => Self::Cyclomatic,
402 (false, true, false) => Self::Cognitive,
403 (false, false, true) => Self::Crap,
404 (false, false, false) => {
405 unreachable!("ExceededThreshold requires at least one threshold exceeded")
406 }
407 }
408 }
409
410 #[must_use]
412 pub const fn includes_cyclomatic(self) -> bool {
413 matches!(
414 self,
415 Self::Cyclomatic | Self::Both | Self::CyclomaticCrap | Self::All
416 )
417 }
418
419 #[must_use]
421 pub const fn includes_cognitive(self) -> bool {
422 matches!(
423 self,
424 Self::Cognitive | Self::Both | Self::CognitiveCrap | Self::All
425 )
426 }
427
428 #[must_use]
430 pub const fn includes_crap(self) -> bool {
431 matches!(
432 self,
433 Self::Crap | Self::CyclomaticCrap | Self::CognitiveCrap | Self::All
434 )
435 }
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
443#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
444#[serde(rename_all = "snake_case")]
445pub enum FindingSeverity {
446 Moderate,
448 High,
450 Critical,
452}
453
454pub const DEFAULT_CRAP_HIGH: f64 = 50.0;
456
457pub const DEFAULT_CRAP_CRITICAL: f64 = 100.0;
461
462#[expect(
468 clippy::too_many_arguments,
469 reason = "public library API for napi/embedders; the metric values and their high/critical threshold pairs are a stable positional contract that bundling would break"
470)]
471pub fn compute_finding_severity(
472 cognitive: u16,
473 cyclomatic: u16,
474 crap: Option<f64>,
475 cognitive_high: u16,
476 cognitive_critical: u16,
477 cyclomatic_high: u16,
478 cyclomatic_critical: u16,
479) -> FindingSeverity {
480 let cog = if cognitive >= cognitive_critical {
481 FindingSeverity::Critical
482 } else if cognitive >= cognitive_high {
483 FindingSeverity::High
484 } else {
485 FindingSeverity::Moderate
486 };
487
488 let cyc = if cyclomatic >= cyclomatic_critical {
489 FindingSeverity::Critical
490 } else if cyclomatic >= cyclomatic_high {
491 FindingSeverity::High
492 } else {
493 FindingSeverity::Moderate
494 };
495
496 let crap_sev = crap.map_or(FindingSeverity::Moderate, |c| {
497 if c >= DEFAULT_CRAP_CRITICAL {
498 FindingSeverity::Critical
499 } else if c >= DEFAULT_CRAP_HIGH {
500 FindingSeverity::High
501 } else {
502 FindingSeverity::Moderate
503 }
504 });
505
506 cog.max(cyc).max(crap_sev)
507}
508
509#[derive(Debug, Clone, serde::Serialize)]
511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
512pub struct LargeFunctionEntry {
513 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
514 pub path: std::path::PathBuf,
515 pub name: String,
516 pub line: u32,
517 pub line_count: u32,
518}
519
520#[derive(Debug, Clone, serde::Serialize)]
522#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
523pub struct HealthSummary {
524 pub files_analyzed: usize,
525 pub functions_analyzed: usize,
526 pub functions_above_threshold: usize,
527 pub max_cyclomatic_threshold: u16,
528 pub max_cognitive_threshold: u16,
529 pub max_crap_threshold: f64,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub files_scored: Option<usize>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub average_maintainability: Option<f64>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub coverage_model: Option<CoverageModel>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub coverage_source_consistency: Option<CoverageSourceConsistency>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub istanbul_matched: Option<usize>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub istanbul_total: Option<usize>,
542 pub severity_critical_count: usize,
543 pub severity_high_count: usize,
544 pub severity_moderate_count: usize,
545}
546
547impl Default for HealthSummary {
548 fn default() -> Self {
549 Self {
550 files_analyzed: 0,
551 functions_analyzed: 0,
552 functions_above_threshold: 0,
553 max_cyclomatic_threshold: 20,
554 max_cognitive_threshold: 15,
555 max_crap_threshold: 30.0,
556 files_scored: None,
557 average_maintainability: None,
558 coverage_model: None,
559 coverage_source_consistency: None,
560 istanbul_matched: None,
561 istanbul_total: None,
562 severity_critical_count: 0,
563 severity_high_count: 0,
564 severity_moderate_count: 0,
565 }
566 }
567}
568
569#[derive(Debug, Clone, serde::Serialize)]
571#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
572pub struct FileHealthScore {
573 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
574 pub path: std::path::PathBuf,
575 pub fan_in: usize,
576 pub fan_out: usize,
577 pub dead_code_ratio: f64,
578 pub complexity_density: f64,
579 pub maintainability_index: f64,
580 pub total_cyclomatic: u32,
581 pub total_cognitive: u32,
582 pub function_count: usize,
583 pub lines: u32,
584 pub crap_max: f64,
585 pub crap_above_threshold: usize,
586}
587
588#[derive(Debug, Clone, serde::Serialize)]
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591pub struct HotspotEntry {
592 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
593 pub path: std::path::PathBuf,
594 pub score: f64,
595 pub commits: u32,
596 pub weighted_commits: f64,
597 pub lines_added: u32,
598 pub lines_deleted: u32,
599 pub complexity_density: f64,
600 pub fan_in: usize,
601 pub trend: fallow_types::churn::ChurnTrend,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub ownership: Option<OwnershipMetrics>,
604 #[serde(skip_serializing_if = "std::ops::Not::not")]
605 #[cfg_attr(feature = "schema", schemars(default))]
606 pub is_test_path: bool,
607}
608
609#[derive(Debug, Clone, serde::Serialize)]
610#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
611pub struct ContributorEntry {
612 pub identifier: String,
613 pub format: ContributorIdentifierFormat,
614 pub share: f64,
615 pub stale_days: u64,
616 pub commits: u32,
617}
618
619#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
620#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
621#[serde(rename_all = "kebab-case")]
622pub enum ContributorIdentifierFormat {
623 Raw,
624 Handle,
625 Anonymized,
626 Hash,
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
630#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
631#[serde(rename_all = "snake_case")]
632pub enum OwnershipState {
633 Active,
634 Unowned,
635 DeclaredInactive,
636 Drifting,
637}
638
639#[derive(Debug, Clone, serde::Serialize)]
640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
641pub struct OwnershipMetrics {
642 pub bus_factor: u32,
643
644 pub contributor_count: u32,
645
646 pub top_contributor: ContributorEntry,
647
648 #[serde(default, skip_serializing_if = "Vec::is_empty")]
649 #[cfg_attr(feature = "schema", schemars(default))]
650 pub recent_contributors: Vec<ContributorEntry>,
651
652 #[serde(default, skip_serializing_if = "Vec::is_empty")]
653 #[cfg_attr(feature = "schema", schemars(default))]
654 pub suggested_reviewers: Vec<ContributorEntry>,
655
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub declared_owner: Option<String>,
658
659 pub unowned: Option<bool>,
660
661 pub ownership_state: OwnershipState,
662
663 pub drift: bool,
664
665 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub drift_reason: Option<String>,
667}
668
669#[derive(Debug, Clone, serde::Serialize)]
670#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
671pub struct HotspotSummary {
672 pub since: String,
673 pub min_commits: u32,
674 pub files_analyzed: usize,
675 pub files_excluded: usize,
676 pub shallow_clone: bool,
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn exceeded_threshold_serializes_as_snake_case() {
685 let json = serde_json::to_string(&ExceededThreshold::Both)
686 .expect("threshold variant should serialize");
687 assert_eq!(json, r#""both""#);
688
689 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic)
690 .expect("threshold variant should serialize");
691 assert_eq!(json, r#""cyclomatic""#);
692 }
693
694 #[test]
695 fn exceeded_threshold_all_variants_serialize() {
696 for (variant, expected) in [
697 (ExceededThreshold::Cyclomatic, r#""cyclomatic""#),
698 (ExceededThreshold::Cognitive, r#""cognitive""#),
699 (ExceededThreshold::Both, r#""both""#),
700 (ExceededThreshold::Crap, r#""crap""#),
701 (ExceededThreshold::CyclomaticCrap, r#""cyclomatic_crap""#),
702 (ExceededThreshold::CognitiveCrap, r#""cognitive_crap""#),
703 (ExceededThreshold::All, r#""all""#),
704 ] {
705 let json = serde_json::to_string(&variant).expect("threshold variant should serialize");
706 assert_eq!(json, expected, "wire form for {variant:?} should be stable");
707 }
708 }
709
710 #[test]
711 fn letter_grade_boundaries() {
712 assert_eq!(letter_grade(100.0), "A");
713 assert_eq!(letter_grade(85.0), "A");
714 assert_eq!(letter_grade(84.9), "B");
715 assert_eq!(letter_grade(70.0), "B");
716 assert_eq!(letter_grade(69.9), "C");
717 assert_eq!(letter_grade(55.0), "C");
718 assert_eq!(letter_grade(54.9), "D");
719 assert_eq!(letter_grade(40.0), "D");
720 assert_eq!(letter_grade(39.9), "F");
721 assert_eq!(letter_grade(0.0), "F");
722 }
723
724 #[test]
725 fn coverage_tier_boundaries() {
726 assert_eq!(CoverageTier::from_pct(0.0), CoverageTier::None);
727 assert_eq!(CoverageTier::from_pct(0.1), CoverageTier::Partial);
728 assert_eq!(CoverageTier::from_pct(69.9), CoverageTier::Partial);
729 assert_eq!(CoverageTier::from_pct(70.0), CoverageTier::High);
730 assert_eq!(CoverageTier::from_pct(100.0), CoverageTier::High);
731 }
732
733 #[test]
734 fn hotspot_score_threshold_is_50() {
735 assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
736 }
737
738 #[test]
739 fn health_score_serializes_correctly() {
740 let score = HealthScore {
741 formula_version: HEALTH_SCORE_FORMULA_VERSION,
742 score: 78.5,
743 grade: "B",
744 penalties: HealthScorePenalties {
745 dead_files: Some(3.1),
746 dead_exports: Some(6.0),
747 complexity: 0.0,
748 p90_complexity: 0.0,
749 maintainability: None,
750 hotspots: None,
751 unused_deps: Some(5.0),
752 circular_deps: Some(4.0),
753 unit_size: None,
754 coupling: None,
755 duplication: None,
756 prop_drilling: None,
757 },
758 };
759 let json = serde_json::to_string(&score).expect("health score should serialize");
760 let parsed: serde_json::Value =
761 serde_json::from_str(&json).expect("health score JSON should parse");
762 assert_eq!(parsed["formula_version"], HEALTH_SCORE_FORMULA_VERSION);
763 assert_eq!(parsed["score"], 78.5);
764 assert_eq!(parsed["grade"], "B");
765 assert_eq!(parsed["penalties"]["dead_files"], 3.1);
766 assert!(!json.contains("maintainability"));
767 assert!(!json.contains("hotspots"));
768 assert!(!json.contains("duplication"));
769 }
770
771 #[test]
772 fn coverage_model_serializes_as_snake_case() {
773 let json = serde_json::to_string(&CoverageModel::StaticBinary)
774 .expect("coverage model should serialize");
775 assert_eq!(json, r#""static_binary""#);
776
777 let json = serde_json::to_string(&CoverageModel::StaticEstimated)
778 .expect("coverage model should serialize");
779 assert_eq!(json, r#""static_estimated""#);
780
781 let json = serde_json::to_string(&CoverageModel::Istanbul)
782 .expect("coverage model should serialize");
783 assert_eq!(json, r#""istanbul""#);
784 }
785
786 #[test]
787 fn finding_severity_serializes_as_snake_case() {
788 assert_eq!(
789 serde_json::to_string(&FindingSeverity::Moderate)
790 .expect("finding severity should serialize"),
791 r#""moderate""#,
792 );
793 assert_eq!(
794 serde_json::to_string(&FindingSeverity::High)
795 .expect("finding severity should serialize"),
796 r#""high""#,
797 );
798 assert_eq!(
799 serde_json::to_string(&FindingSeverity::Critical)
800 .expect("finding severity should serialize"),
801 r#""critical""#,
802 );
803 }
804
805 #[test]
806 fn finding_severity_ordering() {
807 assert!(FindingSeverity::Moderate < FindingSeverity::High);
808 assert!(FindingSeverity::High < FindingSeverity::Critical);
809 }
810
811 #[test]
812 fn compute_severity_moderate_when_below_high_thresholds() {
813 let severity = compute_finding_severity(20, 25, None, 25, 40, 30, 50);
814 assert_eq!(severity, FindingSeverity::Moderate);
815 }
816
817 #[test]
818 fn compute_severity_high_from_cognitive() {
819 let severity = compute_finding_severity(25, 20, None, 25, 40, 30, 50);
820 assert_eq!(severity, FindingSeverity::High);
821 }
822
823 #[test]
824 fn compute_severity_high_from_cyclomatic() {
825 let severity = compute_finding_severity(20, 30, None, 25, 40, 30, 50);
826 assert_eq!(severity, FindingSeverity::High);
827 }
828
829 #[test]
830 fn compute_severity_critical_from_cognitive() {
831 let severity = compute_finding_severity(40, 20, None, 25, 40, 30, 50);
832 assert_eq!(severity, FindingSeverity::Critical);
833 }
834
835 #[test]
836 fn compute_severity_critical_from_cyclomatic() {
837 let severity = compute_finding_severity(20, 50, None, 25, 40, 30, 50);
838 assert_eq!(severity, FindingSeverity::Critical);
839 }
840
841 #[test]
842 fn compute_severity_uses_highest_across_dimensions() {
843 let severity = compute_finding_severity(45, 20, None, 25, 40, 30, 50);
844 assert_eq!(severity, FindingSeverity::Critical);
845 }
846
847 #[test]
848 fn compute_severity_at_exact_boundaries() {
849 let severity = compute_finding_severity(25, 30, None, 25, 40, 30, 50);
850 assert_eq!(severity, FindingSeverity::High);
851
852 let severity = compute_finding_severity(24, 29, None, 25, 40, 30, 50);
853 assert_eq!(severity, FindingSeverity::Moderate);
854
855 let severity = compute_finding_severity(40, 50, None, 25, 40, 30, 50);
856 assert_eq!(severity, FindingSeverity::Critical);
857 }
858
859 #[test]
860 fn compute_severity_crap_contributes_high() {
861 let severity = compute_finding_severity(10, 10, Some(60.0), 25, 40, 30, 50);
862 assert_eq!(severity, FindingSeverity::High);
863 }
864
865 #[test]
866 fn compute_severity_crap_contributes_critical() {
867 let severity = compute_finding_severity(10, 10, Some(120.0), 25, 40, 30, 50);
868 assert_eq!(severity, FindingSeverity::Critical);
869 }
870
871 #[test]
872 fn compute_severity_crap_moderate_under_high() {
873 let severity = compute_finding_severity(10, 10, Some(30.0), 25, 40, 30, 50);
874 assert_eq!(severity, FindingSeverity::Moderate);
875 }
876
877 #[test]
878 fn exceeded_threshold_from_bools() {
879 assert!(matches!(
880 ExceededThreshold::from_bools(true, false, false),
881 ExceededThreshold::Cyclomatic
882 ));
883 assert!(matches!(
884 ExceededThreshold::from_bools(true, true, true),
885 ExceededThreshold::All
886 ));
887 assert!(matches!(
888 ExceededThreshold::from_bools(false, false, true),
889 ExceededThreshold::Crap
890 ));
891 assert!(matches!(
892 ExceededThreshold::from_bools(true, false, true),
893 ExceededThreshold::CyclomaticCrap
894 ));
895 }
896
897 #[test]
898 fn exceeded_threshold_includes_helpers() {
899 let all = ExceededThreshold::All;
900 assert!(all.includes_cyclomatic());
901 assert!(all.includes_cognitive());
902 assert!(all.includes_crap());
903
904 let crap_only = ExceededThreshold::Crap;
905 assert!(!crap_only.includes_cyclomatic());
906 assert!(!crap_only.includes_cognitive());
907 assert!(crap_only.includes_crap());
908
909 assert!(ExceededThreshold::CyclomaticCrap.includes_crap());
910 assert!(ExceededThreshold::CognitiveCrap.includes_crap());
911 assert!(!ExceededThreshold::Both.includes_crap());
912 assert!(!ExceededThreshold::Cyclomatic.includes_crap());
913 assert!(!ExceededThreshold::Cognitive.includes_crap());
914 }
915
916 #[test]
917 fn coverage_source_consistency_omits_empty_sources() {
918 let sources = Vec::new();
919 assert_eq!(summarize_coverage_source_consistency(sources), None);
920 }
921
922 #[test]
923 fn coverage_source_consistency_reports_uniform_sources() {
924 assert_eq!(
925 summarize_coverage_source_consistency([
926 CoverageSource::Estimated,
927 CoverageSource::Estimated,
928 ]),
929 Some(CoverageSourceConsistency::Uniform)
930 );
931 }
932
933 #[test]
934 fn coverage_source_consistency_reports_mixed_sources() {
935 assert_eq!(
936 summarize_coverage_source_consistency([
937 CoverageSource::Istanbul,
938 CoverageSource::Estimated,
939 ]),
940 Some(CoverageSourceConsistency::Mixed)
941 );
942 }
943}