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