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