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(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    /// Ownership analysis configuration. Controls bot filtering and email
159    /// privacy mode for `--ownership` output.
160    #[serde(default)]
161    pub ownership: OwnershipConfig,
162
163    /// Whether health JSON output emits `suppress-line` action hints
164    /// alongside complexity findings (default: `true`). Set to `false` to
165    /// opt out across the project: useful for teams that manage suppressions
166    /// exclusively through `// fallow-ignore-*` comments authored by hand or
167    /// through the `fallow.suppress` LSP code action, but who do not want
168    /// CI-driven `suppress-line` action hints in their JSON output.
169    /// `--baseline` activates auto-omission regardless of this setting,
170    /// since baseline files are a separate suppression mechanism.
171    #[serde(default = "default_suggest_inline_suppression")]
172    pub suggest_inline_suppression: bool,
173}
174
175impl Default for HealthConfig {
176    fn default() -> Self {
177        Self {
178            max_cyclomatic: default_max_cyclomatic(),
179            max_cognitive: default_max_cognitive(),
180            max_crap: default_max_crap(),
181            crap_refactor_band: default_crap_refactor_band(),
182            coverage: None,
183            coverage_root: None,
184            ignore: vec![],
185            ownership: OwnershipConfig::default(),
186            suggest_inline_suppression: default_suggest_inline_suppression(),
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn health_config_defaults() {
197        let config = HealthConfig::default();
198        assert_eq!(config.max_cyclomatic, 20);
199        assert_eq!(config.max_cognitive, 15);
200        assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
201        assert_eq!(config.crap_refactor_band, 5);
202        assert!(config.coverage.is_none());
203        assert!(config.coverage_root.is_none());
204        assert!(config.ignore.is_empty());
205    }
206
207    #[test]
208    fn health_config_json_all_fields() {
209        let json = r#"{
210            "maxCyclomatic": 30,
211            "maxCognitive": 25,
212            "maxCrap": 50.0,
213            "crapRefactorBand": 3,
214            "coverage": "coverage/coverage-final.json",
215            "coverageRoot": "/ci/workspace",
216            "ignore": ["**/generated/**", "vendor/**"]
217        }"#;
218        let config: HealthConfig = serde_json::from_str(json).unwrap();
219        assert_eq!(config.max_cyclomatic, 30);
220        assert_eq!(config.max_cognitive, 25);
221        assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
222        assert_eq!(config.crap_refactor_band, 3);
223        assert_eq!(
224            config.coverage,
225            Some(PathBuf::from("coverage/coverage-final.json"))
226        );
227        assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
228        assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
229    }
230
231    #[test]
232    fn health_config_json_partial_uses_defaults() {
233        let json = r#"{"maxCyclomatic": 10}"#;
234        let config: HealthConfig = serde_json::from_str(json).unwrap();
235        assert_eq!(config.max_cyclomatic, 10);
236        assert_eq!(config.max_cognitive, 15); // default
237        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
238        assert_eq!(config.crap_refactor_band, 5); // default
239        assert!(config.ignore.is_empty()); // default
240    }
241
242    #[test]
243    fn health_config_json_only_max_crap() {
244        let json = r#"{"maxCrap": 15.5}"#;
245        let config: HealthConfig = serde_json::from_str(json).unwrap();
246        assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
247        assert_eq!(config.max_cyclomatic, 20); // default
248        assert_eq!(config.max_cognitive, 15); // default
249        assert_eq!(config.crap_refactor_band, 5); // default
250    }
251
252    #[test]
253    fn health_config_json_empty_object_uses_all_defaults() {
254        let config: HealthConfig = serde_json::from_str("{}").unwrap();
255        assert_eq!(config.max_cyclomatic, 20);
256        assert_eq!(config.max_cognitive, 15);
257        assert_eq!(config.crap_refactor_band, 5);
258        assert!(config.ignore.is_empty());
259    }
260
261    #[test]
262    fn health_config_json_only_ignore() {
263        let json = r#"{"ignore": ["test/**"]}"#;
264        let config: HealthConfig = serde_json::from_str(json).unwrap();
265        assert_eq!(config.max_cyclomatic, 20); // default
266        assert_eq!(config.max_cognitive, 15); // default
267        assert_eq!(config.ignore, vec!["test/**"]);
268    }
269
270    #[test]
271    fn health_config_toml_all_fields() {
272        let toml_str = r#"
273maxCyclomatic = 25
274maxCognitive = 20
275ignore = ["generated/**", "vendor/**"]
276"#;
277        let config: HealthConfig = toml::from_str(toml_str).unwrap();
278        assert_eq!(config.max_cyclomatic, 25);
279        assert_eq!(config.max_cognitive, 20);
280        assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
281    }
282
283    #[test]
284    fn health_config_toml_defaults() {
285        let config: HealthConfig = toml::from_str("").unwrap();
286        assert_eq!(config.max_cyclomatic, 20);
287        assert_eq!(config.max_cognitive, 15);
288        assert!(config.ignore.is_empty());
289    }
290
291    #[test]
292    fn health_config_json_roundtrip() {
293        let config = HealthConfig {
294            max_cyclomatic: 50,
295            max_cognitive: 40,
296            max_crap: 75.0,
297            crap_refactor_band: 4,
298            ignore: vec!["test/**".to_string()],
299            coverage: None,
300            coverage_root: None,
301            ownership: OwnershipConfig::default(),
302            suggest_inline_suppression: false,
303        };
304        let json = serde_json::to_string(&config).unwrap();
305        let restored: HealthConfig = serde_json::from_str(&json).unwrap();
306        assert_eq!(restored.max_cyclomatic, 50);
307        assert_eq!(restored.max_cognitive, 40);
308        assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
309        assert_eq!(restored.crap_refactor_band, 4);
310        assert_eq!(restored.ignore, vec!["test/**"]);
311        assert!(!restored.suggest_inline_suppression);
312    }
313
314    #[test]
315    fn health_config_suggest_inline_suppression_default_true() {
316        let config = HealthConfig::default();
317        assert!(config.suggest_inline_suppression);
318    }
319
320    #[test]
321    fn health_config_suggest_inline_suppression_explicit_false() {
322        let json = r#"{"suggestInlineSuppression": false}"#;
323        let config: HealthConfig = serde_json::from_str(json).unwrap();
324        assert!(!config.suggest_inline_suppression);
325    }
326
327    #[test]
328    fn health_config_suggest_inline_suppression_omitted_uses_default() {
329        let config: HealthConfig = serde_json::from_str("{}").unwrap();
330        assert!(config.suggest_inline_suppression);
331    }
332
333    #[test]
334    fn health_config_zero_thresholds() {
335        let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
336        let config: HealthConfig = serde_json::from_str(json).unwrap();
337        assert_eq!(config.max_cyclomatic, 0);
338        assert_eq!(config.max_cognitive, 0);
339    }
340
341    #[test]
342    fn health_config_large_thresholds() {
343        let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
344        let config: HealthConfig = serde_json::from_str(json).unwrap();
345        assert_eq!(config.max_cyclomatic, u16::MAX);
346        assert_eq!(config.max_cognitive, u16::MAX);
347    }
348
349    #[test]
350    fn ownership_config_default_has_bot_patterns() {
351        let cfg = OwnershipConfig::default();
352        assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
353        assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
354        assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
355        assert!(
356            !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
357            "*noreply* must not be a default bot pattern (filters real human \
358             contributors using GitHub's privacy default email)"
359        );
360        assert_eq!(cfg.email_mode, EmailMode::Handle);
361    }
362
363    #[test]
364    fn ownership_config_default_via_health() {
365        let cfg = HealthConfig::default();
366        assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
367        assert!(!cfg.ownership.bot_patterns.is_empty());
368    }
369
370    #[test]
371    fn ownership_config_json_overrides_defaults() {
372        let json = r#"{
373            "ownership": {
374                "botPatterns": ["custom-bot*"],
375                "emailMode": "raw"
376            }
377        }"#;
378        let config: HealthConfig = serde_json::from_str(json).unwrap();
379        assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
380        assert_eq!(config.ownership.email_mode, EmailMode::Raw);
381    }
382
383    #[test]
384    fn ownership_config_email_mode_kebab_case() {
385        for (mode, repr) in [
386            (EmailMode::Raw, "\"raw\""),
387            (EmailMode::Handle, "\"handle\""),
388            (EmailMode::Anonymized, "\"anonymized\""),
389            (EmailMode::Hash, "\"hash\""),
390        ] {
391            let s = serde_json::to_string(&mode).unwrap();
392            assert_eq!(s, repr);
393            let back: EmailMode = serde_json::from_str(repr).unwrap();
394            assert_eq!(back, mode);
395        }
396    }
397
398    #[test]
399    fn ownership_config_email_mode_accepts_legacy_hash_alias() {
400        let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
401        assert_eq!(back, EmailMode::Hash);
402    }
403}