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