Skip to main content

fallow_config/config/
health.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4const fn default_max_cyclomatic() -> u16 {
5    20
6}
7
8const fn default_max_cognitive() -> u16 {
9    15
10}
11
12/// Savoia and Evans (2007) canonical CRAP threshold: CC=5 untested gives
13/// exactly `5^2 + 5 = 30`, marking the boundary where refactoring or test
14/// coverage becomes recommended.
15const fn default_max_crap() -> f64 {
16    30.0
17}
18
19const fn default_crap_refactor_band() -> u16 {
20    5
21}
22
23/// Default for `suggest_inline_suppression`: emit `suppress-line` actions
24/// alongside health findings unless a baseline is active or the team has
25/// opted out via config.
26const fn default_suggest_inline_suppression() -> bool {
27    true
28}
29
30/// Default bot/service-account author patterns filtered from ownership metrics.
31///
32/// Matches common CI bot signatures and service-account naming conventions.
33/// Users can extend via `health.ownership.botPatterns` in config.
34///
35/// Note on `[bot]` matching: globset treats `[abc]` as a character class.
36/// To match the literal `[bot]` substring (used by GitHub App bots), escape
37/// the brackets as `\[bot\]`.
38///
39/// `*noreply*` is intentionally NOT a default. Most human GitHub contributors
40/// commit from `<id>+<handle>@users.noreply.github.com` addresses (GitHub's
41/// privacy default). Filtering on `noreply` would silently exclude the
42/// majority of real authors. The actual bot accounts already match via the
43/// `\[bot\]` literal (e.g., `github-actions[bot]@users.noreply.github.com`).
44fn default_bot_patterns() -> Vec<String> {
45    vec![
46        r"*\[bot\]*".to_string(),
47        "dependabot*".to_string(),
48        "renovate*".to_string(),
49        "github-actions*".to_string(),
50        "svc-*".to_string(),
51        "*-service-account*".to_string(),
52    ]
53}
54
55const fn default_email_mode() -> EmailMode {
56    EmailMode::Handle
57}
58
59/// Privacy mode for author emails emitted in ownership output.
60///
61/// Defaults to `handle` (local-part only, no domain) so SARIF and JSON
62/// artifacts do not leak raw email addresses into CI pipelines.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
64#[serde(rename_all = "kebab-case")]
65pub enum EmailMode {
66    /// Show the raw email address as it appears in git history.
67    /// Use for public repositories where history is already exposed.
68    Raw,
69    /// Show the local-part only (before the `@`). Mailmap-resolved where possible.
70    /// Default. Balances readability and privacy.
71    Handle,
72    /// Show a stable `xxh3:<16hex>` pseudonym derived from the raw email.
73    /// Non-cryptographic; suitable to keep raw emails out of CI artifacts
74    /// (SARIF, code-scanning uploads) but not as a security primitive:
75    /// a known list of org emails can be brute-forced into a rainbow table.
76    /// Use in regulated environments where even local-parts are sensitive.
77    Anonymized,
78    /// Legacy spelling for [`EmailMode::Anonymized`].
79    Hash,
80}
81
82/// Configuration for ownership analysis (`fallow health --hotspots --ownership`).
83#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
84#[serde(rename_all = "camelCase")]
85pub struct OwnershipConfig {
86    /// Glob patterns (matched against the author email local-part) that
87    /// identify bot or service-account commits to exclude from ownership
88    /// signals. Overrides the defaults entirely when set.
89    #[serde(default = "default_bot_patterns")]
90    pub bot_patterns: Vec<String>,
91
92    /// Privacy mode for emitted author emails. Defaults to `handle`.
93    /// Override on the CLI via `--ownership-emails=raw|handle|anonymized`.
94    /// The legacy spelling `hash` is still accepted for compatibility.
95    #[serde(default = "default_email_mode")]
96    pub email_mode: EmailMode,
97}
98
99impl Default for OwnershipConfig {
100    fn default() -> Self {
101        Self {
102            bot_patterns: default_bot_patterns(),
103            email_mode: default_email_mode(),
104        }
105    }
106}
107
108/// Configuration for complexity health metrics (`fallow health`).
109#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(rename_all = "camelCase")]
111pub struct HealthConfig {
112    /// Maximum allowed cyclomatic complexity per function (default: 20).
113    /// Functions exceeding this threshold are reported.
114    #[serde(default = "default_max_cyclomatic")]
115    pub max_cyclomatic: u16,
116
117    /// Maximum allowed cognitive complexity per function (default: 15).
118    /// Functions exceeding this threshold are reported.
119    #[serde(default = "default_max_cognitive")]
120    pub max_cognitive: u16,
121
122    /// Maximum allowed CRAP (Change Risk Anti-Patterns) score per function
123    /// (default: 30.0). CRAP combines cyclomatic complexity with test
124    /// coverage: high complexity plus low coverage produces a high CRAP
125    /// score. Functions meeting or exceeding this threshold are reported.
126    /// Use `--coverage` with Istanbul data for accurate per-function CRAP;
127    /// otherwise fallow estimates coverage from the module graph.
128    #[serde(default = "default_max_crap")]
129    pub max_crap: f64,
130
131    /// Band below `maxCyclomatic` where CRAP-only findings also receive a
132    /// secondary `refactor-function` action (default: 5). Set to `0` to only
133    /// suggest refactoring when cyclomatic already meets the configured
134    /// threshold.
135    #[serde(default = "default_crap_refactor_band")]
136    pub crap_refactor_band: u16,
137
138    /// Glob patterns to exclude from complexity analysis.
139    #[serde(default)]
140    pub ignore: Vec<String>,
141
142    /// Ownership analysis configuration. Controls bot filtering and email
143    /// privacy mode for `--ownership` output.
144    #[serde(default)]
145    pub ownership: OwnershipConfig,
146
147    /// Whether health JSON output emits `suppress-line` action hints
148    /// alongside complexity findings (default: `true`). Set to `false` to
149    /// opt out across the project: useful for teams that manage suppressions
150    /// exclusively through `// fallow-ignore-*` comments authored by hand or
151    /// through the `fallow.suppress` LSP code action, but who do not want
152    /// CI-driven `suppress-line` action hints in their JSON output.
153    /// `--baseline` activates auto-omission regardless of this setting,
154    /// since baseline files are a separate suppression mechanism.
155    #[serde(default = "default_suggest_inline_suppression")]
156    pub suggest_inline_suppression: bool,
157}
158
159impl Default for HealthConfig {
160    fn default() -> Self {
161        Self {
162            max_cyclomatic: default_max_cyclomatic(),
163            max_cognitive: default_max_cognitive(),
164            max_crap: default_max_crap(),
165            crap_refactor_band: default_crap_refactor_band(),
166            ignore: vec![],
167            ownership: OwnershipConfig::default(),
168            suggest_inline_suppression: default_suggest_inline_suppression(),
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn health_config_defaults() {
179        let config = HealthConfig::default();
180        assert_eq!(config.max_cyclomatic, 20);
181        assert_eq!(config.max_cognitive, 15);
182        assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
183        assert_eq!(config.crap_refactor_band, 5);
184        assert!(config.ignore.is_empty());
185    }
186
187    #[test]
188    fn health_config_json_all_fields() {
189        let json = r#"{
190            "maxCyclomatic": 30,
191            "maxCognitive": 25,
192            "maxCrap": 50.0,
193            "crapRefactorBand": 3,
194            "ignore": ["**/generated/**", "vendor/**"]
195        }"#;
196        let config: HealthConfig = serde_json::from_str(json).unwrap();
197        assert_eq!(config.max_cyclomatic, 30);
198        assert_eq!(config.max_cognitive, 25);
199        assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
200        assert_eq!(config.crap_refactor_band, 3);
201        assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
202    }
203
204    #[test]
205    fn health_config_json_partial_uses_defaults() {
206        let json = r#"{"maxCyclomatic": 10}"#;
207        let config: HealthConfig = serde_json::from_str(json).unwrap();
208        assert_eq!(config.max_cyclomatic, 10);
209        assert_eq!(config.max_cognitive, 15); // default
210        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
211        assert_eq!(config.crap_refactor_band, 5); // default
212        assert!(config.ignore.is_empty()); // default
213    }
214
215    #[test]
216    fn health_config_json_only_max_crap() {
217        let json = r#"{"maxCrap": 15.5}"#;
218        let config: HealthConfig = serde_json::from_str(json).unwrap();
219        assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
220        assert_eq!(config.max_cyclomatic, 20); // default
221        assert_eq!(config.max_cognitive, 15); // default
222        assert_eq!(config.crap_refactor_band, 5); // default
223    }
224
225    #[test]
226    fn health_config_json_empty_object_uses_all_defaults() {
227        let config: HealthConfig = serde_json::from_str("{}").unwrap();
228        assert_eq!(config.max_cyclomatic, 20);
229        assert_eq!(config.max_cognitive, 15);
230        assert_eq!(config.crap_refactor_band, 5);
231        assert!(config.ignore.is_empty());
232    }
233
234    #[test]
235    fn health_config_json_only_ignore() {
236        let json = r#"{"ignore": ["test/**"]}"#;
237        let config: HealthConfig = serde_json::from_str(json).unwrap();
238        assert_eq!(config.max_cyclomatic, 20); // default
239        assert_eq!(config.max_cognitive, 15); // default
240        assert_eq!(config.ignore, vec!["test/**"]);
241    }
242
243    #[test]
244    fn health_config_toml_all_fields() {
245        let toml_str = r#"
246maxCyclomatic = 25
247maxCognitive = 20
248ignore = ["generated/**", "vendor/**"]
249"#;
250        let config: HealthConfig = toml::from_str(toml_str).unwrap();
251        assert_eq!(config.max_cyclomatic, 25);
252        assert_eq!(config.max_cognitive, 20);
253        assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
254    }
255
256    #[test]
257    fn health_config_toml_defaults() {
258        let config: HealthConfig = toml::from_str("").unwrap();
259        assert_eq!(config.max_cyclomatic, 20);
260        assert_eq!(config.max_cognitive, 15);
261        assert!(config.ignore.is_empty());
262    }
263
264    #[test]
265    fn health_config_json_roundtrip() {
266        let config = HealthConfig {
267            max_cyclomatic: 50,
268            max_cognitive: 40,
269            max_crap: 75.0,
270            crap_refactor_band: 4,
271            ignore: vec!["test/**".to_string()],
272            ownership: OwnershipConfig::default(),
273            suggest_inline_suppression: false,
274        };
275        let json = serde_json::to_string(&config).unwrap();
276        let restored: HealthConfig = serde_json::from_str(&json).unwrap();
277        assert_eq!(restored.max_cyclomatic, 50);
278        assert_eq!(restored.max_cognitive, 40);
279        assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
280        assert_eq!(restored.crap_refactor_band, 4);
281        assert_eq!(restored.ignore, vec!["test/**"]);
282        assert!(!restored.suggest_inline_suppression);
283    }
284
285    #[test]
286    fn health_config_suggest_inline_suppression_default_true() {
287        let config = HealthConfig::default();
288        assert!(config.suggest_inline_suppression);
289    }
290
291    #[test]
292    fn health_config_suggest_inline_suppression_explicit_false() {
293        let json = r#"{"suggestInlineSuppression": false}"#;
294        let config: HealthConfig = serde_json::from_str(json).unwrap();
295        assert!(!config.suggest_inline_suppression);
296    }
297
298    #[test]
299    fn health_config_suggest_inline_suppression_omitted_uses_default() {
300        let config: HealthConfig = serde_json::from_str("{}").unwrap();
301        assert!(config.suggest_inline_suppression);
302    }
303
304    #[test]
305    fn health_config_zero_thresholds() {
306        let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
307        let config: HealthConfig = serde_json::from_str(json).unwrap();
308        assert_eq!(config.max_cyclomatic, 0);
309        assert_eq!(config.max_cognitive, 0);
310    }
311
312    #[test]
313    fn health_config_large_thresholds() {
314        let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
315        let config: HealthConfig = serde_json::from_str(json).unwrap();
316        assert_eq!(config.max_cyclomatic, u16::MAX);
317        assert_eq!(config.max_cognitive, u16::MAX);
318    }
319
320    #[test]
321    fn ownership_config_default_has_bot_patterns() {
322        let cfg = OwnershipConfig::default();
323        assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
324        assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
325        assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
326        assert!(
327            !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
328            "*noreply* must not be a default bot pattern (filters real human \
329             contributors using GitHub's privacy default email)"
330        );
331        assert_eq!(cfg.email_mode, EmailMode::Handle);
332    }
333
334    #[test]
335    fn ownership_config_default_via_health() {
336        let cfg = HealthConfig::default();
337        assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
338        assert!(!cfg.ownership.bot_patterns.is_empty());
339    }
340
341    #[test]
342    fn ownership_config_json_overrides_defaults() {
343        let json = r#"{
344            "ownership": {
345                "botPatterns": ["custom-bot*"],
346                "emailMode": "raw"
347            }
348        }"#;
349        let config: HealthConfig = serde_json::from_str(json).unwrap();
350        assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
351        assert_eq!(config.ownership.email_mode, EmailMode::Raw);
352    }
353
354    #[test]
355    fn ownership_config_email_mode_kebab_case() {
356        for (mode, repr) in [
357            (EmailMode::Raw, "\"raw\""),
358            (EmailMode::Handle, "\"handle\""),
359            (EmailMode::Anonymized, "\"anonymized\""),
360            (EmailMode::Hash, "\"hash\""),
361        ] {
362            let s = serde_json::to_string(&mode).unwrap();
363            assert_eq!(s, repr);
364            let back: EmailMode = serde_json::from_str(repr).unwrap();
365            assert_eq!(back, mode);
366        }
367    }
368
369    #[test]
370    fn ownership_config_email_mode_accepts_legacy_hash_alias() {
371        let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
372        assert_eq!(back, EmailMode::Hash);
373    }
374}