1use std::path::PathBuf;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6const fn default_max_cyclomatic() -> u16 {
7 20
8}
9
10const fn default_max_cognitive() -> u16 {
11 15
12}
13
14const fn default_max_crap() -> f64 {
18 30.0
19}
20
21const fn default_crap_refactor_band() -> u16 {
22 5
23}
24
25const fn default_max_unit_size() -> u32 {
29 60
30}
31
32const fn default_suggest_inline_suppression() -> bool {
36 true
37}
38
39fn default_bot_patterns() -> Vec<String> {
54 vec![
55 r"*\[bot\]*".to_string(),
56 "dependabot*".to_string(),
57 "renovate*".to_string(),
58 "github-actions*".to_string(),
59 "svc-*".to_string(),
60 "*-service-account*".to_string(),
61 ]
62}
63
64const fn default_email_mode() -> EmailMode {
65 EmailMode::Handle
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
73#[serde(rename_all = "kebab-case")]
74pub enum EmailMode {
75 Raw,
78 Handle,
81 Anonymized,
87 Hash,
89}
90
91#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
93#[serde(rename_all = "camelCase")]
94pub struct OwnershipConfig {
95 #[serde(default = "default_bot_patterns")]
99 pub bot_patterns: Vec<String>,
100
101 #[serde(default = "default_email_mode")]
105 pub email_mode: EmailMode,
106}
107
108impl Default for OwnershipConfig {
109 fn default() -> Self {
110 Self {
111 bot_patterns: default_bot_patterns(),
112 email_mode: default_email_mode(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
124#[serde(deny_unknown_fields, rename_all = "camelCase")]
125pub struct ChangeCouplingConfig {
126 #[serde(default = "default_coupling_max_files_per_commit")]
130 #[schemars(range(min = 2, max = 1000))]
131 pub max_files_per_commit: usize,
132 #[serde(default = "default_coupling_min_revisions")]
134 pub min_revisions: u32,
135 #[serde(default = "default_coupling_min_shared_revisions")]
137 pub min_shared_revisions: u32,
138 #[serde(default = "default_coupling_min_degree")]
142 #[schemars(range(min = 0.0, max = 1.0))]
143 pub min_degree: f64,
144}
145
146const fn default_coupling_max_files_per_commit() -> usize {
147 30
148}
149
150const fn default_coupling_min_revisions() -> u32 {
151 5
152}
153
154const fn default_coupling_min_shared_revisions() -> u32 {
155 5
156}
157
158const fn default_coupling_min_degree() -> f64 {
159 0.3
160}
161
162const MIN_COUPLING_FILES_PER_COMMIT: usize = 2;
164
165const MAX_COUPLING_FILES_PER_COMMIT: usize = 1000;
168
169impl ChangeCouplingConfig {
170 #[must_use]
172 pub fn errors(&self) -> Vec<String> {
173 let mut errors = Vec::new();
174 if !(MIN_COUPLING_FILES_PER_COMMIT..=MAX_COUPLING_FILES_PER_COMMIT)
175 .contains(&self.max_files_per_commit)
176 {
177 errors.push(format!(
178 "health.changeCoupling.maxFilesPerCommit must be from {MIN_COUPLING_FILES_PER_COMMIT} to {MAX_COUPLING_FILES_PER_COMMIT}, found {}",
179 self.max_files_per_commit
180 ));
181 }
182 if !(0.0..=1.0).contains(&self.min_degree) {
183 errors.push(format!(
184 "health.changeCoupling.minDegree must be from 0 to 1, found {}",
185 self.min_degree
186 ));
187 }
188 errors
189 }
190}
191
192impl Default for ChangeCouplingConfig {
193 fn default() -> Self {
194 Self {
195 max_files_per_commit: default_coupling_max_files_per_commit(),
196 min_revisions: default_coupling_min_revisions(),
197 min_shared_revisions: default_coupling_min_shared_revisions(),
198 min_degree: default_coupling_min_degree(),
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
210#[serde(rename_all = "kebab-case")]
211pub enum CyclomaticPolicy {
212 #[default]
214 Standard,
215 LiteralMembership,
222}
223
224impl CyclomaticPolicy {
225 #[must_use]
227 pub const fn is_standard(&self) -> bool {
228 matches!(self, Self::Standard)
229 }
230}
231
232#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
234#[serde(deny_unknown_fields, rename_all = "camelCase")]
235pub struct HealthConfig {
236 #[serde(default = "default_max_cyclomatic")]
241 pub max_cyclomatic: u16,
242
243 #[serde(default, skip_serializing_if = "CyclomaticPolicy::is_standard")]
253 pub cyclomatic_policy: CyclomaticPolicy,
254
255 #[serde(default = "default_max_cognitive")]
259 pub max_cognitive: u16,
260
261 #[serde(default = "default_max_crap")]
277 pub max_crap: f64,
278
279 #[serde(default = "default_crap_refactor_band")]
284 pub crap_refactor_band: u16,
285
286 #[serde(default = "default_max_unit_size")]
297 pub max_unit_size: u32,
298
299 #[serde(default)]
306 pub coverage: Option<PathBuf>,
307
308 #[serde(default)]
315 pub coverage_root: Option<PathBuf>,
316
317 #[serde(default)]
320 pub ignore: Vec<String>,
321
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
326 pub threshold_overrides: Vec<HealthThresholdOverride>,
327
328 #[serde(default)]
331 pub ownership: OwnershipConfig,
332
333 #[serde(default)]
338 pub change_coupling: ChangeCouplingConfig,
339
340 #[serde(default = "default_suggest_inline_suppression")]
349 pub suggest_inline_suppression: bool,
350}
351
352impl Default for HealthConfig {
353 fn default() -> Self {
354 Self {
355 max_cyclomatic: default_max_cyclomatic(),
356 cyclomatic_policy: CyclomaticPolicy::Standard,
357 max_cognitive: default_max_cognitive(),
358 max_crap: default_max_crap(),
359 crap_refactor_band: default_crap_refactor_band(),
360 max_unit_size: default_max_unit_size(),
361 coverage: None,
362 coverage_root: None,
363 ignore: vec![],
364 threshold_overrides: vec![],
365 ownership: OwnershipConfig::default(),
366 change_coupling: ChangeCouplingConfig::default(),
367 suggest_inline_suppression: default_suggest_inline_suppression(),
368 }
369 }
370}
371
372#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
374#[serde(deny_unknown_fields, rename_all = "camelCase")]
375pub struct HealthThresholdOverride {
376 pub files: Vec<String>,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub functions: Vec<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub max_cyclomatic: Option<u16>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub max_cognitive: Option<u16>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub max_crap: Option<f64>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub max_unit_size: Option<u32>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub reason: Option<String>,
406}
407
408impl HealthThresholdOverride {
409 #[must_use]
411 pub const fn has_any_threshold(&self) -> bool {
412 self.max_cyclomatic.is_some()
413 || self.max_cognitive.is_some()
414 || self.max_crap.is_some()
415 || self.max_unit_size.is_some()
416 }
417}
418
419impl HealthConfig {
420 #[must_use]
422 pub fn threshold_override_errors(&self) -> Vec<String> {
423 let mut errors = Vec::new();
424 for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
425 if override_entry.files.is_empty() {
426 errors.push(format!(
427 "health.thresholdOverrides[{index}].files must contain at least one pattern"
428 ));
429 }
430 if !override_entry.has_any_threshold() {
431 errors.push(format!(
432 "health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
433 ));
434 }
435 }
436 errors
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn change_coupling_ranges_are_validated() {
446 assert!(ChangeCouplingConfig::default().errors().is_empty());
447 let at_bounds = ChangeCouplingConfig {
448 max_files_per_commit: 1000,
449 min_degree: 1.0,
450 ..ChangeCouplingConfig::default()
451 };
452 assert!(at_bounds.errors().is_empty());
453
454 for (max_files_per_commit, min_degree) in [(1, 0.3), (1001, 0.3), (30, -0.1), (30, 1.5)] {
455 let config = ChangeCouplingConfig {
456 max_files_per_commit,
457 min_degree,
458 ..ChangeCouplingConfig::default()
459 };
460 assert_eq!(
461 config.errors().len(),
462 1,
463 "maxFilesPerCommit {max_files_per_commit}, minDegree {min_degree}"
464 );
465 }
466 let nan = ChangeCouplingConfig {
467 min_degree: f64::NAN,
468 ..ChangeCouplingConfig::default()
469 };
470 assert_eq!(nan.errors().len(), 1);
471 }
472
473 #[test]
474 fn health_config_defaults() {
475 let config = HealthConfig::default();
476 assert_eq!(config.max_cyclomatic, 20);
477 assert_eq!(config.max_cognitive, 15);
478 assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
479 assert_eq!(config.crap_refactor_band, 5);
480 assert_eq!(config.max_unit_size, 60);
481 assert!(config.coverage.is_none());
482 assert!(config.coverage_root.is_none());
483 assert!(config.ignore.is_empty());
484 assert!(config.threshold_overrides.is_empty());
485 assert_eq!(config.change_coupling.max_files_per_commit, 30);
486 assert_eq!(config.change_coupling.min_revisions, 5);
487 assert_eq!(config.change_coupling.min_shared_revisions, 5);
488 assert!((config.change_coupling.min_degree - 0.3).abs() < f64::EPSILON);
489 let ownership = OwnershipConfig::default();
490 assert_eq!(config.ownership.bot_patterns, ownership.bot_patterns);
491 assert_eq!(config.ownership.email_mode, ownership.email_mode);
492 }
493
494 #[test]
495 fn health_config_json_all_fields() {
496 let json = r#"{
497 "maxCyclomatic": 30,
498 "maxCognitive": 25,
499 "maxCrap": 50.0,
500 "crapRefactorBand": 3,
501 "coverage": "coverage/coverage-final.json",
502 "coverageRoot": "/ci/workspace",
503 "ignore": ["**/generated/**", "vendor/**"],
504 "thresholdOverrides": [{
505 "files": ["components/auth/src/index.ts"],
506 "functions": ["createAuthModule"],
507 "maxCognitive": 25,
508 "reason": "linear module assembly; agreed 2026-06"
509 }]
510 }"#;
511 let config: HealthConfig = serde_json::from_str(json).unwrap();
512 assert_eq!(config.max_cyclomatic, 30);
513 assert_eq!(config.max_cognitive, 25);
514 assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
515 assert_eq!(config.crap_refactor_band, 3);
516 assert_eq!(
517 config.coverage,
518 Some(PathBuf::from("coverage/coverage-final.json"))
519 );
520 assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
521 assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
522 assert_eq!(config.threshold_overrides.len(), 1);
523 assert_eq!(
524 config.threshold_overrides[0].files,
525 vec!["components/auth/src/index.ts"]
526 );
527 assert_eq!(
528 config.threshold_overrides[0].functions,
529 vec!["createAuthModule"]
530 );
531 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
532 }
533
534 #[test]
535 fn health_config_json_partial_uses_defaults() {
536 let json = r#"{"maxCyclomatic": 10}"#;
537 let config: HealthConfig = serde_json::from_str(json).unwrap();
538 assert_eq!(config.max_cyclomatic, 10);
539 assert_eq!(config.max_cognitive, 15); assert!((config.max_crap - 30.0).abs() < f64::EPSILON); assert_eq!(config.crap_refactor_band, 5); assert!(config.ignore.is_empty()); assert!(config.threshold_overrides.is_empty()); }
545
546 #[test]
547 fn health_config_json_only_max_crap() {
548 let json = r#"{"maxCrap": 15.5}"#;
549 let config: HealthConfig = serde_json::from_str(json).unwrap();
550 assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
551 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.cyclomatic_policy, CyclomaticPolicy::Standard);
553 assert_eq!(config.max_cognitive, 15); assert_eq!(config.crap_refactor_band, 5); }
556
557 #[test]
558 fn health_config_json_empty_object_uses_all_defaults() {
559 let config: HealthConfig = serde_json::from_str("{}").unwrap();
560 assert_eq!(config.max_cyclomatic, 20);
561 assert_eq!(config.max_cognitive, 15);
562 assert_eq!(config.crap_refactor_band, 5);
563 assert!(config.ignore.is_empty());
564 assert!(config.threshold_overrides.is_empty());
565 }
566
567 #[test]
568 fn health_config_json_only_ignore() {
569 let json = r#"{"ignore": ["test/**"]}"#;
570 let config: HealthConfig = serde_json::from_str(json).unwrap();
571 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
574 assert!(config.threshold_overrides.is_empty());
575 }
576
577 #[test]
578 fn health_config_toml_all_fields() {
579 let toml_str = r#"
580maxCyclomatic = 25
581maxCognitive = 20
582ignore = ["generated/**", "vendor/**"]
583
584[[thresholdOverrides]]
585files = ["src/auth.ts"]
586maxCognitive = 25
587"#;
588 let config: HealthConfig = toml::from_str(toml_str).unwrap();
589 assert_eq!(config.max_cyclomatic, 25);
590 assert_eq!(config.max_cognitive, 20);
591 assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
592 assert_eq!(config.threshold_overrides.len(), 1);
593 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
594 }
595
596 #[test]
597 fn health_config_toml_defaults() {
598 let config: HealthConfig = toml::from_str("").unwrap();
599 assert_eq!(config.max_cyclomatic, 20);
600 assert_eq!(config.max_cognitive, 15);
601 assert!(config.ignore.is_empty());
602 assert!(config.threshold_overrides.is_empty());
603 }
604
605 #[test]
606 fn health_config_json_roundtrip() {
607 let config = HealthConfig {
608 max_cyclomatic: 50,
609 cyclomatic_policy: CyclomaticPolicy::LiteralMembership,
610 max_cognitive: 40,
611 max_crap: 75.0,
612 crap_refactor_band: 4,
613 max_unit_size: 120,
614 ignore: vec!["test/**".to_string()],
615 threshold_overrides: vec![HealthThresholdOverride {
616 files: vec!["src/auth.ts".to_string()],
617 functions: Vec::new(),
618 max_cyclomatic: Some(30),
619 max_cognitive: None,
620 max_crap: Some(45.0),
621 max_unit_size: None,
622 reason: Some("framework assembly".to_string()),
623 }],
624 coverage: None,
625 coverage_root: None,
626 ownership: OwnershipConfig::default(),
627 change_coupling: ChangeCouplingConfig {
628 min_degree: 0.4,
629 ..ChangeCouplingConfig::default()
630 },
631 suggest_inline_suppression: false,
632 };
633 let json = serde_json::to_string(&config).unwrap();
634 let restored: HealthConfig = serde_json::from_str(&json).unwrap();
635 assert_eq!(restored.max_cyclomatic, 50);
636 assert_eq!(
637 restored.cyclomatic_policy,
638 CyclomaticPolicy::LiteralMembership
639 );
640 assert!((restored.change_coupling.min_degree - 0.4).abs() < f64::EPSILON);
641 assert_eq!(restored.max_cognitive, 40);
642 assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
643 assert_eq!(restored.crap_refactor_band, 4);
644 assert_eq!(restored.max_unit_size, 120);
645 assert_eq!(restored.ignore, vec!["test/**"]);
646 assert_eq!(restored.threshold_overrides.len(), 1);
647 assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
648 assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
649 assert!(!restored.suggest_inline_suppression);
650 }
651
652 #[test]
653 fn health_config_threshold_override_omitted_functions_matches_all() {
654 let json = r#"{
655 "thresholdOverrides": [{
656 "files": ["src/auth.ts"],
657 "maxCognitive": 25
658 }]
659 }"#;
660 let config: HealthConfig = serde_json::from_str(json).unwrap();
661 let override_entry = &config.threshold_overrides[0];
662 assert!(override_entry.functions.is_empty());
663 assert_eq!(override_entry.max_cognitive, Some(25));
664 assert!(config.threshold_override_errors().is_empty());
665 }
666
667 #[test]
668 fn health_config_threshold_override_validation_requires_files() {
669 let json = r#"{
670 "thresholdOverrides": [{
671 "files": [],
672 "maxCognitive": 25
673 }]
674 }"#;
675 let config: HealthConfig = serde_json::from_str(json).unwrap();
676 assert_eq!(
677 config.threshold_override_errors(),
678 vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
679 );
680 }
681
682 #[test]
683 fn health_config_threshold_override_validation_requires_threshold() {
684 let json = r#"{
685 "thresholdOverrides": [{
686 "files": ["src/auth.ts"],
687 "reason": "temporary"
688 }]
689 }"#;
690 let config: HealthConfig = serde_json::from_str(json).unwrap();
691 assert_eq!(
692 config.threshold_override_errors(),
693 vec![
694 "health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
695 ]
696 );
697 }
698
699 #[test]
700 fn health_config_threshold_override_max_unit_size_only_is_valid() {
701 let json = r#"{
702 "thresholdOverrides": [{
703 "files": ["**/*.test.*"],
704 "maxUnitSize": 500
705 }]
706 }"#;
707 let config: HealthConfig = serde_json::from_str(json).unwrap();
708 let override_entry = &config.threshold_overrides[0];
709 assert_eq!(override_entry.max_unit_size, Some(500));
710 assert!(override_entry.max_cyclomatic.is_none());
711 assert!(override_entry.has_any_threshold());
712 assert!(config.threshold_override_errors().is_empty());
713 }
714
715 #[test]
716 fn health_config_json_only_max_unit_size() {
717 let json = r#"{"maxUnitSize": 100}"#;
718 let config: HealthConfig = serde_json::from_str(json).unwrap();
719 assert_eq!(config.max_unit_size, 100);
720 assert_eq!(config.max_cyclomatic, 20); assert!(config.threshold_overrides.is_empty());
722 }
723
724 #[test]
725 fn health_config_threshold_override_rejects_unknown_keys() {
726 let err = serde_json::from_str::<HealthConfig>(
727 r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
728 )
729 .unwrap_err();
730 assert!(err.to_string().contains("maxCogntive"));
731 }
732
733 #[test]
734 fn health_config_suggest_inline_suppression_default_true() {
735 let config = HealthConfig::default();
736 assert!(config.suggest_inline_suppression);
737 }
738
739 #[test]
740 fn health_config_suggest_inline_suppression_explicit_false() {
741 let json = r#"{"suggestInlineSuppression": false}"#;
742 let config: HealthConfig = serde_json::from_str(json).unwrap();
743 assert!(!config.suggest_inline_suppression);
744 }
745
746 #[test]
747 fn health_config_suggest_inline_suppression_omitted_uses_default() {
748 let config: HealthConfig = serde_json::from_str("{}").unwrap();
749 assert!(config.suggest_inline_suppression);
750 }
751
752 #[test]
753 fn health_config_zero_thresholds() {
754 let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
755 let config: HealthConfig = serde_json::from_str(json).unwrap();
756 assert_eq!(config.max_cyclomatic, 0);
757 assert_eq!(config.max_cognitive, 0);
758 }
759
760 #[test]
761 fn health_config_large_thresholds() {
762 let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
763 let config: HealthConfig = serde_json::from_str(json).unwrap();
764 assert_eq!(config.max_cyclomatic, u16::MAX);
765 assert_eq!(config.max_cognitive, u16::MAX);
766 }
767
768 #[test]
769 fn ownership_config_default_has_bot_patterns() {
770 let cfg = OwnershipConfig::default();
771 assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
772 assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
773 assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
774 assert!(
775 !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
776 "*noreply* must not be a default bot pattern (filters real human \
777 contributors using GitHub's privacy default email)"
778 );
779 assert_eq!(cfg.email_mode, EmailMode::Handle);
780 }
781
782 #[test]
783 fn ownership_config_json_overrides_defaults() {
784 let json = r#"{
785 "ownership": {
786 "botPatterns": ["custom-bot*"],
787 "emailMode": "raw"
788 }
789 }"#;
790 let config: HealthConfig = serde_json::from_str(json).unwrap();
791 assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
792 assert_eq!(config.ownership.email_mode, EmailMode::Raw);
793 }
794
795 #[test]
796 fn ownership_config_email_mode_kebab_case() {
797 for (mode, repr) in [
798 (EmailMode::Raw, "\"raw\""),
799 (EmailMode::Handle, "\"handle\""),
800 (EmailMode::Anonymized, "\"anonymized\""),
801 (EmailMode::Hash, "\"hash\""),
802 ] {
803 let s = serde_json::to_string(&mode).unwrap();
804 assert_eq!(s, repr);
805 let back: EmailMode = serde_json::from_str(repr).unwrap();
806 assert_eq!(back, mode);
807 }
808 }
809
810 #[test]
811 fn ownership_config_email_mode_accepts_legacy_hash_alias() {
812 let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
813 assert_eq!(back, EmailMode::Hash);
814 }
815}