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