Skip to main content

fallow_config/config/
health.rs

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
14/// Savoia and Evans (2007) canonical CRAP threshold: CC=5 untested gives
15/// exactly `5^2 + 5 = 30`, marking the boundary where refactoring or test
16/// coverage becomes recommended.
17const fn default_max_crap() -> f64 {
18    30.0
19}
20
21const fn default_crap_refactor_band() -> u16 {
22    5
23}
24
25/// SIG unit-size "very high risk" boundary: functions over 60 lines of code.
26/// This is the default line-count threshold above which a function is reported
27/// as an oversized "large function".
28const fn default_max_unit_size() -> u32 {
29    60
30}
31
32/// Default for `suggest_inline_suppression`: emit `suppress-line` actions
33/// alongside health findings unless a baseline is active or the team has
34/// opted out via config.
35const fn default_suggest_inline_suppression() -> bool {
36    true
37}
38
39/// Default bot/service-account author patterns filtered from ownership metrics.
40///
41/// Matches common CI bot signatures and service-account naming conventions.
42/// Users can extend via `health.ownership.botPatterns` in config.
43///
44/// Note on `[bot]` matching: globset treats `[abc]` as a character class.
45/// To match the literal `[bot]` substring (used by GitHub App bots), escape
46/// the brackets as `\[bot\]`.
47///
48/// `*noreply*` is intentionally NOT a default. Most human GitHub contributors
49/// commit from `<id>+<handle>@users.noreply.github.com` addresses (GitHub's
50/// privacy default). Filtering on `noreply` would silently exclude the
51/// majority of real authors. The actual bot accounts already match via the
52/// `\[bot\]` literal (e.g., `github-actions[bot]@users.noreply.github.com`).
53fn 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/// Privacy mode for author emails emitted in ownership output.
69///
70/// Defaults to `handle` (local-part only, no domain) so SARIF and JSON
71/// artifacts do not leak raw email addresses into CI pipelines.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
73#[serde(rename_all = "kebab-case")]
74pub enum EmailMode {
75    /// Show the raw email address as it appears in git history.
76    /// Use for public repositories where history is already exposed.
77    Raw,
78    /// Show the local-part only (before the `@`). Mailmap-resolved where possible.
79    /// Default. Balances readability and privacy.
80    Handle,
81    /// Show a stable `xxh3:<16hex>` pseudonym derived from the raw email.
82    /// Non-cryptographic; suitable to keep raw emails out of CI artifacts
83    /// (SARIF, code-scanning uploads) but not as a security primitive:
84    /// a known list of org emails can be brute-forced into a rainbow table.
85    /// Use in regulated environments where even local-parts are sensitive.
86    Anonymized,
87    /// Legacy spelling for [`EmailMode::Anonymized`].
88    Hash,
89}
90
91/// Configuration for ownership analysis (`fallow health --hotspots --ownership`).
92#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
93#[serde(rename_all = "camelCase")]
94pub struct OwnershipConfig {
95    /// Glob patterns (matched against the author email local-part) that
96    /// identify bot or service-account commits to exclude from ownership
97    /// signals. Overrides the defaults entirely when set.
98    #[serde(default = "default_bot_patterns")]
99    pub bot_patterns: Vec<String>,
100
101    /// Privacy mode for emitted author emails. Defaults to `handle`.
102    /// Override on the CLI via `--ownership-emails=raw|handle|anonymized`.
103    /// The legacy spelling `hash` is still accepted for compatibility.
104    #[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/// Configuration for complexity health metrics (`fallow health`).
118#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
119#[serde(deny_unknown_fields, rename_all = "camelCase")]
120pub struct HealthConfig {
121    /// Maximum allowed cyclomatic complexity per function (default: 20).
122    /// Functions exceeding this threshold are reported. Governs findings
123    /// only; the health score's complexity penalties use fixed calibration
124    /// (use `health.ignore` to remove a file from the score).
125    #[serde(default = "default_max_cyclomatic")]
126    pub max_cyclomatic: u16,
127
128    /// Maximum allowed cognitive complexity per function (default: 15).
129    /// Functions exceeding this threshold are reported. Governs findings
130    /// only, never the health score.
131    #[serde(default = "default_max_cognitive")]
132    pub max_cognitive: u16,
133
134    /// Maximum allowed CRAP (Change Risk Anti-Patterns) score per function
135    /// (default: 30.0). CRAP combines cyclomatic complexity with test
136    /// coverage: high complexity plus low coverage produces a high CRAP
137    /// score. Functions meeting or exceeding this threshold are reported.
138    /// Use `--coverage` with Istanbul data for accurate per-function CRAP;
139    /// otherwise fallow estimates coverage from the module graph. Governs
140    /// findings and the threshold-relative file-score signals
141    /// (`crap_above_threshold`, the `risk` triage tag, and the
142    /// `add_test_coverage` refactoring target); measured values such as
143    /// `crap_max` and the overall health score never move with it. Set to
144    /// `0` to disable CRAP enforcement entirely: no findings, nothing counts
145    /// above threshold, and file-score rows disclose baseline breaches as
146    /// exempt instead.
147    #[serde(default = "default_max_crap")]
148    pub max_crap: f64,
149
150    /// Band below `maxCyclomatic` where CRAP-only findings also receive a
151    /// secondary `refactor-function` action (default: 5). Set to `0` to only
152    /// suggest refactoring when cyclomatic already meets the configured
153    /// threshold.
154    #[serde(default = "default_crap_refactor_band")]
155    pub crap_refactor_band: u16,
156
157    /// Maximum function length in lines of code before it is reported as an
158    /// oversized "large function" (default: 60). Raise it globally, or per file
159    /// via `thresholdOverrides[].maxUnitSize`, to relax the bar for generated or
160    /// test files (where a `describe()` block spans hundreds of lines) without
161    /// disabling complexity checks on those files. This filters the reported
162    /// large-functions list only; the descriptive unit-size profile and the
163    /// health score still reflect raw sizes against fixed calibration (the
164    /// `unit_size` penalty keeps its `>60` LOC very-high-risk edge so grades
165    /// stay comparable across projects; use `health.ignore` to remove a file
166    /// from the score entirely).
167    #[serde(default = "default_max_unit_size")]
168    pub max_unit_size: u32,
169
170    /// Path to Istanbul-format coverage data for accurate per-function CRAP
171    /// scores. Relative paths resolve against the project root. The CLI
172    /// `--coverage` flag and `FALLOW_COVERAGE` environment variable override
173    /// this value. Consulted by `fallow health`, bare `fallow`, `fallow audit`,
174    /// `fallow viz`, and the MCP `audit` / `check_health` tools.
175    #[serde(default)]
176    pub coverage: Option<PathBuf>,
177
178    /// Absolute prefix to strip from Istanbul file paths before CRAP matching.
179    /// Use when coverage was generated under a different checkout root in CI
180    /// or Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`
181    /// environment variable override this value. Consulted by `fallow health`,
182    /// bare `fallow`, `fallow audit`, `fallow viz`, and the MCP `audit` /
183    /// `check_health` tools.
184    #[serde(default)]
185    pub coverage_root: Option<PathBuf>,
186
187    /// Glob patterns to exclude from complexity analysis.
188    #[serde(default)]
189    pub ignore: Vec<String>,
190
191    /// Per-file or per-function threshold overrides. These keep exceptional
192    /// functions visible as configured numeric ceilings instead of hiding them
193    /// behind binary suppressions.
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub threshold_overrides: Vec<HealthThresholdOverride>,
196
197    /// Ownership analysis configuration. Controls bot filtering and email
198    /// privacy mode for `--ownership` output.
199    #[serde(default)]
200    pub ownership: OwnershipConfig,
201
202    /// Whether health JSON output emits `suppress-line` action hints
203    /// alongside complexity findings (default: `true`). Set to `false` to
204    /// opt out across the project: useful for teams that manage suppressions
205    /// exclusively through `// fallow-ignore-*` comments authored by hand or
206    /// through the `fallow.suppress` LSP code action, but who do not want
207    /// CI-driven `suppress-line` action hints in their JSON output.
208    /// `--baseline` activates auto-omission regardless of this setting,
209    /// since baseline files are a separate suppression mechanism.
210    #[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/// Per-file or per-function health threshold override.
233#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
234#[serde(deny_unknown_fields, rename_all = "camelCase")]
235pub struct HealthThresholdOverride {
236    /// Project-root-relative file globs this override applies to.
237    pub files: Vec<String>,
238    /// Exact emitted function names this override applies to. Empty means every
239    /// function in matching files. Synthetic units are matched by their emitted
240    /// name, so `"<template>"` and `"<snippet:NAME>"` are valid keys. The
241    /// synthetic `"<module>"` unit is not: it carries a file's module-scope
242    /// decision points into the aggregate metrics and never produces a finding,
243    /// so there is no ceiling for an override to move.
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub functions: Vec<String>,
246    /// Local cyclomatic complexity ceiling.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub max_cyclomatic: Option<u16>,
249    /// Local cognitive complexity ceiling.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub max_cognitive: Option<u16>,
252    /// Local CRAP ceiling.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub max_crap: Option<f64>,
255    /// Local unit-size ceiling: maximum function length in lines of code before
256    /// it is reported as an oversized "large function". Leave `functions` empty
257    /// to relax the bar for every function in the matching files (which covers
258    /// both the `describe()` wrapper and the individual `it()` blocks in a test
259    /// suite).
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub max_unit_size: Option<u32>,
262    /// Human-readable rationale for the exception.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub reason: Option<String>,
265}
266
267impl HealthThresholdOverride {
268    /// Return true when the override configures at least one local ceiling.
269    #[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    /// Validate semantic constraints that serde cannot express.
280    #[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); // default
363        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
364        assert_eq!(config.crap_refactor_band, 5); // default
365        assert!(config.ignore.is_empty()); // default
366        assert!(config.threshold_overrides.is_empty()); // default
367    }
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); // default
375        assert_eq!(config.max_cognitive, 15); // default
376        assert_eq!(config.crap_refactor_band, 5); // default
377    }
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); // default
394        assert_eq!(config.max_cognitive, 15); // default
395        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); // default
533        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}