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    Hash,
74}
75
76/// Configuration for ownership analysis (`fallow health --hotspots --ownership`).
77#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
78#[serde(rename_all = "camelCase")]
79pub struct OwnershipConfig {
80    /// Glob patterns (matched against the author email local-part) that
81    /// identify bot or service-account commits to exclude from ownership
82    /// signals. Overrides the defaults entirely when set.
83    #[serde(default = "default_bot_patterns")]
84    pub bot_patterns: Vec<String>,
85
86    /// Privacy mode for emitted author emails. Defaults to `handle`.
87    /// Override on the CLI via `--ownership-emails=raw|handle|hash`.
88    #[serde(default = "default_email_mode")]
89    pub email_mode: EmailMode,
90}
91
92impl Default for OwnershipConfig {
93    fn default() -> Self {
94        Self {
95            bot_patterns: default_bot_patterns(),
96            email_mode: default_email_mode(),
97        }
98    }
99}
100
101/// Configuration for complexity health metrics (`fallow health`).
102#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
103#[serde(rename_all = "camelCase")]
104pub struct HealthConfig {
105    /// Maximum allowed cyclomatic complexity per function (default: 20).
106    /// Functions exceeding this threshold are reported.
107    #[serde(default = "default_max_cyclomatic")]
108    pub max_cyclomatic: u16,
109
110    /// Maximum allowed cognitive complexity per function (default: 15).
111    /// Functions exceeding this threshold are reported.
112    #[serde(default = "default_max_cognitive")]
113    pub max_cognitive: u16,
114
115    /// Maximum allowed CRAP (Change Risk Anti-Patterns) score per function
116    /// (default: 30.0). CRAP combines cyclomatic complexity with test
117    /// coverage: high complexity plus low coverage produces a high CRAP
118    /// score. Functions meeting or exceeding this threshold are reported.
119    /// Use `--coverage` with Istanbul data for accurate per-function CRAP;
120    /// otherwise fallow estimates coverage from the module graph.
121    #[serde(default = "default_max_crap")]
122    pub max_crap: f64,
123
124    /// Glob patterns to exclude from complexity analysis.
125    #[serde(default)]
126    pub ignore: Vec<String>,
127
128    /// Ownership analysis configuration. Controls bot filtering and email
129    /// privacy mode for `--ownership` output.
130    #[serde(default)]
131    pub ownership: OwnershipConfig,
132
133    /// Whether health JSON output emits `suppress-line` action hints
134    /// alongside complexity findings (default: `true`). Set to `false` to
135    /// opt out across the project: useful for teams that manage suppressions
136    /// exclusively through `// fallow-ignore-*` comments authored by hand or
137    /// through the `fallow.suppress` LSP code action, but who do not want
138    /// CI-driven `suppress-line` action hints in their JSON output.
139    /// `--baseline` activates auto-omission regardless of this setting,
140    /// since baseline files are a separate suppression mechanism.
141    #[serde(default = "default_suggest_inline_suppression")]
142    pub suggest_inline_suppression: bool,
143}
144
145impl Default for HealthConfig {
146    fn default() -> Self {
147        Self {
148            max_cyclomatic: default_max_cyclomatic(),
149            max_cognitive: default_max_cognitive(),
150            max_crap: default_max_crap(),
151            ignore: vec![],
152            ownership: OwnershipConfig::default(),
153            suggest_inline_suppression: default_suggest_inline_suppression(),
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn health_config_defaults() {
164        let config = HealthConfig::default();
165        assert_eq!(config.max_cyclomatic, 20);
166        assert_eq!(config.max_cognitive, 15);
167        assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
168        assert!(config.ignore.is_empty());
169    }
170
171    #[test]
172    fn health_config_json_all_fields() {
173        let json = r#"{
174            "maxCyclomatic": 30,
175            "maxCognitive": 25,
176            "maxCrap": 50.0,
177            "ignore": ["**/generated/**", "vendor/**"]
178        }"#;
179        let config: HealthConfig = serde_json::from_str(json).unwrap();
180        assert_eq!(config.max_cyclomatic, 30);
181        assert_eq!(config.max_cognitive, 25);
182        assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
183        assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
184    }
185
186    #[test]
187    fn health_config_json_partial_uses_defaults() {
188        let json = r#"{"maxCyclomatic": 10}"#;
189        let config: HealthConfig = serde_json::from_str(json).unwrap();
190        assert_eq!(config.max_cyclomatic, 10);
191        assert_eq!(config.max_cognitive, 15); // default
192        assert!((config.max_crap - 30.0).abs() < f64::EPSILON); // default
193        assert!(config.ignore.is_empty()); // default
194    }
195
196    #[test]
197    fn health_config_json_only_max_crap() {
198        let json = r#"{"maxCrap": 15.5}"#;
199        let config: HealthConfig = serde_json::from_str(json).unwrap();
200        assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
201        assert_eq!(config.max_cyclomatic, 20); // default
202        assert_eq!(config.max_cognitive, 15); // default
203    }
204
205    #[test]
206    fn health_config_json_empty_object_uses_all_defaults() {
207        let config: HealthConfig = serde_json::from_str("{}").unwrap();
208        assert_eq!(config.max_cyclomatic, 20);
209        assert_eq!(config.max_cognitive, 15);
210        assert!(config.ignore.is_empty());
211    }
212
213    #[test]
214    fn health_config_json_only_ignore() {
215        let json = r#"{"ignore": ["test/**"]}"#;
216        let config: HealthConfig = serde_json::from_str(json).unwrap();
217        assert_eq!(config.max_cyclomatic, 20); // default
218        assert_eq!(config.max_cognitive, 15); // default
219        assert_eq!(config.ignore, vec!["test/**"]);
220    }
221
222    // ── TOML deserialization ────────────────────────────────────────
223
224    #[test]
225    fn health_config_toml_all_fields() {
226        let toml_str = r#"
227maxCyclomatic = 25
228maxCognitive = 20
229ignore = ["generated/**", "vendor/**"]
230"#;
231        let config: HealthConfig = toml::from_str(toml_str).unwrap();
232        assert_eq!(config.max_cyclomatic, 25);
233        assert_eq!(config.max_cognitive, 20);
234        assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
235    }
236
237    #[test]
238    fn health_config_toml_defaults() {
239        let config: HealthConfig = toml::from_str("").unwrap();
240        assert_eq!(config.max_cyclomatic, 20);
241        assert_eq!(config.max_cognitive, 15);
242        assert!(config.ignore.is_empty());
243    }
244
245    // ── Serialize roundtrip ─────────────────────────────────────────
246
247    #[test]
248    fn health_config_json_roundtrip() {
249        let config = HealthConfig {
250            max_cyclomatic: 50,
251            max_cognitive: 40,
252            max_crap: 75.0,
253            ignore: vec!["test/**".to_string()],
254            ownership: OwnershipConfig::default(),
255            suggest_inline_suppression: false,
256        };
257        let json = serde_json::to_string(&config).unwrap();
258        let restored: HealthConfig = serde_json::from_str(&json).unwrap();
259        assert_eq!(restored.max_cyclomatic, 50);
260        assert_eq!(restored.max_cognitive, 40);
261        assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
262        assert_eq!(restored.ignore, vec!["test/**"]);
263        assert!(!restored.suggest_inline_suppression);
264    }
265
266    #[test]
267    fn health_config_suggest_inline_suppression_default_true() {
268        let config = HealthConfig::default();
269        assert!(config.suggest_inline_suppression);
270    }
271
272    #[test]
273    fn health_config_suggest_inline_suppression_explicit_false() {
274        let json = r#"{"suggestInlineSuppression": false}"#;
275        let config: HealthConfig = serde_json::from_str(json).unwrap();
276        assert!(!config.suggest_inline_suppression);
277    }
278
279    #[test]
280    fn health_config_suggest_inline_suppression_omitted_uses_default() {
281        let config: HealthConfig = serde_json::from_str("{}").unwrap();
282        assert!(config.suggest_inline_suppression);
283    }
284
285    // ── Zero thresholds ─────────────────────────────────────────────
286
287    #[test]
288    fn health_config_zero_thresholds() {
289        let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
290        let config: HealthConfig = serde_json::from_str(json).unwrap();
291        assert_eq!(config.max_cyclomatic, 0);
292        assert_eq!(config.max_cognitive, 0);
293    }
294
295    // ── Large thresholds ────────────────────────────────────────────
296
297    #[test]
298    fn health_config_large_thresholds() {
299        let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
300        let config: HealthConfig = serde_json::from_str(json).unwrap();
301        assert_eq!(config.max_cyclomatic, u16::MAX);
302        assert_eq!(config.max_cognitive, u16::MAX);
303    }
304
305    // ── OwnershipConfig ─────────────────────────────────────────────
306
307    #[test]
308    fn ownership_config_default_has_bot_patterns() {
309        let cfg = OwnershipConfig::default();
310        // Brackets are escaped because globset treats `[abc]` as a class;
311        // the literal `[bot]` pattern requires escaping.
312        assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
313        assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
314        assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
315        // `*noreply*` is intentionally NOT a default. See `default_bot_patterns`
316        // for why: it would filter out the majority of real GitHub contributors
317        // who commit from `<id>+<handle>@users.noreply.github.com`.
318        assert!(
319            !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
320            "*noreply* must not be a default bot pattern (filters real human \
321             contributors using GitHub's privacy default email)"
322        );
323        assert_eq!(cfg.email_mode, EmailMode::Handle);
324    }
325
326    #[test]
327    fn ownership_config_default_via_health() {
328        let cfg = HealthConfig::default();
329        assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
330        assert!(!cfg.ownership.bot_patterns.is_empty());
331    }
332
333    #[test]
334    fn ownership_config_json_overrides_defaults() {
335        let json = r#"{
336            "ownership": {
337                "botPatterns": ["custom-bot*"],
338                "emailMode": "raw"
339            }
340        }"#;
341        let config: HealthConfig = serde_json::from_str(json).unwrap();
342        assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
343        assert_eq!(config.ownership.email_mode, EmailMode::Raw);
344    }
345
346    #[test]
347    fn ownership_config_email_mode_kebab_case() {
348        // All three EmailMode variants round-trip through their kebab-case JSON form.
349        for (mode, repr) in [
350            (EmailMode::Raw, "\"raw\""),
351            (EmailMode::Handle, "\"handle\""),
352            (EmailMode::Hash, "\"hash\""),
353        ] {
354            let s = serde_json::to_string(&mode).unwrap();
355            assert_eq!(s, repr);
356            let back: EmailMode = serde_json::from_str(repr).unwrap();
357            assert_eq!(back, mode);
358        }
359    }
360}