use std::path::PathBuf;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const fn default_max_cyclomatic() -> u16 {
20
}
const fn default_max_cognitive() -> u16 {
15
}
const fn default_max_crap() -> f64 {
30.0
}
const fn default_crap_refactor_band() -> u16 {
5
}
const fn default_suggest_inline_suppression() -> bool {
true
}
fn default_bot_patterns() -> Vec<String> {
vec![
r"*\[bot\]*".to_string(),
"dependabot*".to_string(),
"renovate*".to_string(),
"github-actions*".to_string(),
"svc-*".to_string(),
"*-service-account*".to_string(),
]
}
const fn default_email_mode() -> EmailMode {
EmailMode::Handle
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum EmailMode {
Raw,
Handle,
Anonymized,
Hash,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct OwnershipConfig {
#[serde(default = "default_bot_patterns")]
pub bot_patterns: Vec<String>,
#[serde(default = "default_email_mode")]
pub email_mode: EmailMode,
}
impl Default for OwnershipConfig {
fn default() -> Self {
Self {
bot_patterns: default_bot_patterns(),
email_mode: default_email_mode(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct HealthConfig {
#[serde(default = "default_max_cyclomatic")]
pub max_cyclomatic: u16,
#[serde(default = "default_max_cognitive")]
pub max_cognitive: u16,
#[serde(default = "default_max_crap")]
pub max_crap: f64,
#[serde(default = "default_crap_refactor_band")]
pub crap_refactor_band: u16,
#[serde(default)]
pub coverage: Option<PathBuf>,
#[serde(default)]
pub coverage_root: Option<PathBuf>,
#[serde(default)]
pub ignore: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub threshold_overrides: Vec<HealthThresholdOverride>,
#[serde(default)]
pub ownership: OwnershipConfig,
#[serde(default = "default_suggest_inline_suppression")]
pub suggest_inline_suppression: bool,
}
impl Default for HealthConfig {
fn default() -> Self {
Self {
max_cyclomatic: default_max_cyclomatic(),
max_cognitive: default_max_cognitive(),
max_crap: default_max_crap(),
crap_refactor_band: default_crap_refactor_band(),
coverage: None,
coverage_root: None,
ignore: vec![],
threshold_overrides: vec![],
ownership: OwnershipConfig::default(),
suggest_inline_suppression: default_suggest_inline_suppression(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct HealthThresholdOverride {
pub files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub functions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cyclomatic: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cognitive: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_crap: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl HealthThresholdOverride {
#[must_use]
pub const fn has_any_threshold(&self) -> bool {
self.max_cyclomatic.is_some() || self.max_cognitive.is_some() || self.max_crap.is_some()
}
}
impl HealthConfig {
#[must_use]
pub fn threshold_override_errors(&self) -> Vec<String> {
let mut errors = Vec::new();
for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
if override_entry.files.is_empty() {
errors.push(format!(
"health.thresholdOverrides[{index}].files must contain at least one pattern"
));
}
if !override_entry.has_any_threshold() {
errors.push(format!(
"health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, or maxCrap"
));
}
}
errors
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_config_defaults() {
let config = HealthConfig::default();
assert_eq!(config.max_cyclomatic, 20);
assert_eq!(config.max_cognitive, 15);
assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
assert_eq!(config.crap_refactor_band, 5);
assert!(config.coverage.is_none());
assert!(config.coverage_root.is_none());
assert!(config.ignore.is_empty());
assert!(config.threshold_overrides.is_empty());
}
#[test]
fn health_config_json_all_fields() {
let json = r#"{
"maxCyclomatic": 30,
"maxCognitive": 25,
"maxCrap": 50.0,
"crapRefactorBand": 3,
"coverage": "coverage/coverage-final.json",
"coverageRoot": "/ci/workspace",
"ignore": ["**/generated/**", "vendor/**"],
"thresholdOverrides": [{
"files": ["components/auth/src/index.ts"],
"functions": ["createAuthModule"],
"maxCognitive": 25,
"reason": "linear module assembly; agreed 2026-06"
}]
}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.max_cyclomatic, 30);
assert_eq!(config.max_cognitive, 25);
assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
assert_eq!(config.crap_refactor_band, 3);
assert_eq!(
config.coverage,
Some(PathBuf::from("coverage/coverage-final.json"))
);
assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
assert_eq!(config.threshold_overrides.len(), 1);
assert_eq!(
config.threshold_overrides[0].files,
vec!["components/auth/src/index.ts"]
);
assert_eq!(
config.threshold_overrides[0].functions,
vec!["createAuthModule"]
);
assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
}
#[test]
fn health_config_json_partial_uses_defaults() {
let json = r#"{"maxCyclomatic": 10}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.max_cyclomatic, 10);
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()); }
#[test]
fn health_config_json_only_max_crap() {
let json = r#"{"maxCrap": 15.5}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.crap_refactor_band, 5); }
#[test]
fn health_config_json_empty_object_uses_all_defaults() {
let config: HealthConfig = serde_json::from_str("{}").unwrap();
assert_eq!(config.max_cyclomatic, 20);
assert_eq!(config.max_cognitive, 15);
assert_eq!(config.crap_refactor_band, 5);
assert!(config.ignore.is_empty());
assert!(config.threshold_overrides.is_empty());
}
#[test]
fn health_config_json_only_ignore() {
let json = r#"{"ignore": ["test/**"]}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
assert!(config.threshold_overrides.is_empty());
}
#[test]
fn health_config_toml_all_fields() {
let toml_str = r#"
maxCyclomatic = 25
maxCognitive = 20
ignore = ["generated/**", "vendor/**"]
[[thresholdOverrides]]
files = ["src/auth.ts"]
maxCognitive = 25
"#;
let config: HealthConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_cyclomatic, 25);
assert_eq!(config.max_cognitive, 20);
assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
assert_eq!(config.threshold_overrides.len(), 1);
assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
}
#[test]
fn health_config_toml_defaults() {
let config: HealthConfig = toml::from_str("").unwrap();
assert_eq!(config.max_cyclomatic, 20);
assert_eq!(config.max_cognitive, 15);
assert!(config.ignore.is_empty());
assert!(config.threshold_overrides.is_empty());
}
#[test]
fn health_config_json_roundtrip() {
let config = HealthConfig {
max_cyclomatic: 50,
max_cognitive: 40,
max_crap: 75.0,
crap_refactor_band: 4,
ignore: vec!["test/**".to_string()],
threshold_overrides: vec![HealthThresholdOverride {
files: vec!["src/auth.ts".to_string()],
functions: Vec::new(),
max_cyclomatic: Some(30),
max_cognitive: None,
max_crap: Some(45.0),
reason: Some("framework assembly".to_string()),
}],
coverage: None,
coverage_root: None,
ownership: OwnershipConfig::default(),
suggest_inline_suppression: false,
};
let json = serde_json::to_string(&config).unwrap();
let restored: HealthConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.max_cyclomatic, 50);
assert_eq!(restored.max_cognitive, 40);
assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
assert_eq!(restored.crap_refactor_band, 4);
assert_eq!(restored.ignore, vec!["test/**"]);
assert_eq!(restored.threshold_overrides.len(), 1);
assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
assert!(!restored.suggest_inline_suppression);
}
#[test]
fn health_config_threshold_override_omitted_functions_matches_all() {
let json = r#"{
"thresholdOverrides": [{
"files": ["src/auth.ts"],
"maxCognitive": 25
}]
}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
let override_entry = &config.threshold_overrides[0];
assert!(override_entry.functions.is_empty());
assert_eq!(override_entry.max_cognitive, Some(25));
assert!(config.threshold_override_errors().is_empty());
}
#[test]
fn health_config_threshold_override_validation_requires_files() {
let json = r#"{
"thresholdOverrides": [{
"files": [],
"maxCognitive": 25
}]
}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.threshold_override_errors(),
vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
);
}
#[test]
fn health_config_threshold_override_validation_requires_threshold() {
let json = r#"{
"thresholdOverrides": [{
"files": ["src/auth.ts"],
"reason": "temporary"
}]
}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.threshold_override_errors(),
vec![
"health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, or maxCrap"
]
);
}
#[test]
fn health_config_threshold_override_rejects_unknown_keys() {
let err = serde_json::from_str::<HealthConfig>(
r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
)
.unwrap_err();
assert!(err.to_string().contains("maxCogntive"));
}
#[test]
fn health_config_suggest_inline_suppression_default_true() {
let config = HealthConfig::default();
assert!(config.suggest_inline_suppression);
}
#[test]
fn health_config_suggest_inline_suppression_explicit_false() {
let json = r#"{"suggestInlineSuppression": false}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert!(!config.suggest_inline_suppression);
}
#[test]
fn health_config_suggest_inline_suppression_omitted_uses_default() {
let config: HealthConfig = serde_json::from_str("{}").unwrap();
assert!(config.suggest_inline_suppression);
}
#[test]
fn health_config_zero_thresholds() {
let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.max_cyclomatic, 0);
assert_eq!(config.max_cognitive, 0);
}
#[test]
fn health_config_large_thresholds() {
let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.max_cyclomatic, u16::MAX);
assert_eq!(config.max_cognitive, u16::MAX);
}
#[test]
fn ownership_config_default_has_bot_patterns() {
let cfg = OwnershipConfig::default();
assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
assert!(
!cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
"*noreply* must not be a default bot pattern (filters real human \
contributors using GitHub's privacy default email)"
);
assert_eq!(cfg.email_mode, EmailMode::Handle);
}
#[test]
fn ownership_config_default_via_health() {
let cfg = HealthConfig::default();
assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
assert!(!cfg.ownership.bot_patterns.is_empty());
}
#[test]
fn ownership_config_json_overrides_defaults() {
let json = r#"{
"ownership": {
"botPatterns": ["custom-bot*"],
"emailMode": "raw"
}
}"#;
let config: HealthConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
assert_eq!(config.ownership.email_mode, EmailMode::Raw);
}
#[test]
fn ownership_config_email_mode_kebab_case() {
for (mode, repr) in [
(EmailMode::Raw, "\"raw\""),
(EmailMode::Handle, "\"handle\""),
(EmailMode::Anonymized, "\"anonymized\""),
(EmailMode::Hash, "\"hash\""),
] {
let s = serde_json::to_string(&mode).unwrap();
assert_eq!(s, repr);
let back: EmailMode = serde_json::from_str(repr).unwrap();
assert_eq!(back, mode);
}
}
#[test]
fn ownership_config_email_mode_accepts_legacy_hash_alias() {
let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
assert_eq!(back, EmailMode::Hash);
}
}