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, Deserialize, Serialize, JsonSchema)]
119#[serde(deny_unknown_fields, rename_all = "camelCase")]
120pub struct HealthConfig {
121 #[serde(default = "default_max_cyclomatic")]
126 pub max_cyclomatic: u16,
127
128 #[serde(default = "default_max_cognitive")]
132 pub max_cognitive: u16,
133
134 #[serde(default = "default_max_crap")]
148 pub max_crap: f64,
149
150 #[serde(default = "default_crap_refactor_band")]
155 pub crap_refactor_band: u16,
156
157 #[serde(default = "default_max_unit_size")]
168 pub max_unit_size: u32,
169
170 #[serde(default)]
175 pub coverage: Option<PathBuf>,
176
177 #[serde(default)]
182 pub coverage_root: Option<PathBuf>,
183
184 #[serde(default)]
186 pub ignore: Vec<String>,
187
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub threshold_overrides: Vec<HealthThresholdOverride>,
193
194 #[serde(default)]
197 pub ownership: OwnershipConfig,
198
199 #[serde(default = "default_suggest_inline_suppression")]
208 pub suggest_inline_suppression: bool,
209}
210
211impl Default for HealthConfig {
212 fn default() -> Self {
213 Self {
214 max_cyclomatic: default_max_cyclomatic(),
215 max_cognitive: default_max_cognitive(),
216 max_crap: default_max_crap(),
217 crap_refactor_band: default_crap_refactor_band(),
218 max_unit_size: default_max_unit_size(),
219 coverage: None,
220 coverage_root: None,
221 ignore: vec![],
222 threshold_overrides: vec![],
223 ownership: OwnershipConfig::default(),
224 suggest_inline_suppression: default_suggest_inline_suppression(),
225 }
226 }
227}
228
229#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
231#[serde(deny_unknown_fields, rename_all = "camelCase")]
232pub struct HealthThresholdOverride {
233 pub files: Vec<String>,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 pub functions: Vec<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub max_cyclomatic: Option<u16>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub max_cognitive: Option<u16>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub max_crap: Option<f64>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub max_unit_size: Option<u32>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub reason: Option<String>,
258}
259
260impl HealthThresholdOverride {
261 #[must_use]
263 pub const fn has_any_threshold(&self) -> bool {
264 self.max_cyclomatic.is_some()
265 || self.max_cognitive.is_some()
266 || self.max_crap.is_some()
267 || self.max_unit_size.is_some()
268 }
269}
270
271impl HealthConfig {
272 #[must_use]
274 pub fn threshold_override_errors(&self) -> Vec<String> {
275 let mut errors = Vec::new();
276 for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
277 if override_entry.files.is_empty() {
278 errors.push(format!(
279 "health.thresholdOverrides[{index}].files must contain at least one pattern"
280 ));
281 }
282 if !override_entry.has_any_threshold() {
283 errors.push(format!(
284 "health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
285 ));
286 }
287 }
288 errors
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn health_config_defaults() {
298 let config = HealthConfig::default();
299 assert_eq!(config.max_cyclomatic, 20);
300 assert_eq!(config.max_cognitive, 15);
301 assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
302 assert_eq!(config.crap_refactor_band, 5);
303 assert_eq!(config.max_unit_size, 60);
304 assert!(config.coverage.is_none());
305 assert!(config.coverage_root.is_none());
306 assert!(config.ignore.is_empty());
307 assert!(config.threshold_overrides.is_empty());
308 }
309
310 #[test]
311 fn health_config_json_all_fields() {
312 let json = r#"{
313 "maxCyclomatic": 30,
314 "maxCognitive": 25,
315 "maxCrap": 50.0,
316 "crapRefactorBand": 3,
317 "coverage": "coverage/coverage-final.json",
318 "coverageRoot": "/ci/workspace",
319 "ignore": ["**/generated/**", "vendor/**"],
320 "thresholdOverrides": [{
321 "files": ["components/auth/src/index.ts"],
322 "functions": ["createAuthModule"],
323 "maxCognitive": 25,
324 "reason": "linear module assembly; agreed 2026-06"
325 }]
326 }"#;
327 let config: HealthConfig = serde_json::from_str(json).unwrap();
328 assert_eq!(config.max_cyclomatic, 30);
329 assert_eq!(config.max_cognitive, 25);
330 assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
331 assert_eq!(config.crap_refactor_band, 3);
332 assert_eq!(
333 config.coverage,
334 Some(PathBuf::from("coverage/coverage-final.json"))
335 );
336 assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
337 assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
338 assert_eq!(config.threshold_overrides.len(), 1);
339 assert_eq!(
340 config.threshold_overrides[0].files,
341 vec!["components/auth/src/index.ts"]
342 );
343 assert_eq!(
344 config.threshold_overrides[0].functions,
345 vec!["createAuthModule"]
346 );
347 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
348 }
349
350 #[test]
351 fn health_config_json_partial_uses_defaults() {
352 let json = r#"{"maxCyclomatic": 10}"#;
353 let config: HealthConfig = serde_json::from_str(json).unwrap();
354 assert_eq!(config.max_cyclomatic, 10);
355 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()); }
361
362 #[test]
363 fn health_config_json_only_max_crap() {
364 let json = r#"{"maxCrap": 15.5}"#;
365 let config: HealthConfig = serde_json::from_str(json).unwrap();
366 assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
367 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.crap_refactor_band, 5); }
371
372 #[test]
373 fn health_config_json_empty_object_uses_all_defaults() {
374 let config: HealthConfig = serde_json::from_str("{}").unwrap();
375 assert_eq!(config.max_cyclomatic, 20);
376 assert_eq!(config.max_cognitive, 15);
377 assert_eq!(config.crap_refactor_band, 5);
378 assert!(config.ignore.is_empty());
379 assert!(config.threshold_overrides.is_empty());
380 }
381
382 #[test]
383 fn health_config_json_only_ignore() {
384 let json = r#"{"ignore": ["test/**"]}"#;
385 let config: HealthConfig = serde_json::from_str(json).unwrap();
386 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
389 assert!(config.threshold_overrides.is_empty());
390 }
391
392 #[test]
393 fn health_config_toml_all_fields() {
394 let toml_str = r#"
395maxCyclomatic = 25
396maxCognitive = 20
397ignore = ["generated/**", "vendor/**"]
398
399[[thresholdOverrides]]
400files = ["src/auth.ts"]
401maxCognitive = 25
402"#;
403 let config: HealthConfig = toml::from_str(toml_str).unwrap();
404 assert_eq!(config.max_cyclomatic, 25);
405 assert_eq!(config.max_cognitive, 20);
406 assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
407 assert_eq!(config.threshold_overrides.len(), 1);
408 assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
409 }
410
411 #[test]
412 fn health_config_toml_defaults() {
413 let config: HealthConfig = toml::from_str("").unwrap();
414 assert_eq!(config.max_cyclomatic, 20);
415 assert_eq!(config.max_cognitive, 15);
416 assert!(config.ignore.is_empty());
417 assert!(config.threshold_overrides.is_empty());
418 }
419
420 #[test]
421 fn health_config_json_roundtrip() {
422 let config = HealthConfig {
423 max_cyclomatic: 50,
424 max_cognitive: 40,
425 max_crap: 75.0,
426 crap_refactor_band: 4,
427 max_unit_size: 120,
428 ignore: vec!["test/**".to_string()],
429 threshold_overrides: vec![HealthThresholdOverride {
430 files: vec!["src/auth.ts".to_string()],
431 functions: Vec::new(),
432 max_cyclomatic: Some(30),
433 max_cognitive: None,
434 max_crap: Some(45.0),
435 max_unit_size: None,
436 reason: Some("framework assembly".to_string()),
437 }],
438 coverage: None,
439 coverage_root: None,
440 ownership: OwnershipConfig::default(),
441 suggest_inline_suppression: false,
442 };
443 let json = serde_json::to_string(&config).unwrap();
444 let restored: HealthConfig = serde_json::from_str(&json).unwrap();
445 assert_eq!(restored.max_cyclomatic, 50);
446 assert_eq!(restored.max_cognitive, 40);
447 assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
448 assert_eq!(restored.crap_refactor_band, 4);
449 assert_eq!(restored.max_unit_size, 120);
450 assert_eq!(restored.ignore, vec!["test/**"]);
451 assert_eq!(restored.threshold_overrides.len(), 1);
452 assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
453 assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
454 assert!(!restored.suggest_inline_suppression);
455 }
456
457 #[test]
458 fn health_config_threshold_override_omitted_functions_matches_all() {
459 let json = r#"{
460 "thresholdOverrides": [{
461 "files": ["src/auth.ts"],
462 "maxCognitive": 25
463 }]
464 }"#;
465 let config: HealthConfig = serde_json::from_str(json).unwrap();
466 let override_entry = &config.threshold_overrides[0];
467 assert!(override_entry.functions.is_empty());
468 assert_eq!(override_entry.max_cognitive, Some(25));
469 assert!(config.threshold_override_errors().is_empty());
470 }
471
472 #[test]
473 fn health_config_threshold_override_validation_requires_files() {
474 let json = r#"{
475 "thresholdOverrides": [{
476 "files": [],
477 "maxCognitive": 25
478 }]
479 }"#;
480 let config: HealthConfig = serde_json::from_str(json).unwrap();
481 assert_eq!(
482 config.threshold_override_errors(),
483 vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
484 );
485 }
486
487 #[test]
488 fn health_config_threshold_override_validation_requires_threshold() {
489 let json = r#"{
490 "thresholdOverrides": [{
491 "files": ["src/auth.ts"],
492 "reason": "temporary"
493 }]
494 }"#;
495 let config: HealthConfig = serde_json::from_str(json).unwrap();
496 assert_eq!(
497 config.threshold_override_errors(),
498 vec![
499 "health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
500 ]
501 );
502 }
503
504 #[test]
505 fn health_config_threshold_override_max_unit_size_only_is_valid() {
506 let json = r#"{
507 "thresholdOverrides": [{
508 "files": ["**/*.test.*"],
509 "maxUnitSize": 500
510 }]
511 }"#;
512 let config: HealthConfig = serde_json::from_str(json).unwrap();
513 let override_entry = &config.threshold_overrides[0];
514 assert_eq!(override_entry.max_unit_size, Some(500));
515 assert!(override_entry.max_cyclomatic.is_none());
516 assert!(override_entry.has_any_threshold());
517 assert!(config.threshold_override_errors().is_empty());
518 }
519
520 #[test]
521 fn health_config_json_only_max_unit_size() {
522 let json = r#"{"maxUnitSize": 100}"#;
523 let config: HealthConfig = serde_json::from_str(json).unwrap();
524 assert_eq!(config.max_unit_size, 100);
525 assert_eq!(config.max_cyclomatic, 20); assert!(config.threshold_overrides.is_empty());
527 }
528
529 #[test]
530 fn health_config_threshold_override_rejects_unknown_keys() {
531 let err = serde_json::from_str::<HealthConfig>(
532 r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
533 )
534 .unwrap_err();
535 assert!(err.to_string().contains("maxCogntive"));
536 }
537
538 #[test]
539 fn health_config_suggest_inline_suppression_default_true() {
540 let config = HealthConfig::default();
541 assert!(config.suggest_inline_suppression);
542 }
543
544 #[test]
545 fn health_config_suggest_inline_suppression_explicit_false() {
546 let json = r#"{"suggestInlineSuppression": false}"#;
547 let config: HealthConfig = serde_json::from_str(json).unwrap();
548 assert!(!config.suggest_inline_suppression);
549 }
550
551 #[test]
552 fn health_config_suggest_inline_suppression_omitted_uses_default() {
553 let config: HealthConfig = serde_json::from_str("{}").unwrap();
554 assert!(config.suggest_inline_suppression);
555 }
556
557 #[test]
558 fn health_config_zero_thresholds() {
559 let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
560 let config: HealthConfig = serde_json::from_str(json).unwrap();
561 assert_eq!(config.max_cyclomatic, 0);
562 assert_eq!(config.max_cognitive, 0);
563 }
564
565 #[test]
566 fn health_config_large_thresholds() {
567 let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
568 let config: HealthConfig = serde_json::from_str(json).unwrap();
569 assert_eq!(config.max_cyclomatic, u16::MAX);
570 assert_eq!(config.max_cognitive, u16::MAX);
571 }
572
573 #[test]
574 fn ownership_config_default_has_bot_patterns() {
575 let cfg = OwnershipConfig::default();
576 assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
577 assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
578 assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
579 assert!(
580 !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
581 "*noreply* must not be a default bot pattern (filters real human \
582 contributors using GitHub's privacy default email)"
583 );
584 assert_eq!(cfg.email_mode, EmailMode::Handle);
585 }
586
587 #[test]
588 fn ownership_config_default_via_health() {
589 let cfg = HealthConfig::default();
590 assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
591 assert!(!cfg.ownership.bot_patterns.is_empty());
592 }
593
594 #[test]
595 fn ownership_config_json_overrides_defaults() {
596 let json = r#"{
597 "ownership": {
598 "botPatterns": ["custom-bot*"],
599 "emailMode": "raw"
600 }
601 }"#;
602 let config: HealthConfig = serde_json::from_str(json).unwrap();
603 assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
604 assert_eq!(config.ownership.email_mode, EmailMode::Raw);
605 }
606
607 #[test]
608 fn ownership_config_email_mode_kebab_case() {
609 for (mode, repr) in [
610 (EmailMode::Raw, "\"raw\""),
611 (EmailMode::Handle, "\"handle\""),
612 (EmailMode::Anonymized, "\"anonymized\""),
613 (EmailMode::Hash, "\"hash\""),
614 ] {
615 let s = serde_json::to_string(&mode).unwrap();
616 assert_eq!(s, repr);
617 let back: EmailMode = serde_json::from_str(repr).unwrap();
618 assert_eq!(back, mode);
619 }
620 }
621
622 #[test]
623 fn ownership_config_email_mode_accepts_legacy_hash_alias() {
624 let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
625 assert_eq!(back, EmailMode::Hash);
626 }
627}