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