1use std::path::PathBuf;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6const fn default_max_cyclomatic() -> u16 {
7 20
8}
9
10const fn default_max_cognitive() -> u16 {
11 15
12}
13
14const fn default_max_crap() -> f64 {
18 30.0
19}
20
21const fn default_crap_refactor_band() -> u16 {
22 5
23}
24
25const fn default_suggest_inline_suppression() -> bool {
29 true
30}
31
32fn default_bot_patterns() -> Vec<String> {
47 vec![
48 r"*\[bot\]*".to_string(),
49 "dependabot*".to_string(),
50 "renovate*".to_string(),
51 "github-actions*".to_string(),
52 "svc-*".to_string(),
53 "*-service-account*".to_string(),
54 ]
55}
56
57const fn default_email_mode() -> EmailMode {
58 EmailMode::Handle
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
66#[serde(rename_all = "kebab-case")]
67pub enum EmailMode {
68 Raw,
71 Handle,
74 Anonymized,
80 Hash,
82}
83
84#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
86#[serde(rename_all = "camelCase")]
87pub struct OwnershipConfig {
88 #[serde(default = "default_bot_patterns")]
92 pub bot_patterns: Vec<String>,
93
94 #[serde(default = "default_email_mode")]
98 pub email_mode: EmailMode,
99}
100
101impl Default for OwnershipConfig {
102 fn default() -> Self {
103 Self {
104 bot_patterns: default_bot_patterns(),
105 email_mode: default_email_mode(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
112#[serde(rename_all = "camelCase")]
113pub struct HealthConfig {
114 #[serde(default = "default_max_cyclomatic")]
117 pub max_cyclomatic: u16,
118
119 #[serde(default = "default_max_cognitive")]
122 pub max_cognitive: u16,
123
124 #[serde(default = "default_max_crap")]
131 pub max_crap: f64,
132
133 #[serde(default = "default_crap_refactor_band")]
138 pub crap_refactor_band: u16,
139
140 #[serde(default)]
145 pub coverage: Option<PathBuf>,
146
147 #[serde(default)]
152 pub coverage_root: Option<PathBuf>,
153
154 #[serde(default)]
156 pub ignore: Vec<String>,
157
158 #[serde(default)]
161 pub ownership: OwnershipConfig,
162
163 #[serde(default = "default_suggest_inline_suppression")]
172 pub suggest_inline_suppression: bool,
173}
174
175impl Default for HealthConfig {
176 fn default() -> Self {
177 Self {
178 max_cyclomatic: default_max_cyclomatic(),
179 max_cognitive: default_max_cognitive(),
180 max_crap: default_max_crap(),
181 crap_refactor_band: default_crap_refactor_band(),
182 coverage: None,
183 coverage_root: None,
184 ignore: vec![],
185 ownership: OwnershipConfig::default(),
186 suggest_inline_suppression: default_suggest_inline_suppression(),
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn health_config_defaults() {
197 let config = HealthConfig::default();
198 assert_eq!(config.max_cyclomatic, 20);
199 assert_eq!(config.max_cognitive, 15);
200 assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
201 assert_eq!(config.crap_refactor_band, 5);
202 assert!(config.coverage.is_none());
203 assert!(config.coverage_root.is_none());
204 assert!(config.ignore.is_empty());
205 }
206
207 #[test]
208 fn health_config_json_all_fields() {
209 let json = r#"{
210 "maxCyclomatic": 30,
211 "maxCognitive": 25,
212 "maxCrap": 50.0,
213 "crapRefactorBand": 3,
214 "coverage": "coverage/coverage-final.json",
215 "coverageRoot": "/ci/workspace",
216 "ignore": ["**/generated/**", "vendor/**"]
217 }"#;
218 let config: HealthConfig = serde_json::from_str(json).unwrap();
219 assert_eq!(config.max_cyclomatic, 30);
220 assert_eq!(config.max_cognitive, 25);
221 assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
222 assert_eq!(config.crap_refactor_band, 3);
223 assert_eq!(
224 config.coverage,
225 Some(PathBuf::from("coverage/coverage-final.json"))
226 );
227 assert_eq!(config.coverage_root, Some(PathBuf::from("/ci/workspace")));
228 assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
229 }
230
231 #[test]
232 fn health_config_json_partial_uses_defaults() {
233 let json = r#"{"maxCyclomatic": 10}"#;
234 let config: HealthConfig = serde_json::from_str(json).unwrap();
235 assert_eq!(config.max_cyclomatic, 10);
236 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()); }
241
242 #[test]
243 fn health_config_json_only_max_crap() {
244 let json = r#"{"maxCrap": 15.5}"#;
245 let config: HealthConfig = serde_json::from_str(json).unwrap();
246 assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
247 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.crap_refactor_band, 5); }
251
252 #[test]
253 fn health_config_json_empty_object_uses_all_defaults() {
254 let config: HealthConfig = serde_json::from_str("{}").unwrap();
255 assert_eq!(config.max_cyclomatic, 20);
256 assert_eq!(config.max_cognitive, 15);
257 assert_eq!(config.crap_refactor_band, 5);
258 assert!(config.ignore.is_empty());
259 }
260
261 #[test]
262 fn health_config_json_only_ignore() {
263 let json = r#"{"ignore": ["test/**"]}"#;
264 let config: HealthConfig = serde_json::from_str(json).unwrap();
265 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
268 }
269
270 #[test]
271 fn health_config_toml_all_fields() {
272 let toml_str = r#"
273maxCyclomatic = 25
274maxCognitive = 20
275ignore = ["generated/**", "vendor/**"]
276"#;
277 let config: HealthConfig = toml::from_str(toml_str).unwrap();
278 assert_eq!(config.max_cyclomatic, 25);
279 assert_eq!(config.max_cognitive, 20);
280 assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
281 }
282
283 #[test]
284 fn health_config_toml_defaults() {
285 let config: HealthConfig = toml::from_str("").unwrap();
286 assert_eq!(config.max_cyclomatic, 20);
287 assert_eq!(config.max_cognitive, 15);
288 assert!(config.ignore.is_empty());
289 }
290
291 #[test]
292 fn health_config_json_roundtrip() {
293 let config = HealthConfig {
294 max_cyclomatic: 50,
295 max_cognitive: 40,
296 max_crap: 75.0,
297 crap_refactor_band: 4,
298 ignore: vec!["test/**".to_string()],
299 coverage: None,
300 coverage_root: None,
301 ownership: OwnershipConfig::default(),
302 suggest_inline_suppression: false,
303 };
304 let json = serde_json::to_string(&config).unwrap();
305 let restored: HealthConfig = serde_json::from_str(&json).unwrap();
306 assert_eq!(restored.max_cyclomatic, 50);
307 assert_eq!(restored.max_cognitive, 40);
308 assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
309 assert_eq!(restored.crap_refactor_band, 4);
310 assert_eq!(restored.ignore, vec!["test/**"]);
311 assert!(!restored.suggest_inline_suppression);
312 }
313
314 #[test]
315 fn health_config_suggest_inline_suppression_default_true() {
316 let config = HealthConfig::default();
317 assert!(config.suggest_inline_suppression);
318 }
319
320 #[test]
321 fn health_config_suggest_inline_suppression_explicit_false() {
322 let json = r#"{"suggestInlineSuppression": false}"#;
323 let config: HealthConfig = serde_json::from_str(json).unwrap();
324 assert!(!config.suggest_inline_suppression);
325 }
326
327 #[test]
328 fn health_config_suggest_inline_suppression_omitted_uses_default() {
329 let config: HealthConfig = serde_json::from_str("{}").unwrap();
330 assert!(config.suggest_inline_suppression);
331 }
332
333 #[test]
334 fn health_config_zero_thresholds() {
335 let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
336 let config: HealthConfig = serde_json::from_str(json).unwrap();
337 assert_eq!(config.max_cyclomatic, 0);
338 assert_eq!(config.max_cognitive, 0);
339 }
340
341 #[test]
342 fn health_config_large_thresholds() {
343 let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
344 let config: HealthConfig = serde_json::from_str(json).unwrap();
345 assert_eq!(config.max_cyclomatic, u16::MAX);
346 assert_eq!(config.max_cognitive, u16::MAX);
347 }
348
349 #[test]
350 fn ownership_config_default_has_bot_patterns() {
351 let cfg = OwnershipConfig::default();
352 assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
353 assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
354 assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
355 assert!(
356 !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
357 "*noreply* must not be a default bot pattern (filters real human \
358 contributors using GitHub's privacy default email)"
359 );
360 assert_eq!(cfg.email_mode, EmailMode::Handle);
361 }
362
363 #[test]
364 fn ownership_config_default_via_health() {
365 let cfg = HealthConfig::default();
366 assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
367 assert!(!cfg.ownership.bot_patterns.is_empty());
368 }
369
370 #[test]
371 fn ownership_config_json_overrides_defaults() {
372 let json = r#"{
373 "ownership": {
374 "botPatterns": ["custom-bot*"],
375 "emailMode": "raw"
376 }
377 }"#;
378 let config: HealthConfig = serde_json::from_str(json).unwrap();
379 assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
380 assert_eq!(config.ownership.email_mode, EmailMode::Raw);
381 }
382
383 #[test]
384 fn ownership_config_email_mode_kebab_case() {
385 for (mode, repr) in [
386 (EmailMode::Raw, "\"raw\""),
387 (EmailMode::Handle, "\"handle\""),
388 (EmailMode::Anonymized, "\"anonymized\""),
389 (EmailMode::Hash, "\"hash\""),
390 ] {
391 let s = serde_json::to_string(&mode).unwrap();
392 assert_eq!(s, repr);
393 let back: EmailMode = serde_json::from_str(repr).unwrap();
394 assert_eq!(back, mode);
395 }
396 }
397
398 #[test]
399 fn ownership_config_email_mode_accepts_legacy_hash_alias() {
400 let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
401 assert_eq!(back, EmailMode::Hash);
402 }
403}