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, Deserialize, Serialize, JsonSchema)]
205#[serde(deny_unknown_fields, rename_all = "camelCase")]
206pub struct HealthConfig {
207 #[serde(default = "default_max_cyclomatic")]
212 pub max_cyclomatic: u16,
213
214 #[serde(default = "default_max_cognitive")]
218 pub max_cognitive: u16,
219
220 #[serde(default = "default_max_crap")]
236 pub max_crap: f64,
237
238 #[serde(default = "default_crap_refactor_band")]
243 pub crap_refactor_band: u16,
244
245 #[serde(default = "default_max_unit_size")]
256 pub max_unit_size: u32,
257
258 #[serde(default)]
265 pub coverage: Option<PathBuf>,
266
267 #[serde(default)]
274 pub coverage_root: Option<PathBuf>,
275
276 #[serde(default)]
279 pub ignore: Vec<String>,
280
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 pub threshold_overrides: Vec<HealthThresholdOverride>,
286
287 #[serde(default)]
290 pub ownership: OwnershipConfig,
291
292 #[serde(default)]
297 pub change_coupling: ChangeCouplingConfig,
298
299 #[serde(default = "default_suggest_inline_suppression")]
308 pub suggest_inline_suppression: bool,
309}
310
311impl Default for HealthConfig {
312 fn default() -> Self {
313 Self {
314 max_cyclomatic: default_max_cyclomatic(),
315 max_cognitive: default_max_cognitive(),
316 max_crap: default_max_crap(),
317 crap_refactor_band: default_crap_refactor_band(),
318 max_unit_size: default_max_unit_size(),
319 coverage: None,
320 coverage_root: None,
321 ignore: vec![],
322 threshold_overrides: vec![],
323 ownership: OwnershipConfig::default(),
324 change_coupling: ChangeCouplingConfig::default(),
325 suggest_inline_suppression: default_suggest_inline_suppression(),
326 }
327 }
328}
329
330#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
332#[serde(deny_unknown_fields, rename_all = "camelCase")]
333pub struct HealthThresholdOverride {
334 pub files: Vec<String>,
337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub functions: Vec<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub max_cyclomatic: Option<u16>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub max_cognitive: Option<u16>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub max_crap: Option<f64>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub max_unit_size: Option<u32>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub reason: Option<String>,
364}
365
366impl HealthThresholdOverride {
367 #[must_use]
369 pub const fn has_any_threshold(&self) -> bool {
370 self.max_cyclomatic.is_some()
371 || self.max_cognitive.is_some()
372 || self.max_crap.is_some()
373 || self.max_unit_size.is_some()
374 }
375}
376
377impl HealthConfig {
378 #[must_use]
380 pub fn threshold_override_errors(&self) -> Vec<String> {
381 let mut errors = Vec::new();
382 for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
383 if override_entry.files.is_empty() {
384 errors.push(format!(
385 "health.thresholdOverrides[{index}].files must contain at least one pattern"
386 ));
387 }
388 if !override_entry.has_any_threshold() {
389 errors.push(format!(
390 "health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
391 ));
392 }
393 }
394 errors
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn change_coupling_ranges_are_validated() {
404 assert!(ChangeCouplingConfig::default().errors().is_empty());
405 let at_bounds = ChangeCouplingConfig {
406 max_files_per_commit: 1000,
407 min_degree: 1.0,
408 ..ChangeCouplingConfig::default()
409 };
410 assert!(at_bounds.errors().is_empty());
411
412 for (max_files_per_commit, min_degree) in [(1, 0.3), (1001, 0.3), (30, -0.1), (30, 1.5)] {
413 let config = ChangeCouplingConfig {
414 max_files_per_commit,
415 min_degree,
416 ..ChangeCouplingConfig::default()
417 };
418 assert_eq!(
419 config.errors().len(),
420 1,
421 "maxFilesPerCommit {max_files_per_commit}, minDegree {min_degree}"
422 );
423 }
424 let nan = ChangeCouplingConfig {
425 min_degree: f64::NAN,
426 ..ChangeCouplingConfig::default()
427 };
428 assert_eq!(nan.errors().len(), 1);
429 }
430
431 #[test]
432 fn health_config_defaults() {
433 let config = HealthConfig::default();
434 assert_eq!(config.max_cyclomatic, 20);
435 assert_eq!(config.max_cognitive, 15);
436 assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
437 assert_eq!(config.crap_refactor_band, 5);
438 assert_eq!(config.max_unit_size, 60);
439 assert!(config.coverage.is_none());
440 assert!(config.coverage_root.is_none());
441 assert!(config.ignore.is_empty());
442 assert!(config.threshold_overrides.is_empty());
443 assert_eq!(config.change_coupling.max_files_per_commit, 30);
444 assert_eq!(config.change_coupling.min_revisions, 5);
445 assert_eq!(config.change_coupling.min_shared_revisions, 5);
446 assert!((config.change_coupling.min_degree - 0.3).abs() < f64::EPSILON);
447 let ownership = OwnershipConfig::default();
448 assert_eq!(config.ownership.bot_patterns, ownership.bot_patterns);
449 assert_eq!(config.ownership.email_mode, ownership.email_mode);
450 }
451
452 #[test]
453 fn health_config_json_all_fields() {
454 let json = r#"{
455 "maxCyclomatic": 30,
456 "maxCognitive": 25,
457 "maxCrap": 50.0,
458 "crapRefactorBand": 3,
459 "coverage": "coverage/coverage-final.json",
460 "coverageRoot": "/ci/workspace",
461 "ignore": ["**/generated/**", "vendor/**"],
462 "thresholdOverrides": [{
463 "files": ["components/auth/src/index.ts"],
464 "functions": ["createAuthModule"],
465 "maxCognitive": 25,
466 "reason": "linear module assembly; agreed 2026-06"
467 }]
468 }"#;
469 let config: HealthConfig = serde_json::from_str(json).unwrap();
470 assert_eq!(config.max_cyclomatic, 30);
471 assert_eq!(config.max_cognitive, 25);
472 assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
473 assert_eq!(config.crap_refactor_band, 3);
474 assert_eq!(
475 config.coverage,
476 Some(PathBuf::from("coverage/coverage-final.json"))
477 );
478 assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
479 assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
480 assert_eq!(config.threshold_overrides.len(), 1);
481 assert_eq!(
482 config.threshold_overrides[0].files,
483 vec!["components/auth/src/index.ts"]
484 );
485 assert_eq!(
486 config.threshold_overrides[0].functions,
487 vec!["createAuthModule"]
488 );
489 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
490 }
491
492 #[test]
493 fn health_config_json_partial_uses_defaults() {
494 let json = r#"{"maxCyclomatic": 10}"#;
495 let config: HealthConfig = serde_json::from_str(json).unwrap();
496 assert_eq!(config.max_cyclomatic, 10);
497 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()); }
503
504 #[test]
505 fn health_config_json_only_max_crap() {
506 let json = r#"{"maxCrap": 15.5}"#;
507 let config: HealthConfig = serde_json::from_str(json).unwrap();
508 assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
509 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.crap_refactor_band, 5); }
513
514 #[test]
515 fn health_config_json_empty_object_uses_all_defaults() {
516 let config: HealthConfig = serde_json::from_str("{}").unwrap();
517 assert_eq!(config.max_cyclomatic, 20);
518 assert_eq!(config.max_cognitive, 15);
519 assert_eq!(config.crap_refactor_band, 5);
520 assert!(config.ignore.is_empty());
521 assert!(config.threshold_overrides.is_empty());
522 }
523
524 #[test]
525 fn health_config_json_only_ignore() {
526 let json = r#"{"ignore": ["test/**"]}"#;
527 let config: HealthConfig = serde_json::from_str(json).unwrap();
528 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
531 assert!(config.threshold_overrides.is_empty());
532 }
533
534 #[test]
535 fn health_config_toml_all_fields() {
536 let toml_str = r#"
537maxCyclomatic = 25
538maxCognitive = 20
539ignore = ["generated/**", "vendor/**"]
540
541[[thresholdOverrides]]
542files = ["src/auth.ts"]
543maxCognitive = 25
544"#;
545 let config: HealthConfig = toml::from_str(toml_str).unwrap();
546 assert_eq!(config.max_cyclomatic, 25);
547 assert_eq!(config.max_cognitive, 20);
548 assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
549 assert_eq!(config.threshold_overrides.len(), 1);
550 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
551 }
552
553 #[test]
554 fn health_config_toml_defaults() {
555 let config: HealthConfig = toml::from_str("").unwrap();
556 assert_eq!(config.max_cyclomatic, 20);
557 assert_eq!(config.max_cognitive, 15);
558 assert!(config.ignore.is_empty());
559 assert!(config.threshold_overrides.is_empty());
560 }
561
562 #[test]
563 fn health_config_json_roundtrip() {
564 let config = HealthConfig {
565 max_cyclomatic: 50,
566 max_cognitive: 40,
567 max_crap: 75.0,
568 crap_refactor_band: 4,
569 max_unit_size: 120,
570 ignore: vec!["test/**".to_string()],
571 threshold_overrides: vec![HealthThresholdOverride {
572 files: vec!["src/auth.ts".to_string()],
573 functions: Vec::new(),
574 max_cyclomatic: Some(30),
575 max_cognitive: None,
576 max_crap: Some(45.0),
577 max_unit_size: None,
578 reason: Some("framework assembly".to_string()),
579 }],
580 coverage: None,
581 coverage_root: None,
582 ownership: OwnershipConfig::default(),
583 change_coupling: ChangeCouplingConfig {
584 min_degree: 0.4,
585 ..ChangeCouplingConfig::default()
586 },
587 suggest_inline_suppression: false,
588 };
589 let json = serde_json::to_string(&config).unwrap();
590 let restored: HealthConfig = serde_json::from_str(&json).unwrap();
591 assert_eq!(restored.max_cyclomatic, 50);
592 assert!((restored.change_coupling.min_degree - 0.4).abs() < f64::EPSILON);
593 assert_eq!(restored.max_cognitive, 40);
594 assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
595 assert_eq!(restored.crap_refactor_band, 4);
596 assert_eq!(restored.max_unit_size, 120);
597 assert_eq!(restored.ignore, vec!["test/**"]);
598 assert_eq!(restored.threshold_overrides.len(), 1);
599 assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
600 assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
601 assert!(!restored.suggest_inline_suppression);
602 }
603
604 #[test]
605 fn health_config_threshold_override_omitted_functions_matches_all() {
606 let json = r#"{
607 "thresholdOverrides": [{
608 "files": ["src/auth.ts"],
609 "maxCognitive": 25
610 }]
611 }"#;
612 let config: HealthConfig = serde_json::from_str(json).unwrap();
613 let override_entry = &config.threshold_overrides[0];
614 assert!(override_entry.functions.is_empty());
615 assert_eq!(override_entry.max_cognitive, Some(25));
616 assert!(config.threshold_override_errors().is_empty());
617 }
618
619 #[test]
620 fn health_config_threshold_override_validation_requires_files() {
621 let json = r#"{
622 "thresholdOverrides": [{
623 "files": [],
624 "maxCognitive": 25
625 }]
626 }"#;
627 let config: HealthConfig = serde_json::from_str(json).unwrap();
628 assert_eq!(
629 config.threshold_override_errors(),
630 vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
631 );
632 }
633
634 #[test]
635 fn health_config_threshold_override_validation_requires_threshold() {
636 let json = r#"{
637 "thresholdOverrides": [{
638 "files": ["src/auth.ts"],
639 "reason": "temporary"
640 }]
641 }"#;
642 let config: HealthConfig = serde_json::from_str(json).unwrap();
643 assert_eq!(
644 config.threshold_override_errors(),
645 vec![
646 "health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
647 ]
648 );
649 }
650
651 #[test]
652 fn health_config_threshold_override_max_unit_size_only_is_valid() {
653 let json = r#"{
654 "thresholdOverrides": [{
655 "files": ["**/*.test.*"],
656 "maxUnitSize": 500
657 }]
658 }"#;
659 let config: HealthConfig = serde_json::from_str(json).unwrap();
660 let override_entry = &config.threshold_overrides[0];
661 assert_eq!(override_entry.max_unit_size, Some(500));
662 assert!(override_entry.max_cyclomatic.is_none());
663 assert!(override_entry.has_any_threshold());
664 assert!(config.threshold_override_errors().is_empty());
665 }
666
667 #[test]
668 fn health_config_json_only_max_unit_size() {
669 let json = r#"{"maxUnitSize": 100}"#;
670 let config: HealthConfig = serde_json::from_str(json).unwrap();
671 assert_eq!(config.max_unit_size, 100);
672 assert_eq!(config.max_cyclomatic, 20); assert!(config.threshold_overrides.is_empty());
674 }
675
676 #[test]
677 fn health_config_threshold_override_rejects_unknown_keys() {
678 let err = serde_json::from_str::<HealthConfig>(
679 r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
680 )
681 .unwrap_err();
682 assert!(err.to_string().contains("maxCogntive"));
683 }
684
685 #[test]
686 fn health_config_suggest_inline_suppression_default_true() {
687 let config = HealthConfig::default();
688 assert!(config.suggest_inline_suppression);
689 }
690
691 #[test]
692 fn health_config_suggest_inline_suppression_explicit_false() {
693 let json = r#"{"suggestInlineSuppression": false}"#;
694 let config: HealthConfig = serde_json::from_str(json).unwrap();
695 assert!(!config.suggest_inline_suppression);
696 }
697
698 #[test]
699 fn health_config_suggest_inline_suppression_omitted_uses_default() {
700 let config: HealthConfig = serde_json::from_str("{}").unwrap();
701 assert!(config.suggest_inline_suppression);
702 }
703
704 #[test]
705 fn health_config_zero_thresholds() {
706 let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
707 let config: HealthConfig = serde_json::from_str(json).unwrap();
708 assert_eq!(config.max_cyclomatic, 0);
709 assert_eq!(config.max_cognitive, 0);
710 }
711
712 #[test]
713 fn health_config_large_thresholds() {
714 let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
715 let config: HealthConfig = serde_json::from_str(json).unwrap();
716 assert_eq!(config.max_cyclomatic, u16::MAX);
717 assert_eq!(config.max_cognitive, u16::MAX);
718 }
719
720 #[test]
721 fn ownership_config_default_has_bot_patterns() {
722 let cfg = OwnershipConfig::default();
723 assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
724 assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
725 assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
726 assert!(
727 !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
728 "*noreply* must not be a default bot pattern (filters real human \
729 contributors using GitHub's privacy default email)"
730 );
731 assert_eq!(cfg.email_mode, EmailMode::Handle);
732 }
733
734 #[test]
735 fn ownership_config_json_overrides_defaults() {
736 let json = r#"{
737 "ownership": {
738 "botPatterns": ["custom-bot*"],
739 "emailMode": "raw"
740 }
741 }"#;
742 let config: HealthConfig = serde_json::from_str(json).unwrap();
743 assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
744 assert_eq!(config.ownership.email_mode, EmailMode::Raw);
745 }
746
747 #[test]
748 fn ownership_config_email_mode_kebab_case() {
749 for (mode, repr) in [
750 (EmailMode::Raw, "\"raw\""),
751 (EmailMode::Handle, "\"handle\""),
752 (EmailMode::Anonymized, "\"anonymized\""),
753 (EmailMode::Hash, "\"hash\""),
754 ] {
755 let s = serde_json::to_string(&mode).unwrap();
756 assert_eq!(s, repr);
757 let back: EmailMode = serde_json::from_str(repr).unwrap();
758 assert_eq!(back, mode);
759 }
760 }
761
762 #[test]
763 fn ownership_config_email_mode_accepts_legacy_hash_alias() {
764 let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
765 assert_eq!(back, EmailMode::Hash);
766 }
767}