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