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/// Thresholds for change coupling (`change_coupling` in `fallow health`).
118///
119/// The defaults are the Code Maat defaults. A pair of files is hidden coupling
120/// when each file has `minRevisions` commits, the pair shares
121/// `minSharedRevisions` commits, the coupling degree reaches `minDegree`, and
122/// no import or re-export chain connects the two files.
123#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
124#[serde(deny_unknown_fields, rename_all = "camelCase")]
125pub struct ChangeCouplingConfig {
126    /// Skip a commit that changes more analyzed files than this (default:
127    /// 30, from 2 to 1000). Bulk commits (renames, format passes) create
128    /// false pairs. The upper bound keeps the pair count of one commit small.
129    #[serde(default = "default_coupling_max_files_per_commit")]
130    #[schemars(range(min = 2, max = 1000))]
131    pub max_files_per_commit: usize,
132    /// Minimum commits for each file of a pair (default: 5).
133    #[serde(default = "default_coupling_min_revisions")]
134    pub min_revisions: u32,
135    /// Minimum commits that change both files of a pair (default: 5).
136    #[serde(default = "default_coupling_min_shared_revisions")]
137    pub min_shared_revisions: u32,
138    /// Minimum coupling degree, from 0 to 1 (default: 0.3). The degree is the
139    /// number of shared commits divided by the mean commit count of the two
140    /// files.
141    #[serde(default = "default_coupling_min_degree")]
142    #[schemars(range(min = 0.0, max = 1.0))]
143    pub min_degree: f64,
144}
145
146const fn default_coupling_max_files_per_commit() -> usize {
147    30
148}
149
150const fn default_coupling_min_revisions() -> u32 {
151    5
152}
153
154const fn default_coupling_min_shared_revisions() -> u32 {
155    5
156}
157
158const fn default_coupling_min_degree() -> f64 {
159    0.3
160}
161
162/// Smallest `maxFilesPerCommit`: a commit needs two files to form a pair.
163const MIN_COUPLING_FILES_PER_COMMIT: usize = 2;
164
165/// Largest `maxFilesPerCommit`. A commit with `n` files gives `n * (n - 1) / 2`
166/// pairs, so a higher value lets one bulk commit dominate the run time.
167const MAX_COUPLING_FILES_PER_COMMIT: usize = 1000;
168
169impl ChangeCouplingConfig {
170    /// Validate the ranges that serde cannot express.
171    #[must_use]
172    pub fn errors(&self) -> Vec<String> {
173        let mut errors = Vec::new();
174        if !(MIN_COUPLING_FILES_PER_COMMIT..=MAX_COUPLING_FILES_PER_COMMIT)
175            .contains(&self.max_files_per_commit)
176        {
177            errors.push(format!(
178                "health.changeCoupling.maxFilesPerCommit must be from {MIN_COUPLING_FILES_PER_COMMIT} to {MAX_COUPLING_FILES_PER_COMMIT}, found {}",
179                self.max_files_per_commit
180            ));
181        }
182        if !(0.0..=1.0).contains(&self.min_degree) {
183            errors.push(format!(
184                "health.changeCoupling.minDegree must be from 0 to 1, found {}",
185                self.min_degree
186            ));
187        }
188        errors
189    }
190}
191
192impl Default for ChangeCouplingConfig {
193    fn default() -> Self {
194        Self {
195            max_files_per_commit: default_coupling_max_files_per_commit(),
196            min_revisions: default_coupling_min_revisions(),
197            min_shared_revisions: default_coupling_min_shared_revisions(),
198            min_degree: default_coupling_min_degree(),
199        }
200    }
201}
202
203/// Configuration for complexity health metrics (`fallow health`).
204#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
205#[serde(deny_unknown_fields, rename_all = "camelCase")]
206pub struct HealthConfig {
207    /// Maximum allowed cyclomatic complexity per function (default: 20).
208    /// Functions exceeding this threshold are reported. Governs findings
209    /// only; the health score's complexity penalties use fixed calibration
210    /// (use `health.ignore` to remove a file from the score).
211    #[serde(default = "default_max_cyclomatic")]
212    pub max_cyclomatic: u16,
213
214    /// Maximum allowed cognitive complexity per function (default: 15).
215    /// Functions exceeding this threshold are reported. Governs findings
216    /// only, never the health score.
217    #[serde(default = "default_max_cognitive")]
218    pub max_cognitive: u16,
219
220    /// Maximum allowed CRAP (Change Risk Anti-Patterns) score per function
221    /// (default: 30.0). CRAP combines cyclomatic complexity with test
222    /// coverage: high complexity plus low coverage produces a high CRAP
223    /// score. Functions meeting or exceeding this threshold are reported.
224    /// Use `--coverage` with Istanbul data for accurate per-function CRAP;
225    /// otherwise fallow estimates coverage from the module graph. Governs
226    /// findings and the threshold-relative file-score signals
227    /// (`crap_above_threshold`, the `risk` triage tag, and the
228    /// `add_test_coverage` refactoring target); measured values such as
229    /// `crap_max` and the overall health score never move with it. Set to
230    /// `0` to disable CRAP enforcement entirely: no findings, nothing counts
231    /// above threshold, and file-score rows disclose baseline breaches as
232    /// exempt instead. The `complexity-crap` rule is different: it decides if
233    /// a finding fails the run, and `off` hides the findings but keeps the
234    /// file-score signals.
235    #[serde(default = "default_max_crap")]
236    pub max_crap: f64,
237
238    /// Band below `maxCyclomatic` where CRAP-only findings also receive a
239    /// secondary `refactor-function` action (default: 5). Set to `0` to only
240    /// suggest refactoring when cyclomatic already meets the configured
241    /// threshold.
242    #[serde(default = "default_crap_refactor_band")]
243    pub crap_refactor_band: u16,
244
245    /// Maximum function length in lines of code before it is reported as an
246    /// oversized "large function" (default: 60). Raise it globally, or per file
247    /// via `thresholdOverrides[].maxUnitSize`, to relax the bar for generated or
248    /// test files (where a `describe()` block spans hundreds of lines) without
249    /// disabling complexity checks on those files. This filters the reported
250    /// large-functions list only; the descriptive unit-size profile and the
251    /// health score still reflect raw sizes against fixed calibration (the
252    /// `unit_size` penalty keeps its `>60` LOC very-high-risk edge so grades
253    /// stay comparable across projects; use `health.ignore` to remove a file
254    /// from the score entirely).
255    #[serde(default = "default_max_unit_size")]
256    pub max_unit_size: u32,
257
258    /// Path to Istanbul coverage data (coverage-final.json) or raw V8 coverage
259    /// (a `NODE_V8_COVERAGE` directory or one V8 JSON file) for accurate
260    /// per-function CRAP scores. Relative paths resolve against the project root. The CLI
261    /// `--coverage` flag and `FALLOW_COVERAGE` environment variable override
262    /// this value. Consulted by `fallow health`, bare `fallow`, `fallow audit`,
263    /// `fallow viz`, and the MCP `audit` / `check_health` tools.
264    #[serde(default)]
265    pub coverage: Option<PathBuf>,
266
267    /// Absolute prefix to strip from Istanbul file paths before CRAP matching.
268    /// Use when coverage was generated under a different checkout root in CI
269    /// or Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`
270    /// environment variable override this value. Consulted by `fallow health`,
271    /// bare `fallow`, `fallow audit`, `fallow viz`, and the MCP `audit` /
272    /// `check_health` tools.
273    #[serde(default)]
274    pub coverage_root: Option<PathBuf>,
275
276    /// Glob patterns to exclude from complexity analysis.
277    /// A single `*` stays within one path segment; use `**` to match across directories.
278    #[serde(default)]
279    pub ignore: Vec<String>,
280
281    /// Per-file or per-function threshold overrides. These keep exceptional
282    /// functions visible as configured numeric ceilings instead of hiding them
283    /// behind binary suppressions.
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    pub threshold_overrides: Vec<HealthThresholdOverride>,
286
287    /// Ownership analysis configuration. Controls bot filtering and email
288    /// privacy mode for `--ownership` output.
289    #[serde(default)]
290    pub ownership: OwnershipConfig,
291
292    /// Change coupling thresholds for the `change_coupling` section and the
293    /// hidden-coupling refactoring targets. Older fallow versions reject this
294    /// key as unknown, so pair it with `minimumVersion` set to the release
295    /// that added it.
296    #[serde(default)]
297    pub change_coupling: ChangeCouplingConfig,
298
299    /// Whether health JSON output emits `suppress-line` action hints
300    /// alongside complexity findings (default: `true`). Set to `false` to
301    /// opt out across the project: useful for teams that manage suppressions
302    /// exclusively through `// fallow-ignore-*` comments authored by hand or
303    /// through the `fallow.suppress` LSP code action, but who do not want
304    /// CI-driven `suppress-line` action hints in their JSON output.
305    /// `--baseline` activates auto-omission regardless of this setting,
306    /// since baseline files are a separate suppression mechanism.
307    #[serde(default = "default_suggest_inline_suppression")]
308    pub suggest_inline_suppression: bool,
309}
310
311impl Default for HealthConfig {
312    fn default() -> Self {
313        Self {
314            max_cyclomatic: default_max_cyclomatic(),
315            max_cognitive: default_max_cognitive(),
316            max_crap: default_max_crap(),
317            crap_refactor_band: default_crap_refactor_band(),
318            max_unit_size: default_max_unit_size(),
319            coverage: None,
320            coverage_root: None,
321            ignore: vec![],
322            threshold_overrides: vec![],
323            ownership: OwnershipConfig::default(),
324            change_coupling: ChangeCouplingConfig::default(),
325            suggest_inline_suppression: default_suggest_inline_suppression(),
326        }
327    }
328}
329
330/// Per-file or per-function health threshold override.
331#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
332#[serde(deny_unknown_fields, rename_all = "camelCase")]
333pub struct HealthThresholdOverride {
334    /// Project-root-relative file globs this override applies to.
335    /// A single `*` stays within one path segment; use `**` to match across directories.
336    pub files: Vec<String>,
337    /// Exact emitted function names this override applies to. Empty means every
338    /// function in matching files. Synthetic units are matched by their emitted
339    /// name, so `"<template>"` and `"<snippet:NAME>"` are valid keys. The
340    /// synthetic `"<module>"` unit is not: it carries a file's module-scope
341    /// decision points into the aggregate metrics and never produces a finding,
342    /// so there is no ceiling for an override to move.
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub functions: Vec<String>,
345    /// Local cyclomatic complexity ceiling.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub max_cyclomatic: Option<u16>,
348    /// Local cognitive complexity ceiling.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub max_cognitive: Option<u16>,
351    /// Local CRAP ceiling.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub max_crap: Option<f64>,
354    /// Local unit-size ceiling: maximum function length in lines of code before
355    /// it is reported as an oversized "large function". Leave `functions` empty
356    /// to relax the bar for every function in the matching files (which covers
357    /// both the `describe()` wrapper and the individual `it()` blocks in a test
358    /// suite).
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub max_unit_size: Option<u32>,
361    /// Human-readable rationale for the exception.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub reason: Option<String>,
364}
365
366impl HealthThresholdOverride {
367    /// Return true when the override configures at least one local ceiling.
368    #[must_use]
369    pub const fn has_any_threshold(&self) -> bool {
370        self.max_cyclomatic.is_some()
371            || self.max_cognitive.is_some()
372            || self.max_crap.is_some()
373            || self.max_unit_size.is_some()
374    }
375}
376
377impl HealthConfig {
378    /// Validate semantic constraints that serde cannot express.
379    #[must_use]
380    pub fn threshold_override_errors(&self) -> Vec<String> {
381        let mut errors = Vec::new();
382        for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
383            if override_entry.files.is_empty() {
384                errors.push(format!(
385                    "health.thresholdOverrides[{index}].files must contain at least one pattern"
386                ));
387            }
388            if !override_entry.has_any_threshold() {
389                errors.push(format!(
390                    "health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
391                ));
392            }
393        }
394        errors
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn change_coupling_ranges_are_validated() {
404        assert!(ChangeCouplingConfig::default().errors().is_empty());
405        let at_bounds = ChangeCouplingConfig {
406            max_files_per_commit: 1000,
407            min_degree: 1.0,
408            ..ChangeCouplingConfig::default()
409        };
410        assert!(at_bounds.errors().is_empty());
411
412        for (max_files_per_commit, min_degree) in [(1, 0.3), (1001, 0.3), (30, -0.1), (30, 1.5)] {
413            let config = ChangeCouplingConfig {
414                max_files_per_commit,
415                min_degree,
416                ..ChangeCouplingConfig::default()
417            };
418            assert_eq!(
419                config.errors().len(),
420                1,
421                "maxFilesPerCommit {max_files_per_commit}, minDegree {min_degree}"
422            );
423        }
424        let nan = ChangeCouplingConfig {
425            min_degree: f64::NAN,
426            ..ChangeCouplingConfig::default()
427        };
428        assert_eq!(nan.errors().len(), 1);
429    }
430
431    #[test]
432    fn health_config_defaults() {
433        let config = HealthConfig::default();
434        assert_eq!(config.max_cyclomatic, 20);
435        assert_eq!(config.max_cognitive, 15);
436        assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
437        assert_eq!(config.crap_refactor_band, 5);
438        assert_eq!(config.max_unit_size, 60);
439        assert!(config.coverage.is_none());
440        assert!(config.coverage_root.is_none());
441        assert!(config.ignore.is_empty());
442        assert!(config.threshold_overrides.is_empty());
443        assert_eq!(config.change_coupling.max_files_per_commit, 30);
444        assert_eq!(config.change_coupling.min_revisions, 5);
445        assert_eq!(config.change_coupling.min_shared_revisions, 5);
446        assert!((config.change_coupling.min_degree - 0.3).abs() < f64::EPSILON);
447        let ownership = OwnershipConfig::default();
448        assert_eq!(config.ownership.bot_patterns, ownership.bot_patterns);
449        assert_eq!(config.ownership.email_mode, ownership.email_mode);
450    }
451
452    #[test]
453    fn health_config_json_all_fields() {
454        let json = r#"{
455            "maxCyclomatic": 30,
456            "maxCognitive": 25,
457            "maxCrap": 50.0,
458            "crapRefactorBand": 3,
459            "coverage": "coverage/coverage-final.json",
460            "coverageRoot": "/ci/workspace",
461            "ignore": ["**/generated/**", "vendor/**"],
462            "thresholdOverrides": [{
463                "files": ["components/auth/src/index.ts"],
464                "functions": ["createAuthModule"],
465                "maxCognitive": 25,
466                "reason": "linear module assembly; agreed 2026-06"
467            }]
468        }"#;
469        let config: HealthConfig = serde_json::from_str(json).unwrap();
470        assert_eq!(config.max_cyclomatic, 30);
471        assert_eq!(config.max_cognitive, 25);
472        assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
473        assert_eq!(config.crap_refactor_band, 3);
474        assert_eq!(
475            config.coverage,
476            Some(PathBuf::from("coverage/coverage-final.json"))
477        );
478        assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
479        assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
480        assert_eq!(config.threshold_overrides.len(), 1);
481        assert_eq!(
482            config.threshold_overrides[0].files,
483            vec!["components/auth/src/index.ts"]
484        );
485        assert_eq!(
486            config.threshold_overrides[0].functions,
487            vec!["createAuthModule"]
488        );
489        assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
490    }
491
492    #[test]
493    fn health_config_json_partial_uses_defaults() {
494        let json = r#"{"maxCyclomatic": 10}"#;
495        let config: HealthConfig = serde_json::from_str(json).unwrap();
496        assert_eq!(config.max_cyclomatic, 10);
497        assert_eq!(config.max_cognitive, 15); // default
498        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
499        assert_eq!(config.crap_refactor_band, 5); // default
500        assert!(config.ignore.is_empty()); // default
501        assert!(config.threshold_overrides.is_empty()); // default
502    }
503
504    #[test]
505    fn health_config_json_only_max_crap() {
506        let json = r#"{"maxCrap": 15.5}"#;
507        let config: HealthConfig = serde_json::from_str(json).unwrap();
508        assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
509        assert_eq!(config.max_cyclomatic, 20); // default
510        assert_eq!(config.max_cognitive, 15); // default
511        assert_eq!(config.crap_refactor_band, 5); // default
512    }
513
514    #[test]
515    fn health_config_json_empty_object_uses_all_defaults() {
516        let config: HealthConfig = serde_json::from_str("{}").unwrap();
517        assert_eq!(config.max_cyclomatic, 20);
518        assert_eq!(config.max_cognitive, 15);
519        assert_eq!(config.crap_refactor_band, 5);
520        assert!(config.ignore.is_empty());
521        assert!(config.threshold_overrides.is_empty());
522    }
523
524    #[test]
525    fn health_config_json_only_ignore() {
526        let json = r#"{"ignore": ["test/**"]}"#;
527        let config: HealthConfig = serde_json::from_str(json).unwrap();
528        assert_eq!(config.max_cyclomatic, 20); // default
529        assert_eq!(config.max_cognitive, 15); // default
530        assert_eq!(config.ignore, vec!["test/**"]);
531        assert!(config.threshold_overrides.is_empty());
532    }
533
534    #[test]
535    fn health_config_toml_all_fields() {
536        let toml_str = r#"
537maxCyclomatic = 25
538maxCognitive = 20
539ignore = ["generated/**", "vendor/**"]
540
541[[thresholdOverrides]]
542files = ["src/auth.ts"]
543maxCognitive = 25
544"#;
545        let config: HealthConfig = toml::from_str(toml_str).unwrap();
546        assert_eq!(config.max_cyclomatic, 25);
547        assert_eq!(config.max_cognitive, 20);
548        assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
549        assert_eq!(config.threshold_overrides.len(), 1);
550        assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
551    }
552
553    #[test]
554    fn health_config_toml_defaults() {
555        let config: HealthConfig = toml::from_str("").unwrap();
556        assert_eq!(config.max_cyclomatic, 20);
557        assert_eq!(config.max_cognitive, 15);
558        assert!(config.ignore.is_empty());
559        assert!(config.threshold_overrides.is_empty());
560    }
561
562    #[test]
563    fn health_config_json_roundtrip() {
564        let config = HealthConfig {
565            max_cyclomatic: 50,
566            max_cognitive: 40,
567            max_crap: 75.0,
568            crap_refactor_band: 4,
569            max_unit_size: 120,
570            ignore: vec!["test/**".to_string()],
571            threshold_overrides: vec![HealthThresholdOverride {
572                files: vec!["src/auth.ts".to_string()],
573                functions: Vec::new(),
574                max_cyclomatic: Some(30),
575                max_cognitive: None,
576                max_crap: Some(45.0),
577                max_unit_size: None,
578                reason: Some("framework assembly".to_string()),
579            }],
580            coverage: None,
581            coverage_root: None,
582            ownership: OwnershipConfig::default(),
583            change_coupling: ChangeCouplingConfig {
584                min_degree: 0.4,
585                ..ChangeCouplingConfig::default()
586            },
587            suggest_inline_suppression: false,
588        };
589        let json = serde_json::to_string(&config).unwrap();
590        let restored: HealthConfig = serde_json::from_str(&json).unwrap();
591        assert_eq!(restored.max_cyclomatic, 50);
592        assert!((restored.change_coupling.min_degree - 0.4).abs() < f64::EPSILON);
593        assert_eq!(restored.max_cognitive, 40);
594        assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
595        assert_eq!(restored.crap_refactor_band, 4);
596        assert_eq!(restored.max_unit_size, 120);
597        assert_eq!(restored.ignore, vec!["test/**"]);
598        assert_eq!(restored.threshold_overrides.len(), 1);
599        assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
600        assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
601        assert!(!restored.suggest_inline_suppression);
602    }
603
604    #[test]
605    fn health_config_threshold_override_omitted_functions_matches_all() {
606        let json = r#"{
607            "thresholdOverrides": [{
608                "files": ["src/auth.ts"],
609                "maxCognitive": 25
610            }]
611        }"#;
612        let config: HealthConfig = serde_json::from_str(json).unwrap();
613        let override_entry = &config.threshold_overrides[0];
614        assert!(override_entry.functions.is_empty());
615        assert_eq!(override_entry.max_cognitive, Some(25));
616        assert!(config.threshold_override_errors().is_empty());
617    }
618
619    #[test]
620    fn health_config_threshold_override_validation_requires_files() {
621        let json = r#"{
622            "thresholdOverrides": [{
623                "files": [],
624                "maxCognitive": 25
625            }]
626        }"#;
627        let config: HealthConfig = serde_json::from_str(json).unwrap();
628        assert_eq!(
629            config.threshold_override_errors(),
630            vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
631        );
632    }
633
634    #[test]
635    fn health_config_threshold_override_validation_requires_threshold() {
636        let json = r#"{
637            "thresholdOverrides": [{
638                "files": ["src/auth.ts"],
639                "reason": "temporary"
640            }]
641        }"#;
642        let config: HealthConfig = serde_json::from_str(json).unwrap();
643        assert_eq!(
644            config.threshold_override_errors(),
645            vec![
646                "health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, maxCrap, or maxUnitSize"
647            ]
648        );
649    }
650
651    #[test]
652    fn health_config_threshold_override_max_unit_size_only_is_valid() {
653        let json = r#"{
654            "thresholdOverrides": [{
655                "files": ["**/*.test.*"],
656                "maxUnitSize": 500
657            }]
658        }"#;
659        let config: HealthConfig = serde_json::from_str(json).unwrap();
660        let override_entry = &config.threshold_overrides[0];
661        assert_eq!(override_entry.max_unit_size, Some(500));
662        assert!(override_entry.max_cyclomatic.is_none());
663        assert!(override_entry.has_any_threshold());
664        assert!(config.threshold_override_errors().is_empty());
665    }
666
667    #[test]
668    fn health_config_json_only_max_unit_size() {
669        let json = r#"{"maxUnitSize": 100}"#;
670        let config: HealthConfig = serde_json::from_str(json).unwrap();
671        assert_eq!(config.max_unit_size, 100);
672        assert_eq!(config.max_cyclomatic, 20); // default
673        assert!(config.threshold_overrides.is_empty());
674    }
675
676    #[test]
677    fn health_config_threshold_override_rejects_unknown_keys() {
678        let err = serde_json::from_str::<HealthConfig>(
679            r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
680        )
681        .unwrap_err();
682        assert!(err.to_string().contains("maxCogntive"));
683    }
684
685    #[test]
686    fn health_config_suggest_inline_suppression_default_true() {
687        let config = HealthConfig::default();
688        assert!(config.suggest_inline_suppression);
689    }
690
691    #[test]
692    fn health_config_suggest_inline_suppression_explicit_false() {
693        let json = r#"{"suggestInlineSuppression": false}"#;
694        let config: HealthConfig = serde_json::from_str(json).unwrap();
695        assert!(!config.suggest_inline_suppression);
696    }
697
698    #[test]
699    fn health_config_suggest_inline_suppression_omitted_uses_default() {
700        let config: HealthConfig = serde_json::from_str("{}").unwrap();
701        assert!(config.suggest_inline_suppression);
702    }
703
704    #[test]
705    fn health_config_zero_thresholds() {
706        let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
707        let config: HealthConfig = serde_json::from_str(json).unwrap();
708        assert_eq!(config.max_cyclomatic, 0);
709        assert_eq!(config.max_cognitive, 0);
710    }
711
712    #[test]
713    fn health_config_large_thresholds() {
714        let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
715        let config: HealthConfig = serde_json::from_str(json).unwrap();
716        assert_eq!(config.max_cyclomatic, u16::MAX);
717        assert_eq!(config.max_cognitive, u16::MAX);
718    }
719
720    #[test]
721    fn ownership_config_default_has_bot_patterns() {
722        let cfg = OwnershipConfig::default();
723        assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
724        assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
725        assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
726        assert!(
727            !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
728            "*noreply* must not be a default bot pattern (filters real human \
729             contributors using GitHub's privacy default email)"
730        );
731        assert_eq!(cfg.email_mode, EmailMode::Handle);
732    }
733
734    #[test]
735    fn ownership_config_json_overrides_defaults() {
736        let json = r#"{
737            "ownership": {
738                "botPatterns": ["custom-bot*"],
739                "emailMode": "raw"
740            }
741        }"#;
742        let config: HealthConfig = serde_json::from_str(json).unwrap();
743        assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
744        assert_eq!(config.ownership.email_mode, EmailMode::Raw);
745    }
746
747    #[test]
748    fn ownership_config_email_mode_kebab_case() {
749        for (mode, repr) in [
750            (EmailMode::Raw, "\"raw\""),
751            (EmailMode::Handle, "\"handle\""),
752            (EmailMode::Anonymized, "\"anonymized\""),
753            (EmailMode::Hash, "\"hash\""),
754        ] {
755            let s = serde_json::to_string(&mode).unwrap();
756            assert_eq!(s, repr);
757            let back: EmailMode = serde_json::from_str(repr).unwrap();
758            assert_eq!(back, mode);
759        }
760    }
761
762    #[test]
763    fn ownership_config_email_mode_accepts_legacy_hash_alias() {
764        let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
765        assert_eq!(back, EmailMode::Hash);
766    }
767}