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