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