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    /// 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`, and the MCP `audit` / `check_health`
183    /// 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.
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub functions: Vec<String>,
242    /// Local cyclomatic complexity ceiling.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub max_cyclomatic: Option<u16>,
245    /// Local cognitive complexity ceiling.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub max_cognitive: Option<u16>,
248    /// Local CRAP ceiling.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub max_crap: Option<f64>,
251    /// Local unit-size ceiling: maximum function length in lines of code before
252    /// it is reported as an oversized "large function". Leave `functions` empty
253    /// to relax the bar for every function in the matching files (which covers
254    /// both the `describe()` wrapper and the individual `it()` blocks in a test
255    /// suite).
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub max_unit_size: Option<u32>,
258    /// Human-readable rationale for the exception.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub reason: Option<String>,
261}
262
263impl HealthThresholdOverride {
264    /// Return true when the override configures at least one local ceiling.
265    #[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    /// Validate semantic constraints that serde cannot express.
276    #[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); // default
359        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
360        assert_eq!(config.crap_refactor_band, 5); // default
361        assert!(config.ignore.is_empty()); // default
362        assert!(config.threshold_overrides.is_empty()); // default
363    }
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); // default
371        assert_eq!(config.max_cognitive, 15); // default
372        assert_eq!(config.crap_refactor_band, 5); // default
373    }
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); // default
390        assert_eq!(config.max_cognitive, 15); // default
391        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); // default
529        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}