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