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/// Default for `suggest_inline_suppression`: emit `suppress-line` actions
26/// alongside health findings unless a baseline is active or the team has
27/// opted out via config.
28const fn default_suggest_inline_suppression() -> bool {
29    true
30}
31
32/// Default bot/service-account author patterns filtered from ownership metrics.
33///
34/// Matches common CI bot signatures and service-account naming conventions.
35/// Users can extend via `health.ownership.botPatterns` in config.
36///
37/// Note on `[bot]` matching: globset treats `[abc]` as a character class.
38/// To match the literal `[bot]` substring (used by GitHub App bots), escape
39/// the brackets as `\[bot\]`.
40///
41/// `*noreply*` is intentionally NOT a default. Most human GitHub contributors
42/// commit from `<id>+<handle>@users.noreply.github.com` addresses (GitHub's
43/// privacy default). Filtering on `noreply` would silently exclude the
44/// majority of real authors. The actual bot accounts already match via the
45/// `\[bot\]` literal (e.g., `github-actions[bot]@users.noreply.github.com`).
46fn default_bot_patterns() -> Vec<String> {
47    vec![
48        r"*\[bot\]*".to_string(),
49        "dependabot*".to_string(),
50        "renovate*".to_string(),
51        "github-actions*".to_string(),
52        "svc-*".to_string(),
53        "*-service-account*".to_string(),
54    ]
55}
56
57const fn default_email_mode() -> EmailMode {
58    EmailMode::Handle
59}
60
61/// Privacy mode for author emails emitted in ownership output.
62///
63/// Defaults to `handle` (local-part only, no domain) so SARIF and JSON
64/// artifacts do not leak raw email addresses into CI pipelines.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
66#[serde(rename_all = "kebab-case")]
67pub enum EmailMode {
68    /// Show the raw email address as it appears in git history.
69    /// Use for public repositories where history is already exposed.
70    Raw,
71    /// Show the local-part only (before the `@`). Mailmap-resolved where possible.
72    /// Default. Balances readability and privacy.
73    Handle,
74    /// Show a stable `xxh3:<16hex>` pseudonym derived from the raw email.
75    /// Non-cryptographic; suitable to keep raw emails out of CI artifacts
76    /// (SARIF, code-scanning uploads) but not as a security primitive:
77    /// a known list of org emails can be brute-forced into a rainbow table.
78    /// Use in regulated environments where even local-parts are sensitive.
79    Anonymized,
80    /// Legacy spelling for [`EmailMode::Anonymized`].
81    Hash,
82}
83
84/// Configuration for ownership analysis (`fallow health --hotspots --ownership`).
85#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
86#[serde(rename_all = "camelCase")]
87pub struct OwnershipConfig {
88    /// Glob patterns (matched against the author email local-part) that
89    /// identify bot or service-account commits to exclude from ownership
90    /// signals. Overrides the defaults entirely when set.
91    #[serde(default = "default_bot_patterns")]
92    pub bot_patterns: Vec<String>,
93
94    /// Privacy mode for emitted author emails. Defaults to `handle`.
95    /// Override on the CLI via `--ownership-emails=raw|handle|anonymized`.
96    /// The legacy spelling `hash` is still accepted for compatibility.
97    #[serde(default = "default_email_mode")]
98    pub email_mode: EmailMode,
99}
100
101impl Default for OwnershipConfig {
102    fn default() -> Self {
103        Self {
104            bot_patterns: default_bot_patterns(),
105            email_mode: default_email_mode(),
106        }
107    }
108}
109
110/// Configuration for complexity health metrics (`fallow health`).
111#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
112#[serde(deny_unknown_fields, rename_all = "camelCase")]
113pub struct HealthConfig {
114    /// Maximum allowed cyclomatic complexity per function (default: 20).
115    /// Functions exceeding this threshold are reported.
116    #[serde(default = "default_max_cyclomatic")]
117    pub max_cyclomatic: u16,
118
119    /// Maximum allowed cognitive complexity per function (default: 15).
120    /// Functions exceeding this threshold are reported.
121    #[serde(default = "default_max_cognitive")]
122    pub max_cognitive: u16,
123
124    /// Maximum allowed CRAP (Change Risk Anti-Patterns) score per function
125    /// (default: 30.0). CRAP combines cyclomatic complexity with test
126    /// coverage: high complexity plus low coverage produces a high CRAP
127    /// score. Functions meeting or exceeding this threshold are reported.
128    /// Use `--coverage` with Istanbul data for accurate per-function CRAP;
129    /// otherwise fallow estimates coverage from the module graph.
130    #[serde(default = "default_max_crap")]
131    pub max_crap: f64,
132
133    /// Band below `maxCyclomatic` where CRAP-only findings also receive a
134    /// secondary `refactor-function` action (default: 5). Set to `0` to only
135    /// suggest refactoring when cyclomatic already meets the configured
136    /// threshold.
137    #[serde(default = "default_crap_refactor_band")]
138    pub crap_refactor_band: u16,
139
140    /// Path to Istanbul-format coverage data for accurate per-function CRAP
141    /// scores. Relative paths resolve against the project root. The CLI
142    /// `--coverage` flag and `FALLOW_COVERAGE` environment variable override
143    /// this value.
144    #[serde(default)]
145    pub coverage: Option<PathBuf>,
146
147    /// Absolute prefix to strip from Istanbul file paths before CRAP matching.
148    /// Use when coverage was generated under a different checkout root in CI
149    /// or Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`
150    /// environment variable override this value.
151    #[serde(default)]
152    pub coverage_root: Option<PathBuf>,
153
154    /// Glob patterns to exclude from complexity analysis.
155    #[serde(default)]
156    pub ignore: Vec<String>,
157
158    /// Per-file or per-function threshold overrides. These keep exceptional
159    /// functions visible as configured numeric ceilings instead of hiding them
160    /// behind binary suppressions.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub threshold_overrides: Vec<HealthThresholdOverride>,
163
164    /// Ownership analysis configuration. Controls bot filtering and email
165    /// privacy mode for `--ownership` output.
166    #[serde(default)]
167    pub ownership: OwnershipConfig,
168
169    /// Whether health JSON output emits `suppress-line` action hints
170    /// alongside complexity findings (default: `true`). Set to `false` to
171    /// opt out across the project: useful for teams that manage suppressions
172    /// exclusively through `// fallow-ignore-*` comments authored by hand or
173    /// through the `fallow.suppress` LSP code action, but who do not want
174    /// CI-driven `suppress-line` action hints in their JSON output.
175    /// `--baseline` activates auto-omission regardless of this setting,
176    /// since baseline files are a separate suppression mechanism.
177    #[serde(default = "default_suggest_inline_suppression")]
178    pub suggest_inline_suppression: bool,
179}
180
181impl Default for HealthConfig {
182    fn default() -> Self {
183        Self {
184            max_cyclomatic: default_max_cyclomatic(),
185            max_cognitive: default_max_cognitive(),
186            max_crap: default_max_crap(),
187            crap_refactor_band: default_crap_refactor_band(),
188            coverage: None,
189            coverage_root: None,
190            ignore: vec![],
191            threshold_overrides: vec![],
192            ownership: OwnershipConfig::default(),
193            suggest_inline_suppression: default_suggest_inline_suppression(),
194        }
195    }
196}
197
198/// Per-file or per-function health threshold override.
199#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
200#[serde(deny_unknown_fields, rename_all = "camelCase")]
201pub struct HealthThresholdOverride {
202    /// Project-root-relative file globs this override applies to.
203    pub files: Vec<String>,
204    /// Exact emitted function names this override applies to. Empty means every
205    /// function in matching files.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub functions: Vec<String>,
208    /// Local cyclomatic complexity ceiling.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub max_cyclomatic: Option<u16>,
211    /// Local cognitive complexity ceiling.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub max_cognitive: Option<u16>,
214    /// Local CRAP ceiling.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub max_crap: Option<f64>,
217    /// Human-readable rationale for the exception.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub reason: Option<String>,
220}
221
222impl HealthThresholdOverride {
223    /// Return true when the override configures at least one local ceiling.
224    #[must_use]
225    pub const fn has_any_threshold(&self) -> bool {
226        self.max_cyclomatic.is_some() || self.max_cognitive.is_some() || self.max_crap.is_some()
227    }
228}
229
230impl HealthConfig {
231    /// Validate semantic constraints that serde cannot express.
232    #[must_use]
233    pub fn threshold_override_errors(&self) -> Vec<String> {
234        let mut errors = Vec::new();
235        for (index, override_entry) in self.threshold_overrides.iter().enumerate() {
236            if override_entry.files.is_empty() {
237                errors.push(format!(
238                    "health.thresholdOverrides[{index}].files must contain at least one pattern"
239                ));
240            }
241            if !override_entry.has_any_threshold() {
242                errors.push(format!(
243                    "health.thresholdOverrides[{index}] must set at least one of maxCyclomatic, maxCognitive, or maxCrap"
244                ));
245            }
246        }
247        errors
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn health_config_defaults() {
257        let config = HealthConfig::default();
258        assert_eq!(config.max_cyclomatic, 20);
259        assert_eq!(config.max_cognitive, 15);
260        assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
261        assert_eq!(config.crap_refactor_band, 5);
262        assert!(config.coverage.is_none());
263        assert!(config.coverage_root.is_none());
264        assert!(config.ignore.is_empty());
265        assert!(config.threshold_overrides.is_empty());
266    }
267
268    #[test]
269    fn health_config_json_all_fields() {
270        let json = r#"{
271            "maxCyclomatic": 30,
272            "maxCognitive": 25,
273            "maxCrap": 50.0,
274            "crapRefactorBand": 3,
275            "coverage": "coverage/coverage-final.json",
276            "coverageRoot": "/ci/workspace",
277            "ignore": ["**/generated/**", "vendor/**"],
278            "thresholdOverrides": [{
279                "files": ["components/auth/src/index.ts"],
280                "functions": ["createAuthModule"],
281                "maxCognitive": 25,
282                "reason": "linear module assembly; agreed 2026-06"
283            }]
284        }"#;
285        let config: HealthConfig = serde_json::from_str(json).unwrap();
286        assert_eq!(config.max_cyclomatic, 30);
287        assert_eq!(config.max_cognitive, 25);
288        assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
289        assert_eq!(config.crap_refactor_band, 3);
290        assert_eq!(
291            config.coverage,
292            Some(PathBuf::from("coverage/coverage-final.json"))
293        );
294        assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
295        assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
296        assert_eq!(config.threshold_overrides.len(), 1);
297        assert_eq!(
298            config.threshold_overrides[0].files,
299            vec!["components/auth/src/index.ts"]
300        );
301        assert_eq!(
302            config.threshold_overrides[0].functions,
303            vec!["createAuthModule"]
304        );
305        assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
306    }
307
308    #[test]
309    fn health_config_json_partial_uses_defaults() {
310        let json = r#"{"maxCyclomatic": 10}"#;
311        let config: HealthConfig = serde_json::from_str(json).unwrap();
312        assert_eq!(config.max_cyclomatic, 10);
313        assert_eq!(config.max_cognitive, 15); // default
314        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
315        assert_eq!(config.crap_refactor_band, 5); // default
316        assert!(config.ignore.is_empty()); // default
317        assert!(config.threshold_overrides.is_empty()); // default
318    }
319
320    #[test]
321    fn health_config_json_only_max_crap() {
322        let json = r#"{"maxCrap": 15.5}"#;
323        let config: HealthConfig = serde_json::from_str(json).unwrap();
324        assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
325        assert_eq!(config.max_cyclomatic, 20); // default
326        assert_eq!(config.max_cognitive, 15); // default
327        assert_eq!(config.crap_refactor_band, 5); // default
328    }
329
330    #[test]
331    fn health_config_json_empty_object_uses_all_defaults() {
332        let config: HealthConfig = serde_json::from_str("{}").unwrap();
333        assert_eq!(config.max_cyclomatic, 20);
334        assert_eq!(config.max_cognitive, 15);
335        assert_eq!(config.crap_refactor_band, 5);
336        assert!(config.ignore.is_empty());
337        assert!(config.threshold_overrides.is_empty());
338    }
339
340    #[test]
341    fn health_config_json_only_ignore() {
342        let json = r#"{"ignore": ["test/**"]}"#;
343        let config: HealthConfig = serde_json::from_str(json).unwrap();
344        assert_eq!(config.max_cyclomatic, 20); // default
345        assert_eq!(config.max_cognitive, 15); // default
346        assert_eq!(config.ignore, vec!["test/**"]);
347        assert!(config.threshold_overrides.is_empty());
348    }
349
350    #[test]
351    fn health_config_toml_all_fields() {
352        let toml_str = r#"
353maxCyclomatic = 25
354maxCognitive = 20
355ignore = ["generated/**", "vendor/**"]
356
357[[thresholdOverrides]]
358files = ["src/auth.ts"]
359maxCognitive = 25
360"#;
361        let config: HealthConfig = toml::from_str(toml_str).unwrap();
362        assert_eq!(config.max_cyclomatic, 25);
363        assert_eq!(config.max_cognitive, 20);
364        assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
365        assert_eq!(config.threshold_overrides.len(), 1);
366        assert_eq!(config.threshold_overrides[0].max_cognitive, Some(25));
367    }
368
369    #[test]
370    fn health_config_toml_defaults() {
371        let config: HealthConfig = toml::from_str("").unwrap();
372        assert_eq!(config.max_cyclomatic, 20);
373        assert_eq!(config.max_cognitive, 15);
374        assert!(config.ignore.is_empty());
375        assert!(config.threshold_overrides.is_empty());
376    }
377
378    #[test]
379    fn health_config_json_roundtrip() {
380        let config = HealthConfig {
381            max_cyclomatic: 50,
382            max_cognitive: 40,
383            max_crap: 75.0,
384            crap_refactor_band: 4,
385            ignore: vec!["test/**".to_string()],
386            threshold_overrides: vec![HealthThresholdOverride {
387                files: vec!["src/auth.ts".to_string()],
388                functions: Vec::new(),
389                max_cyclomatic: Some(30),
390                max_cognitive: None,
391                max_crap: Some(45.0),
392                reason: Some("framework assembly".to_string()),
393            }],
394            coverage: None,
395            coverage_root: None,
396            ownership: OwnershipConfig::default(),
397            suggest_inline_suppression: false,
398        };
399        let json = serde_json::to_string(&config).unwrap();
400        let restored: HealthConfig = serde_json::from_str(&json).unwrap();
401        assert_eq!(restored.max_cyclomatic, 50);
402        assert_eq!(restored.max_cognitive, 40);
403        assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
404        assert_eq!(restored.crap_refactor_band, 4);
405        assert_eq!(restored.ignore, vec!["test/**"]);
406        assert_eq!(restored.threshold_overrides.len(), 1);
407        assert_eq!(restored.threshold_overrides[0].max_cyclomatic, Some(30));
408        assert_eq!(restored.threshold_overrides[0].max_crap, Some(45.0));
409        assert!(!restored.suggest_inline_suppression);
410    }
411
412    #[test]
413    fn health_config_threshold_override_omitted_functions_matches_all() {
414        let json = r#"{
415            "thresholdOverrides": [{
416                "files": ["src/auth.ts"],
417                "maxCognitive": 25
418            }]
419        }"#;
420        let config: HealthConfig = serde_json::from_str(json).unwrap();
421        let override_entry = &config.threshold_overrides[0];
422        assert!(override_entry.functions.is_empty());
423        assert_eq!(override_entry.max_cognitive, Some(25));
424        assert!(config.threshold_override_errors().is_empty());
425    }
426
427    #[test]
428    fn health_config_threshold_override_validation_requires_files() {
429        let json = r#"{
430            "thresholdOverrides": [{
431                "files": [],
432                "maxCognitive": 25
433            }]
434        }"#;
435        let config: HealthConfig = serde_json::from_str(json).unwrap();
436        assert_eq!(
437            config.threshold_override_errors(),
438            vec!["health.thresholdOverrides[0].files must contain at least one pattern"]
439        );
440    }
441
442    #[test]
443    fn health_config_threshold_override_validation_requires_threshold() {
444        let json = r#"{
445            "thresholdOverrides": [{
446                "files": ["src/auth.ts"],
447                "reason": "temporary"
448            }]
449        }"#;
450        let config: HealthConfig = serde_json::from_str(json).unwrap();
451        assert_eq!(
452            config.threshold_override_errors(),
453            vec![
454                "health.thresholdOverrides[0] must set at least one of maxCyclomatic, maxCognitive, or maxCrap"
455            ]
456        );
457    }
458
459    #[test]
460    fn health_config_threshold_override_rejects_unknown_keys() {
461        let err = serde_json::from_str::<HealthConfig>(
462            r#"{"thresholdOverrides":[{"files":["src/auth.ts"],"maxCogntive":25}]}"#,
463        )
464        .unwrap_err();
465        assert!(err.to_string().contains("maxCogntive"));
466    }
467
468    #[test]
469    fn health_config_suggest_inline_suppression_default_true() {
470        let config = HealthConfig::default();
471        assert!(config.suggest_inline_suppression);
472    }
473
474    #[test]
475    fn health_config_suggest_inline_suppression_explicit_false() {
476        let json = r#"{"suggestInlineSuppression": false}"#;
477        let config: HealthConfig = serde_json::from_str(json).unwrap();
478        assert!(!config.suggest_inline_suppression);
479    }
480
481    #[test]
482    fn health_config_suggest_inline_suppression_omitted_uses_default() {
483        let config: HealthConfig = serde_json::from_str("{}").unwrap();
484        assert!(config.suggest_inline_suppression);
485    }
486
487    #[test]
488    fn health_config_zero_thresholds() {
489        let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
490        let config: HealthConfig = serde_json::from_str(json).unwrap();
491        assert_eq!(config.max_cyclomatic, 0);
492        assert_eq!(config.max_cognitive, 0);
493    }
494
495    #[test]
496    fn health_config_large_thresholds() {
497        let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
498        let config: HealthConfig = serde_json::from_str(json).unwrap();
499        assert_eq!(config.max_cyclomatic, u16::MAX);
500        assert_eq!(config.max_cognitive, u16::MAX);
501    }
502
503    #[test]
504    fn ownership_config_default_has_bot_patterns() {
505        let cfg = OwnershipConfig::default();
506        assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
507        assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
508        assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
509        assert!(
510            !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
511            "*noreply* must not be a default bot pattern (filters real human \
512             contributors using GitHub's privacy default email)"
513        );
514        assert_eq!(cfg.email_mode, EmailMode::Handle);
515    }
516
517    #[test]
518    fn ownership_config_default_via_health() {
519        let cfg = HealthConfig::default();
520        assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
521        assert!(!cfg.ownership.bot_patterns.is_empty());
522    }
523
524    #[test]
525    fn ownership_config_json_overrides_defaults() {
526        let json = r#"{
527            "ownership": {
528                "botPatterns": ["custom-bot*"],
529                "emailMode": "raw"
530            }
531        }"#;
532        let config: HealthConfig = serde_json::from_str(json).unwrap();
533        assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
534        assert_eq!(config.ownership.email_mode, EmailMode::Raw);
535    }
536
537    #[test]
538    fn ownership_config_email_mode_kebab_case() {
539        for (mode, repr) in [
540            (EmailMode::Raw, "\"raw\""),
541            (EmailMode::Handle, "\"handle\""),
542            (EmailMode::Anonymized, "\"anonymized\""),
543            (EmailMode::Hash, "\"hash\""),
544        ] {
545            let s = serde_json::to_string(&mode).unwrap();
546            assert_eq!(s, repr);
547            let back: EmailMode = serde_json::from_str(repr).unwrap();
548            assert_eq!(back, mode);
549        }
550    }
551
552    #[test]
553    fn ownership_config_email_mode_accepts_legacy_hash_alias() {
554        let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
555        assert_eq!(back, EmailMode::Hash);
556    }
557}