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