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_suggest_inline_suppression() -> bool {
23 true
24}
25
26fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
60#[serde(rename_all = "kebab-case")]
61pub enum EmailMode {
62 Raw,
65 Handle,
68 Anonymized,
74 Hash,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
80#[serde(rename_all = "camelCase")]
81pub struct OwnershipConfig {
82 #[serde(default = "default_bot_patterns")]
86 pub bot_patterns: Vec<String>,
87
88 #[serde(default = "default_email_mode")]
92 pub email_mode: EmailMode,
93}
94
95impl Default for OwnershipConfig {
96 fn default() -> Self {
97 Self {
98 bot_patterns: default_bot_patterns(),
99 email_mode: default_email_mode(),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
106#[serde(rename_all = "camelCase")]
107pub struct HealthConfig {
108 #[serde(default = "default_max_cyclomatic")]
111 pub max_cyclomatic: u16,
112
113 #[serde(default = "default_max_cognitive")]
116 pub max_cognitive: u16,
117
118 #[serde(default = "default_max_crap")]
125 pub max_crap: f64,
126
127 #[serde(default)]
129 pub ignore: Vec<String>,
130
131 #[serde(default)]
134 pub ownership: OwnershipConfig,
135
136 #[serde(default = "default_suggest_inline_suppression")]
145 pub suggest_inline_suppression: bool,
146}
147
148impl Default for HealthConfig {
149 fn default() -> Self {
150 Self {
151 max_cyclomatic: default_max_cyclomatic(),
152 max_cognitive: default_max_cognitive(),
153 max_crap: default_max_crap(),
154 ignore: vec![],
155 ownership: OwnershipConfig::default(),
156 suggest_inline_suppression: default_suggest_inline_suppression(),
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn health_config_defaults() {
167 let config = HealthConfig::default();
168 assert_eq!(config.max_cyclomatic, 20);
169 assert_eq!(config.max_cognitive, 15);
170 assert!((config.max_crap - 30.0).abs() < f64::EPSILON);
171 assert!(config.ignore.is_empty());
172 }
173
174 #[test]
175 fn health_config_json_all_fields() {
176 let json = r#"{
177 "maxCyclomatic": 30,
178 "maxCognitive": 25,
179 "maxCrap": 50.0,
180 "ignore": ["**/generated/**", "vendor/**"]
181 }"#;
182 let config: HealthConfig = serde_json::from_str(json).unwrap();
183 assert_eq!(config.max_cyclomatic, 30);
184 assert_eq!(config.max_cognitive, 25);
185 assert!((config.max_crap - 50.0).abs() < f64::EPSILON);
186 assert_eq!(config.ignore, vec!["**/generated/**", "vendor/**"]);
187 }
188
189 #[test]
190 fn health_config_json_partial_uses_defaults() {
191 let json = r#"{"maxCyclomatic": 10}"#;
192 let config: HealthConfig = serde_json::from_str(json).unwrap();
193 assert_eq!(config.max_cyclomatic, 10);
194 assert_eq!(config.max_cognitive, 15); assert!((config.max_crap - 30.0).abs() < f64::EPSILON); assert!(config.ignore.is_empty()); }
198
199 #[test]
200 fn health_config_json_only_max_crap() {
201 let json = r#"{"maxCrap": 15.5}"#;
202 let config: HealthConfig = serde_json::from_str(json).unwrap();
203 assert!((config.max_crap - 15.5).abs() < f64::EPSILON);
204 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); }
207
208 #[test]
209 fn health_config_json_empty_object_uses_all_defaults() {
210 let config: HealthConfig = serde_json::from_str("{}").unwrap();
211 assert_eq!(config.max_cyclomatic, 20);
212 assert_eq!(config.max_cognitive, 15);
213 assert!(config.ignore.is_empty());
214 }
215
216 #[test]
217 fn health_config_json_only_ignore() {
218 let json = r#"{"ignore": ["test/**"]}"#;
219 let config: HealthConfig = serde_json::from_str(json).unwrap();
220 assert_eq!(config.max_cyclomatic, 20); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
223 }
224
225 #[test]
228 fn health_config_toml_all_fields() {
229 let toml_str = r#"
230maxCyclomatic = 25
231maxCognitive = 20
232ignore = ["generated/**", "vendor/**"]
233"#;
234 let config: HealthConfig = toml::from_str(toml_str).unwrap();
235 assert_eq!(config.max_cyclomatic, 25);
236 assert_eq!(config.max_cognitive, 20);
237 assert_eq!(config.ignore, vec!["generated/**", "vendor/**"]);
238 }
239
240 #[test]
241 fn health_config_toml_defaults() {
242 let config: HealthConfig = toml::from_str("").unwrap();
243 assert_eq!(config.max_cyclomatic, 20);
244 assert_eq!(config.max_cognitive, 15);
245 assert!(config.ignore.is_empty());
246 }
247
248 #[test]
251 fn health_config_json_roundtrip() {
252 let config = HealthConfig {
253 max_cyclomatic: 50,
254 max_cognitive: 40,
255 max_crap: 75.0,
256 ignore: vec!["test/**".to_string()],
257 ownership: OwnershipConfig::default(),
258 suggest_inline_suppression: false,
259 };
260 let json = serde_json::to_string(&config).unwrap();
261 let restored: HealthConfig = serde_json::from_str(&json).unwrap();
262 assert_eq!(restored.max_cyclomatic, 50);
263 assert_eq!(restored.max_cognitive, 40);
264 assert!((restored.max_crap - 75.0).abs() < f64::EPSILON);
265 assert_eq!(restored.ignore, vec!["test/**"]);
266 assert!(!restored.suggest_inline_suppression);
267 }
268
269 #[test]
270 fn health_config_suggest_inline_suppression_default_true() {
271 let config = HealthConfig::default();
272 assert!(config.suggest_inline_suppression);
273 }
274
275 #[test]
276 fn health_config_suggest_inline_suppression_explicit_false() {
277 let json = r#"{"suggestInlineSuppression": false}"#;
278 let config: HealthConfig = serde_json::from_str(json).unwrap();
279 assert!(!config.suggest_inline_suppression);
280 }
281
282 #[test]
283 fn health_config_suggest_inline_suppression_omitted_uses_default() {
284 let config: HealthConfig = serde_json::from_str("{}").unwrap();
285 assert!(config.suggest_inline_suppression);
286 }
287
288 #[test]
291 fn health_config_zero_thresholds() {
292 let json = r#"{"maxCyclomatic": 0, "maxCognitive": 0}"#;
293 let config: HealthConfig = serde_json::from_str(json).unwrap();
294 assert_eq!(config.max_cyclomatic, 0);
295 assert_eq!(config.max_cognitive, 0);
296 }
297
298 #[test]
301 fn health_config_large_thresholds() {
302 let json = r#"{"maxCyclomatic": 65535, "maxCognitive": 65535}"#;
303 let config: HealthConfig = serde_json::from_str(json).unwrap();
304 assert_eq!(config.max_cyclomatic, u16::MAX);
305 assert_eq!(config.max_cognitive, u16::MAX);
306 }
307
308 #[test]
311 fn ownership_config_default_has_bot_patterns() {
312 let cfg = OwnershipConfig::default();
313 assert!(cfg.bot_patterns.iter().any(|p| p == r"*\[bot\]*"));
316 assert!(cfg.bot_patterns.iter().any(|p| p == "dependabot*"));
317 assert!(cfg.bot_patterns.iter().any(|p| p == "github-actions*"));
318 assert!(
322 !cfg.bot_patterns.iter().any(|p| p == "*noreply*"),
323 "*noreply* must not be a default bot pattern (filters real human \
324 contributors using GitHub's privacy default email)"
325 );
326 assert_eq!(cfg.email_mode, EmailMode::Handle);
327 }
328
329 #[test]
330 fn ownership_config_default_via_health() {
331 let cfg = HealthConfig::default();
332 assert_eq!(cfg.ownership.email_mode, EmailMode::Handle);
333 assert!(!cfg.ownership.bot_patterns.is_empty());
334 }
335
336 #[test]
337 fn ownership_config_json_overrides_defaults() {
338 let json = r#"{
339 "ownership": {
340 "botPatterns": ["custom-bot*"],
341 "emailMode": "raw"
342 }
343 }"#;
344 let config: HealthConfig = serde_json::from_str(json).unwrap();
345 assert_eq!(config.ownership.bot_patterns, vec!["custom-bot*"]);
346 assert_eq!(config.ownership.email_mode, EmailMode::Raw);
347 }
348
349 #[test]
350 fn ownership_config_email_mode_kebab_case() {
351 for (mode, repr) in [
353 (EmailMode::Raw, "\"raw\""),
354 (EmailMode::Handle, "\"handle\""),
355 (EmailMode::Anonymized, "\"anonymized\""),
356 (EmailMode::Hash, "\"hash\""),
357 ] {
358 let s = serde_json::to_string(&mode).unwrap();
359 assert_eq!(s, repr);
360 let back: EmailMode = serde_json::from_str(repr).unwrap();
361 assert_eq!(back, mode);
362 }
363 }
364
365 #[test]
366 fn ownership_config_email_mode_accepts_legacy_hash_alias() {
367 let back: EmailMode = serde_json::from_str("\"hash\"").unwrap();
368 assert_eq!(back, EmailMode::Hash);
369 }
370}