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_suggest_inline_suppression() -> bool {
29 true
30}
31
32fn default_bot_patterns() -> Vec<String> {
47 vec![
48 r"*\[bot\]*".to_string(),
49 "dependabot*".to_string(),
50 "renovate*".to_string(),
51 "github-actions*".to_string(),
52 "svc-*".to_string(),
53 "*-service-account*".to_string(),
54 ]
55}
56
57const fn default_email_mode() -> EmailMode {
58 EmailMode::Handle
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
66#[serde(rename_all = "kebab-case")]
67pub enum EmailMode {
68 Raw,
71 Handle,
74 Anonymized,
80 Hash,
82}
83
84#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
86#[serde(rename_all = "camelCase")]
87pub struct OwnershipConfig {
88 #[serde(default = "default_bot_patterns")]
92 pub bot_patterns: Vec<String>,
93
94 #[serde(default = "default_email_mode")]
98 pub email_mode: EmailMode,
99}
100
101impl Default for OwnershipConfig {
102 fn default() -> Self {
103 Self {
104 bot_patterns: default_bot_patterns(),
105 email_mode: default_email_mode(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
112#[serde(deny_unknown_fields, rename_all = "camelCase")]
113pub struct HealthConfig {
114 #[serde(default = "default_max_cyclomatic")]
117 pub max_cyclomatic: u16,
118
119 #[serde(default = "default_max_cognitive")]
122 pub max_cognitive: u16,
123
124 #[serde(default = "default_max_crap")]
131 pub max_crap: f64,
132
133 #[serde(default = "default_crap_refactor_band")]
138 pub crap_refactor_band: u16,
139
140 #[serde(default)]
145 pub coverage: Option<PathBuf>,
146
147 #[serde(default)]
152 pub coverage_root: Option<PathBuf>,
153
154 #[serde(default)]
156 pub ignore: Vec<String>,
157
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub threshold_overrides: Vec<HealthThresholdOverride>,
163
164 #[serde(default)]
167 pub ownership: OwnershipConfig,
168
169 #[serde(default = "default_suggest_inline_suppression")]
178 pub suggest_inline_suppression: bool,
179}
180
181impl Default for HealthConfig {
182 fn default() -> Self {
183 Self {
184 max_cyclomatic: default_max_cyclomatic(),
185 max_cognitive: default_max_cognitive(),
186 max_crap: default_max_crap(),
187 crap_refactor_band: default_crap_refactor_band(),
188 coverage: None,
189 coverage_root: None,
190 ignore: vec![],
191 threshold_overrides: vec![],
192 ownership: OwnershipConfig::default(),
193 suggest_inline_suppression: default_suggest_inline_suppression(),
194 }
195 }
196}
197
198#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
200#[serde(deny_unknown_fields, rename_all = "camelCase")]
201pub struct HealthThresholdOverride {
202 pub files: Vec<String>,
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub functions: Vec<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub max_cyclomatic: Option<u16>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub max_cognitive: Option<u16>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub max_crap: Option<f64>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub reason: Option<String>,
220}
221
222impl HealthThresholdOverride {
223 #[must_use]
225 pub const fn has_any_threshold(&self) -> bool {
226 self.max_cyclomatic.is_some() || self.max_cognitive.is_some() || self.max_crap.is_some()
227 }
228}
229
230impl HealthConfig {
231 #[must_use]
233 pub fn threshold_override_errors(&self) -> Vec<String> {
234 let mut errors = Vec::new();
235 for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
236 if override_entry.files.is_empty() {
237 errors.push(format!(
238 "health.thresholdOverrides[{index}].files must contain at least one pattern"
239 ));
240 }
241 if !override_entry.has_any_threshold() {
242 errors.push(format!(
243 "health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, or maxCrap"
244 ));
245 }
246 }
247 errors
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn health_config_defaults() {
257 let config = HealthConfig::default();
258 assert_eq!(config.max_cyclomatic, 20);
259 assert_eq!(config.max_cognitive, 15);
260 assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
261 assert_eq!(config.crap_refactor_band, 5);
262 assert!(config.coverage.is_none());
263 assert!(config.coverage_root.is_none());
264 assert!(config.ignore.is_empty());
265 assert!(config.threshold_overrides.is_empty());
266 }
267
268 #[test]
269 fn health_config_json_all_fields() {
270 let json = r#"{
271 "maxCyclomatic": 30,
272 "maxCognitive": 25,
273 "maxCrap": 50.0,
274 "crapRefactorBand": 3,
275 "coverage": "coverage/coverage-final.json",
276 "coverageRoot": "/ci/workspace",
277 "ignore": ["**/generated/**", "vendor/**"],
278 "thresholdOverrides": [{
279 "files": ["components/auth/src/index.ts"],
280 "functions": ["createAuthModule"],
281 "maxCognitive": 25,
282 "reason": "linear module assembly; agreed 2026-06"
283 }]
284 }"#;
285 let config: HealthConfig = serde_json::from_str(json).unwrap();
286 assert_eq!(config.max_cyclomatic, 30);
287 assert_eq!(config.max_cognitive, 25);
288 assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
289 assert_eq!(config.crap_refactor_band, 3);
290 assert_eq!(
291 config.coverage,
292 Some(PathBuf::from("coverage/coverage-final.json"))
293 );
294 assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
295 assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
296 assert_eq!(config.threshold_overrides.len(), 1);
297 assert_eq!(
298 config.threshold_overrides[0].files,
299 vec!["components/auth/src/index.ts"]
300 );
301 assert_eq!(
302 config.threshold_overrides[0].functions,
303 vec!["createAuthModule"]
304 );
305 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
306 }
307
308 #[test]
309 fn health_config_json_partial_uses_defaults() {
310 let json = r#"{"maxCyclomatic": 10}"#;
311 let config: HealthConfig = serde_json::from_str(json).unwrap();
312 assert_eq!(config.max_cyclomatic, 10);
313 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()); }
319
320 #[test]
321 fn health_config_json_only_max_crap() {
322 let json = r#"{"maxCrap": 15.5}"#;
323 let config: HealthConfig = serde_json::from_str(json).unwrap();
324 assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
325 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.crap_refactor_band, 5); }
329
330 #[test]
331 fn health_config_json_empty_object_uses_all_defaults() {
332 let config: HealthConfig = serde_json::from_str("{}").unwrap();
333 assert_eq!(config.max_cyclomatic, 20);
334 assert_eq!(config.max_cognitive, 15);
335 assert_eq!(config.crap_refactor_band, 5);
336 assert!(config.ignore.is_empty());
337 assert!(config.threshold_overrides.is_empty());
338 }
339
340 #[test]
341 fn health_config_json_only_ignore() {
342 let json = r#"{"ignore": ["test/**"]}"#;
343 let config: HealthConfig = serde_json::from_str(json).unwrap();
344 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
347 assert!(config.threshold_overrides.is_empty());
348 }
349
350 #[test]
351 fn health_config_toml_all_fields() {
352 let toml_str = r#"
353maxCyclomatic = 25
354maxCognitive = 20
355ignore = ["generated/**", "vendor/**"]
356
357[[thresholdOverrides]]
358files = ["src/auth.ts"]
359maxCognitive = 25
360"#;
361 let config: HealthConfig = toml::from_str(toml_str).unwrap();
362 assert_eq!(config.max_cyclomatic, 25);
363 assert_eq!(config.max_cognitive, 20);
364 assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
365 assert_eq!(config.threshold_overrides.len(), 1);
366 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
367 }
368
369 #[test]
370 fn health_config_toml_defaults() {
371 let config: HealthConfig = toml::from_str("").unwrap();
372 assert_eq!(config.max_cyclomatic, 20);
373 assert_eq!(config.max_cognitive, 15);
374 assert!(config.ignore.is_empty());
375 assert!(config.threshold_overrides.is_empty());
376 }
377
378 #[test]
379 fn health_config_json_roundtrip() {
380 let config = HealthConfig {
381 max_cyclomatic: 50,
382 max_cognitive: 40,
383 max_crap: 75.0,
384 crap_refactor_band: 4,
385 ignore: vec!["test/**".to_string()],
386 threshold_overrides: vec![HealthThresholdOverride {
387 files: vec!["src/auth.ts".to_string()],
388 functions: Vec::new(),
389 max_cyclomatic: Some(30),
390 max_cognitive: None,
391 max_crap: Some(45.0),
392 reason: Some("framework assembly".to_string()),
393 }],
394 coverage: None,
395 coverage_root: None,
396 ownership: OwnershipConfig::default(),
397 suggest_inline_suppression: false,
398 };
399 let json = serde_json::to_string(&config).unwrap();
400 let restored: HealthConfig = serde_json::from_str(&json).unwrap();
401 assert_eq!(restored.max_cyclomatic, 50);
402 assert_eq!(restored.max_cognitive, 40);
403 assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
404 assert_eq!(restored.crap_refactor_band, 4);
405 assert_eq!(restored.ignore, vec!["test/**"]);
406 assert_eq!(restored.threshold_overrides.len(), 1);
407 assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
408 assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
409 assert!(!restored.suggest_inline_suppression);
410 }
411
412 #[test]
413 fn health_config_threshold_override_omitted_functions_matches_all() {
414 let json = r#"{
415 "thresholdOverrides": [{
416 "files": ["src/auth.ts"],
417 "maxCognitive": 25
418 }]
419 }"#;
420 let config: HealthConfig = serde_json::from_str(json).unwrap();
421 let override_entry = &config.threshold_overrides[0];
422 assert!(override_entry.functions.is_empty());
423 assert_eq!(override_entry.max_cognitive, Some(25));
424 assert!(config.threshold_override_errors().is_empty());
425 }
426
427 #[test]
428 fn health_config_threshold_override_validation_requires_files() {
429 let json = r#"{
430 "thresholdOverrides": [{
431 "files": [],
432 "maxCognitive": 25
433 }]
434 }"#;
435 let config: HealthConfig = serde_json::from_str(json).unwrap();
436 assert_eq!(
437 config.threshold_override_errors(),
438 vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
439 );
440 }
441
442 #[test]
443 fn health_config_threshold_override_validation_requires_threshold() {
444 let json = r#"{
445 "thresholdOverrides": [{
446 "files": ["src/auth.ts"],
447 "reason": "temporary"
448 }]
449 }"#;
450 let config: HealthConfig = serde_json::from_str(json).unwrap();
451 assert_eq!(
452 config.threshold_override_errors(),
453 vec![
454 "health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, or maxCrap"
455 ]
456 );
457 }
458
459 #[test]
460 fn health_config_threshold_override_rejects_unknown_keys() {
461 let err = serde_json::from_str::<HealthConfig>(
462 r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
463 )
464 .unwrap_err();
465 assert!(err.to_string().contains("maxCogntive"));
466 }
467
468 #[test]
469 fn health_config_suggest_inline_suppression_default_true() {
470 let config = HealthConfig::default();
471 assert!(config.suggest_inline_suppression);
472 }
473
474 #[test]
475 fn health_config_suggest_inline_suppression_explicit_false() {
476 let json = r#"{"suggestInlineSuppression": false}"#;
477 let config: HealthConfig = serde_json::from_str(json).unwrap();
478 assert!(!config.suggest_inline_suppression);
479 }
480
481 #[test]
482 fn health_config_suggest_inline_suppression_omitted_uses_default() {
483 let config: HealthConfig = serde_json::from_str("{}").unwrap();
484 assert!(config.suggest_inline_suppression);
485 }
486
487 #[test]
488 fn health_config_zero_thresholds() {
489 let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
490 let config: HealthConfig = serde_json::from_str(json).unwrap();
491 assert_eq!(config.max_cyclomatic, 0);
492 assert_eq!(config.max_cognitive, 0);
493 }
494
495 #[test]
496 fn health_config_large_thresholds() {
497 let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
498 let config: HealthConfig = serde_json::from_str(json).unwrap();
499 assert_eq!(config.max_cyclomatic, u16::MAX);
500 assert_eq!(config.max_cognitive, u16::MAX);
501 }
502
503 #[test]
504 fn ownership_config_default_has_bot_patterns() {
505 let cfg = OwnershipConfig::default();
506 assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
507 assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
508 assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
509 assert!(
510 !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
511 "*noreply* must not be a default bot pattern (filters real human \
512 contributors using GitHub's privacy default email)"
513 );
514 assert_eq!(cfg.email_mode, EmailMode::Handle);
515 }
516
517 #[test]
518 fn ownership_config_default_via_health() {
519 let cfg = HealthConfig::default();
520 assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
521 assert!(!cfg.ownership.bot_patterns.is_empty());
522 }
523
524 #[test]
525 fn ownership_config_json_overrides_defaults() {
526 let json = r#"{
527 "ownership": {
528 "botPatterns": ["custom-bot*"],
529 "emailMode": "raw"
530 }
531 }"#;
532 let config: HealthConfig = serde_json::from_str(json).unwrap();
533 assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
534 assert_eq!(config.ownership.email_mode, EmailMode::Raw);
535 }
536
537 #[test]
538 fn ownership_config_email_mode_kebab_case() {
539 for (mode, repr) in [
540 (EmailMode::Raw, "\"raw\""),
541 (EmailMode::Handle, "\"handle\""),
542 (EmailMode::Anonymized, "\"anonymized\""),
543 (EmailMode::Hash, "\"hash\""),
544 ] {
545 let s = serde_json::to_string(&mode).unwrap();
546 assert_eq!(s, repr);
547 let back: EmailMode = serde_json::from_str(repr).unwrap();
548 assert_eq!(back, mode);
549 }
550 }
551
552 #[test]
553 fn ownership_config_email_mode_accepts_legacy_hash_alias() {
554 let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
555 assert_eq!(back, EmailMode::Hash);
556 }
557}