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. The `complexity-crap` rule is different: it decides if
147    /// a finding fails the run, and `off` hides the findings but keeps the
148    /// file-score signals.
149    #[serde(default = "default_max_crap")]
150    pub max_crap: f64,
151
152    /// Band below `maxCyclomatic` where CRAP-only findings also receive a
153    /// secondary `refactor-function` action (default: 5). Set to `0` to only
154    /// suggest refactoring when cyclomatic already meets the configured
155    /// threshold.
156    #[serde(default = "default_crap_refactor_band")]
157    pub crap_refactor_band: u16,
158
159    /// Maximum function length in lines of code before it is reported as an
160    /// oversized "large function" (default: 60). Raise it globally, or per file
161    /// via `thresholdOverrides[].maxUnitSize`, to relax the bar for generated or
162    /// test files (where a `describe()` block spans hundreds of lines) without
163    /// disabling complexity checks on those files. This filters the reported
164    /// large-functions list only; the descriptive unit-size profile and the
165    /// health score still reflect raw sizes against fixed calibration (the
166    /// `unit_size` penalty keeps its `>60` LOC very-high-risk edge so grades
167    /// stay comparable across projects; use `health.ignore` to remove a file
168    /// from the score entirely).
169    #[serde(default = "default_max_unit_size")]
170    pub max_unit_size: u32,
171
172    /// Path to Istanbul coverage data (coverage-final.json) or raw V8 coverage
173    /// (a `NODE_V8_COVERAGE` directory or one V8 JSON file) for accurate
174    /// per-function CRAP scores. Relative paths resolve against the project root. The CLI
175    /// `--coverage` flag and `FALLOW_COVERAGE` environment variable override
176    /// this value. Consulted by `fallow health`, bare `fallow`, `fallow audit`,
177    /// `fallow viz`, and the MCP `audit` / `check_health` tools.
178    #[serde(default)]
179    pub coverage: Option<PathBuf>,
180
181    /// Absolute prefix to strip from Istanbul file paths before CRAP matching.
182    /// Use when coverage was generated under a different checkout root in CI
183    /// or Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`
184    /// environment variable override this value. Consulted by `fallow health`,
185    /// bare `fallow`, `fallow audit`, `fallow viz`, and the MCP `audit` /
186    /// `check_health` tools.
187    #[serde(default)]
188    pub coverage_root: Option<PathBuf>,
189
190    /// Glob patterns to exclude from complexity analysis.
191    #[serde(default)]
192    pub ignore: Vec<String>,
193
194    /// Per-file or per-function threshold overrides. These keep exceptional
195    /// functions visible as configured numeric ceilings instead of hiding them
196    /// behind binary suppressions.
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub threshold_overrides: Vec<HealthThresholdOverride>,
199
200    /// Ownership analysis configuration. Controls bot filtering and email
201    /// privacy mode for `--ownership` output.
202    #[serde(default)]
203    pub ownership: OwnershipConfig,
204
205    /// Whether health JSON output emits `suppress-line` action hints
206    /// alongside complexity findings (default: `true`). Set to `false` to
207    /// opt out across the project: useful for teams that manage suppressions
208    /// exclusively through `// fallow-ignore-*` comments authored by hand or
209    /// through the `fallow.suppress` LSP code action, but who do not want
210    /// CI-driven `suppress-line` action hints in their JSON output.
211    /// `--baseline` activates auto-omission regardless of this setting,
212    /// since baseline files are a separate suppression mechanism.
213    #[serde(default = "default_suggest_inline_suppression")]
214    pub suggest_inline_suppression: bool,
215}
216
217impl Default for HealthConfig {
218    fn default() -> Self {
219        Self {
220            max_cyclomatic: default_max_cyclomatic(),
221            max_cognitive: default_max_cognitive(),
222            max_crap: default_max_crap(),
223            crap_refactor_band: default_crap_refactor_band(),
224            max_unit_size: default_max_unit_size(),
225            coverage: None,
226            coverage_root: None,
227            ignore: vec![],
228            threshold_overrides: vec![],
229            ownership: OwnershipConfig::default(),
230            suggest_inline_suppression: default_suggest_inline_suppression(),
231        }
232    }
233}
234
235/// Per-file or per-function health threshold override.
236#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
237#[serde(deny_unknown_fields, rename_all = "camelCase")]
238pub struct HealthThresholdOverride {
239    /// Project-root-relative file globs this override applies to.
240    pub files: Vec<String>,
241    /// Exact emitted function names this override applies to. Empty means every
242    /// function in matching files. Synthetic units are matched by their emitted
243    /// name, so `"<template>"` and `"<snippet:NAME>"` are valid keys. The
244    /// synthetic `"<module>"` unit is not: it carries a file's module-scope
245    /// decision points into the aggregate metrics and never produces a finding,
246    /// so there is no ceiling for an override to move.
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub functions: Vec<String>,
249    /// Local cyclomatic complexity ceiling.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub max_cyclomatic: Option<u16>,
252    /// Local cognitive complexity ceiling.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub max_cognitive: Option<u16>,
255    /// Local CRAP ceiling.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub max_crap: Option<f64>,
258    /// Local unit-size ceiling: maximum function length in lines of code before
259    /// it is reported as an oversized "large function". Leave `functions` empty
260    /// to relax the bar for every function in the matching files (which covers
261    /// both the `describe()` wrapper and the individual `it()` blocks in a test
262    /// suite).
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub max_unit_size: Option<u32>,
265    /// Human-readable rationale for the exception.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub reason: Option<String>,
268}
269
270impl HealthThresholdOverride {
271    /// Return true when the override configures at least one local ceiling.
272    #[must_use]
273    pub const fn has_any_threshold(&self) -> bool {
274        self.max_cyclomatic.is_some()
275            || self.max_cognitive.is_some()
276            || self.max_crap.is_some()
277            || self.max_unit_size.is_some()
278    }
279}
280
281impl HealthConfig {
282    /// Validate semantic constraints that serde cannot express.
283    #[must_use]
284    pub fn threshold_override_errors(&self) -> Vec<String> {
285        let mut errors = Vec::new();
286        for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
287            if override_entry.files.is_empty() {
288                errors.push(format!(
289                    "health.thresholdOverrides[{index}].files must contain at least one pattern"
290                ));
291            }
292            if !override_entry.has_any_threshold() {
293                errors.push(format!(
294                    "health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
295                ));
296            }
297        }
298        errors
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn health_config_defaults() {
308        let config = HealthConfig::default();
309        assert_eq!(config.max_cyclomatic, 20);
310        assert_eq!(config.max_cognitive, 15);
311        assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
312        assert_eq!(config.crap_refactor_band, 5);
313        assert_eq!(config.max_unit_size, 60);
314        assert!(config.coverage.is_none());
315        assert!(config.coverage_root.is_none());
316        assert!(config.ignore.is_empty());
317        assert!(config.threshold_overrides.is_empty());
318        let ownership = OwnershipConfig::default();
319        assert_eq!(config.ownership.bot_patterns, ownership.bot_patterns);
320        assert_eq!(config.ownership.email_mode, ownership.email_mode);
321    }
322
323    #[test]
324    fn health_config_json_all_fields() {
325        let json = r#"{
326            "maxCyclomatic": 30,
327            "maxCognitive": 25,
328            "maxCrap": 50.0,
329            "crapRefactorBand": 3,
330            "coverage": "coverage/coverage-final.json",
331            "coverageRoot": "/ci/workspace",
332            "ignore": ["**/generated/**", "vendor/**"],
333            "thresholdOverrides": [{
334                "files": ["components/auth/src/index.ts"],
335                "functions": ["createAuthModule"],
336                "maxCognitive": 25,
337                "reason": "linear module assembly; agreed 2026-06"
338            }]
339        }"#;
340        let config: HealthConfig = serde_json::from_str(json).unwrap();
341        assert_eq!(config.max_cyclomatic, 30);
342        assert_eq!(config.max_cognitive, 25);
343        assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
344        assert_eq!(config.crap_refactor_band, 3);
345        assert_eq!(
346            config.coverage,
347            Some(PathBuf::from("coverage/coverage-final.json"))
348        );
349        assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
350        assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
351        assert_eq!(config.threshold_overrides.len(), 1);
352        assert_eq!(
353            config.threshold_overrides[0].files,
354            vec!["components/auth/src/index.ts"]
355        );
356        assert_eq!(
357            config.threshold_overrides[0].functions,
358            vec!["createAuthModule"]
359        );
360        assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
361    }
362
363    #[test]
364    fn health_config_json_partial_uses_defaults() {
365        let json = r#"{"maxCyclomatic": 10}"#;
366        let config: HealthConfig = serde_json::from_str(json).unwrap();
367        assert_eq!(config.max_cyclomatic, 10);
368        assert_eq!(config.max_cognitive, 15); // default
369        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
370        assert_eq!(config.crap_refactor_band, 5); // default
371        assert!(config.ignore.is_empty()); // default
372        assert!(config.threshold_overrides.is_empty()); // default
373    }
374
375    #[test]
376    fn health_config_json_only_max_crap() {
377        let json = r#"{"maxCrap": 15.5}"#;
378        let config: HealthConfig = serde_json::from_str(json).unwrap();
379        assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
380        assert_eq!(config.max_cyclomatic, 20); // default
381        assert_eq!(config.max_cognitive, 15); // default
382        assert_eq!(config.crap_refactor_band, 5); // default
383    }
384
385    #[test]
386    fn health_config_json_empty_object_uses_all_defaults() {
387        let config: HealthConfig = serde_json::from_str("{}").unwrap();
388        assert_eq!(config.max_cyclomatic, 20);
389        assert_eq!(config.max_cognitive, 15);
390        assert_eq!(config.crap_refactor_band, 5);
391        assert!(config.ignore.is_empty());
392        assert!(config.threshold_overrides.is_empty());
393    }
394
395    #[test]
396    fn health_config_json_only_ignore() {
397        let json = r#"{"ignore": ["test/**"]}"#;
398        let config: HealthConfig = serde_json::from_str(json).unwrap();
399        assert_eq!(config.max_cyclomatic, 20); // default
400        assert_eq!(config.max_cognitive, 15); // default
401        assert_eq!(config.ignore, vec!["test/**"]);
402        assert!(config.threshold_overrides.is_empty());
403    }
404
405    #[test]
406    fn health_config_toml_all_fields() {
407        let toml_str = r#"
408maxCyclomatic = 25
409maxCognitive = 20
410ignore = ["generated/**", "vendor/**"]
411
412[[thresholdOverrides]]
413files = ["src/auth.ts"]
414maxCognitive = 25
415"#;
416        let config: HealthConfig = toml::from_str(toml_str).unwrap();
417        assert_eq!(config.max_cyclomatic, 25);
418        assert_eq!(config.max_cognitive, 20);
419        assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
420        assert_eq!(config.threshold_overrides.len(), 1);
421        assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
422    }
423
424    #[test]
425    fn health_config_toml_defaults() {
426        let config: HealthConfig = toml::from_str("").unwrap();
427        assert_eq!(config.max_cyclomatic, 20);
428        assert_eq!(config.max_cognitive, 15);
429        assert!(config.ignore.is_empty());
430        assert!(config.threshold_overrides.is_empty());
431    }
432
433    #[test]
434    fn health_config_json_roundtrip() {
435        let config = HealthConfig {
436            max_cyclomatic: 50,
437            max_cognitive: 40,
438            max_crap: 75.0,
439            crap_refactor_band: 4,
440            max_unit_size: 120,
441            ignore: vec!["test/**".to_string()],
442            threshold_overrides: vec![HealthThresholdOverride {
443                files: vec!["src/auth.ts".to_string()],
444                functions: Vec::new(),
445                max_cyclomatic: Some(30),
446                max_cognitive: None,
447                max_crap: Some(45.0),
448                max_unit_size: None,
449                reason: Some("framework assembly".to_string()),
450            }],
451            coverage: None,
452            coverage_root: None,
453            ownership: OwnershipConfig::default(),
454            suggest_inline_suppression: false,
455        };
456        let json = serde_json::to_string(&config).unwrap();
457        let restored: HealthConfig = serde_json::from_str(&json).unwrap();
458        assert_eq!(restored.max_cyclomatic, 50);
459        assert_eq!(restored.max_cognitive, 40);
460        assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
461        assert_eq!(restored.crap_refactor_band, 4);
462        assert_eq!(restored.max_unit_size, 120);
463        assert_eq!(restored.ignore, vec!["test/**"]);
464        assert_eq!(restored.threshold_overrides.len(), 1);
465        assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
466        assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
467        assert!(!restored.suggest_inline_suppression);
468    }
469
470    #[test]
471    fn health_config_threshold_override_omitted_functions_matches_all() {
472        let json = r#"{
473            "thresholdOverrides": [{
474                "files": ["src/auth.ts"],
475                "maxCognitive": 25
476            }]
477        }"#;
478        let config: HealthConfig = serde_json::from_str(json).unwrap();
479        let override_entry = &config.threshold_overrides[0];
480        assert!(override_entry.functions.is_empty());
481        assert_eq!(override_entry.max_cognitive, Some(25));
482        assert!(config.threshold_override_errors().is_empty());
483    }
484
485    #[test]
486    fn health_config_threshold_override_validation_requires_files() {
487        let json = r#"{
488            "thresholdOverrides": [{
489                "files": [],
490                "maxCognitive": 25
491            }]
492        }"#;
493        let config: HealthConfig = serde_json::from_str(json).unwrap();
494        assert_eq!(
495            config.threshold_override_errors(),
496            vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
497        );
498    }
499
500    #[test]
501    fn health_config_threshold_override_validation_requires_threshold() {
502        let json = r#"{
503            "thresholdOverrides": [{
504                "files": ["src/auth.ts"],
505                "reason": "temporary"
506            }]
507        }"#;
508        let config: HealthConfig = serde_json::from_str(json).unwrap();
509        assert_eq!(
510            config.threshold_override_errors(),
511            vec![
512                "health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
513            ]
514        );
515    }
516
517    #[test]
518    fn health_config_threshold_override_max_unit_size_only_is_valid() {
519        let json = r#"{
520            "thresholdOverrides": [{
521                "files": ["**/*.test.*"],
522                "maxUnitSize": 500
523            }]
524        }"#;
525        let config: HealthConfig = serde_json::from_str(json).unwrap();
526        let override_entry = &config.threshold_overrides[0];
527        assert_eq!(override_entry.max_unit_size, Some(500));
528        assert!(override_entry.max_cyclomatic.is_none());
529        assert!(override_entry.has_any_threshold());
530        assert!(config.threshold_override_errors().is_empty());
531    }
532
533    #[test]
534    fn health_config_json_only_max_unit_size() {
535        let json = r#"{"maxUnitSize": 100}"#;
536        let config: HealthConfig = serde_json::from_str(json).unwrap();
537        assert_eq!(config.max_unit_size, 100);
538        assert_eq!(config.max_cyclomatic, 20); // default
539        assert!(config.threshold_overrides.is_empty());
540    }
541
542    #[test]
543    fn health_config_threshold_override_rejects_unknown_keys() {
544        let err = serde_json::from_str::<HealthConfig>(
545            r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
546        )
547        .unwrap_err();
548        assert!(err.to_string().contains("maxCogntive"));
549    }
550
551    #[test]
552    fn health_config_suggest_inline_suppression_default_true() {
553        let config = HealthConfig::default();
554        assert!(config.suggest_inline_suppression);
555    }
556
557    #[test]
558    fn health_config_suggest_inline_suppression_explicit_false() {
559        let json = r#"{"suggestInlineSuppression": false}"#;
560        let config: HealthConfig = serde_json::from_str(json).unwrap();
561        assert!(!config.suggest_inline_suppression);
562    }
563
564    #[test]
565    fn health_config_suggest_inline_suppression_omitted_uses_default() {
566        let config: HealthConfig = serde_json::from_str("{}").unwrap();
567        assert!(config.suggest_inline_suppression);
568    }
569
570    #[test]
571    fn health_config_zero_thresholds() {
572        let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
573        let config: HealthConfig = serde_json::from_str(json).unwrap();
574        assert_eq!(config.max_cyclomatic, 0);
575        assert_eq!(config.max_cognitive, 0);
576    }
577
578    #[test]
579    fn health_config_large_thresholds() {
580        let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
581        let config: HealthConfig = serde_json::from_str(json).unwrap();
582        assert_eq!(config.max_cyclomatic, u16::MAX);
583        assert_eq!(config.max_cognitive, u16::MAX);
584    }
585
586    #[test]
587    fn ownership_config_default_has_bot_patterns() {
588        let cfg = OwnershipConfig::default();
589        assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
590        assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
591        assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
592        assert!(
593            !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
594            "*noreply* must not be a default bot pattern (filters real human \
595             contributors using GitHub's privacy default email)"
596        );
597        assert_eq!(cfg.email_mode, EmailMode::Handle);
598    }
599
600    #[test]
601    fn ownership_config_json_overrides_defaults() {
602        let json = r#"{
603            "ownership": {
604                "botPatterns": ["custom-bot*"],
605                "emailMode": "raw"
606            }
607        }"#;
608        let config: HealthConfig = serde_json::from_str(json).unwrap();
609        assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
610        assert_eq!(config.ownership.email_mode, EmailMode::Raw);
611    }
612
613    #[test]
614    fn ownership_config_email_mode_kebab_case() {
615        for (mode, repr) in [
616            (EmailMode::Raw, "\"raw\""),
617            (EmailMode::Handle, "\"handle\""),
618            (EmailMode::Anonymized, "\"anonymized\""),
619            (EmailMode::Hash, "\"hash\""),
620        ] {
621            let s = serde_json::to_string(&mode).unwrap();
622            assert_eq!(s, repr);
623            let back: EmailMode = serde_json::from_str(repr).unwrap();
624            assert_eq!(back, mode);
625        }
626    }
627
628    #[test]
629    fn ownership_config_email_mode_accepts_legacy_hash_alias() {
630        let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
631        assert_eq!(back, EmailMode::Hash);
632    }
633}