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