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
19fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
53#[serde(rename_all = "kebab-case")]
54pub enum EmailMode {
55 Raw,
58 Handle,
61 Hash,
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct OwnershipConfig {
73 #[serde(default = "default_bot_patterns")]
77 pub bot_patterns: Vec<String>,
78
79 #[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#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
96#[serde(rename_all = "camelCase")]
97pub struct HealthConfig {
98 #[serde(default = "default_max_cyclomatic")]
101 pub max_cyclomatic: u16,
102
103 #[serde(default = "default_max_cognitive")]
106 pub max_cognitive: u16,
107
108 #[serde(default = "default_max_crap")]
115 pub max_crap: f64,
116
117 #[serde(default)]
119 pub ignore: Vec<String>,
120
121 #[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); assert!((config.max_crap - 30.0).abs() < f64::EPSILON); assert!(config.ignore.is_empty()); }
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); assert_eq!(config.max_cognitive, 15); }
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); assert_eq!(config.max_cognitive, 15); assert_eq!(config.ignore, vec!["test/**"]);
201 }
202
203 #[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 #[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 #[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 #[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 #[test]
268 fn ownership_config_default_has_bot_patterns() {
269 let cfg = OwnershipConfig::default();
270 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 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 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}