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
12const fn default_max_crap() -> f64 {
16 30.0
17}
18
19const fn default_crap_refactor_band() -> u16 {
20 5
21}
22
23const fn default_suggest_inline_suppression() -> bool {
27 true
28}
29
30fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
64#[serde(rename_all = "kebab-case")]
65pub enum EmailMode {
66 Raw,
69 Handle,
72 Anonymized,
78 Hash,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
84#[serde(rename_all = "camelCase")]
85pub struct OwnershipConfig {
86 #[serde(default = "default_bot_patterns")]
90 pub bot_patterns: Vec<String>,
91
92 #[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#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(rename_all = "camelCase")]
111pub struct HealthConfig {
112 #[serde(default = "default_max_cyclomatic")]
115 pub max_cyclomatic: u16,
116
117 #[serde(default = "default_max_cognitive")]
120 pub max_cognitive: u16,
121
122 #[serde(default = "default_max_crap")]
129 pub max_crap: f64,
130
131 #[serde(default = "default_crap_refactor_band")]
136 pub crap_refactor_band: u16,
137
138 #[serde(default)]
140 pub ignore: Vec<String>,
141
142 #[serde(default)]
145 pub ownership: OwnershipConfig,
146
147 #[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); assert!((config.max_crap - 30.0).abs() < f64::EPSILON); assert_eq!(config.crap_refactor_band, 5); assert!(config.ignore.is_empty()); }
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); assert_eq!(config.max_cognitive, 15); assert_eq!(config.crap_refactor_band, 5); }
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); assert_eq!(config.max_cognitive, 15); 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}