1use std::collections::HashMap;
2use std::str::FromStr;
3
4use serde::Deserialize;
5
6use crate::rules::{Config, RuleId, Severity, KNOWN_RULE_IDS};
7
8#[derive(Debug, Deserialize, Default)]
9pub struct ExspecConfig {
10 #[serde(default)]
11 pub general: GeneralConfig,
12 #[serde(default)]
13 pub rules: RulesConfig,
14 #[serde(default)]
15 pub thresholds: ThresholdsConfig,
16 #[serde(default)]
17 pub paths: PathsConfig,
18 #[serde(default)]
19 pub assertions: AssertionsConfig,
20 #[serde(default)]
21 pub output: OutputConfig,
22}
23
24#[derive(Debug, Deserialize, Default)]
25pub struct OutputConfig {
26 pub min_severity: Option<String>,
27}
28
29#[derive(Debug, Deserialize, Default)]
30pub struct AssertionsConfig {
31 #[serde(default)]
32 pub custom_patterns: Vec<String>,
33}
34
35#[derive(Debug, Deserialize, Default)]
36pub struct GeneralConfig {
37 #[serde(default)]
38 pub lang: Vec<String>,
39}
40
41#[derive(Debug, Deserialize, Default)]
42pub struct RulesConfig {
43 #[serde(default)]
44 pub disable: Vec<String>,
45 #[serde(default)]
46 pub severity: HashMap<String, String>,
47}
48
49#[derive(Debug, Deserialize, Default)]
50pub struct ThresholdsConfig {
51 pub mock_max: Option<usize>,
52 pub mock_class_max: Option<usize>,
53 pub test_max_lines: Option<usize>,
54 pub parameterized_min_ratio: Option<f64>,
55 pub fixture_max: Option<usize>,
56 pub min_assertions_for_t105: Option<usize>,
57 pub min_duplicate_count: Option<usize>,
58}
59
60#[derive(Debug, Deserialize, Default)]
61pub struct PathsConfig {
62 #[serde(default)]
63 pub test_patterns: Vec<String>,
64 #[serde(default)]
65 pub ignore: Vec<String>,
66}
67
68impl ExspecConfig {
69 pub fn from_toml(content: &str) -> Result<Self, toml::de::Error> {
70 toml::from_str(content)
71 }
72}
73
74impl From<ExspecConfig> for Config {
75 fn from(ec: ExspecConfig) -> Self {
76 let defaults = Config::default();
77
78 let mut disabled_rules: Vec<RuleId> =
79 ec.rules.disable.iter().map(|s| RuleId::new(s)).collect();
80 let mut severity_overrides = HashMap::new();
81
82 for (rule_id, severity_str) in &ec.rules.severity {
83 if !KNOWN_RULE_IDS.contains(&rule_id.as_str()) {
84 eprintln!("warning: unknown rule '{rule_id}' in [rules.severity] config");
85 continue;
86 }
87
88 if severity_str.eq_ignore_ascii_case("off") {
89 if !disabled_rules.iter().any(|r| r.0 == *rule_id) {
90 disabled_rules.push(RuleId::new(rule_id));
91 }
92 } else {
93 match Severity::from_str(severity_str) {
94 Ok(sev) => {
95 severity_overrides.insert(rule_id.clone(), sev);
96 }
97 Err(_) => {
98 eprintln!(
99 "warning: invalid severity '{severity_str}' for rule {rule_id}, skipping"
100 );
101 }
102 }
103 }
104 }
105
106 Config {
107 mock_max: ec.thresholds.mock_max.unwrap_or(defaults.mock_max),
108 mock_class_max: ec
109 .thresholds
110 .mock_class_max
111 .unwrap_or(defaults.mock_class_max),
112 test_max_lines: ec
113 .thresholds
114 .test_max_lines
115 .unwrap_or(defaults.test_max_lines),
116 parameterized_min_ratio: ec
117 .thresholds
118 .parameterized_min_ratio
119 .filter(|v| v.is_finite())
120 .unwrap_or(defaults.parameterized_min_ratio)
121 .clamp(0.0, 1.0),
122 fixture_max: ec.thresholds.fixture_max.unwrap_or(defaults.fixture_max),
123 min_assertions_for_t105: ec
124 .thresholds
125 .min_assertions_for_t105
126 .unwrap_or(defaults.min_assertions_for_t105),
127 min_duplicate_count: ec
128 .thresholds
129 .min_duplicate_count
130 .unwrap_or(defaults.min_duplicate_count),
131 disabled_rules,
132 custom_assertion_patterns: ec.assertions.custom_patterns,
133 ignore_patterns: ec.paths.ignore,
134 min_severity: ec
135 .output
136 .min_severity
137 .as_deref()
138 .map(|s| {
139 Severity::from_str(s).unwrap_or_else(|_| {
140 eprintln!("warning: invalid min_severity '{s}', using default");
141 defaults.min_severity
142 })
143 })
144 .unwrap_or(defaults.min_severity),
145 severity_overrides,
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn fixture(name: &str) -> String {
155 let path = format!(
156 "{}/tests/fixtures/config/{}",
157 env!("CARGO_MANIFEST_DIR").replace("/crates/core", ""),
158 name,
159 );
160 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
161 }
162
163 #[test]
164 fn parse_valid_config() {
165 let content = fixture("valid.toml");
166 let ec = ExspecConfig::from_toml(&content).unwrap();
167 assert_eq!(ec.general.lang, vec!["python", "typescript"]);
168 assert_eq!(ec.rules.disable, vec!["T004", "T005"]);
169 assert_eq!(ec.thresholds.mock_max, Some(10));
170 assert_eq!(ec.thresholds.mock_class_max, Some(5));
171 assert_eq!(ec.thresholds.test_max_lines, Some(100));
172 assert_eq!(ec.thresholds.parameterized_min_ratio, Some(0.2));
173 assert_eq!(ec.thresholds.fixture_max, Some(10));
174 assert_eq!(ec.thresholds.min_assertions_for_t105, Some(8));
175 assert_eq!(ec.thresholds.min_duplicate_count, Some(4));
176 assert_eq!(ec.paths.test_patterns, vec!["tests/**", "**/*_test.*"]);
177 assert_eq!(ec.paths.ignore, vec!["node_modules", ".venv"]);
178 }
179
180 #[test]
181 fn parse_partial_config() {
182 let content = fixture("partial.toml");
183 let ec = ExspecConfig::from_toml(&content).unwrap();
184 assert_eq!(ec.thresholds.mock_max, Some(8));
185 assert_eq!(ec.thresholds.mock_class_max, None);
186 assert!(ec.rules.disable.is_empty());
187 }
188
189 #[test]
190 fn parse_empty_config() {
191 let content = fixture("empty.toml");
192 let ec = ExspecConfig::from_toml(&content).unwrap();
193 assert!(ec.general.lang.is_empty());
194 assert!(ec.rules.disable.is_empty());
195 assert_eq!(ec.thresholds.mock_max, None);
196 }
197
198 #[test]
199 fn parse_invalid_config_returns_error() {
200 let content = fixture("invalid.toml");
201 let result = ExspecConfig::from_toml(&content);
202 assert!(result.is_err());
203 }
204
205 #[test]
206 fn convert_full_config_to_rules_config() {
207 let content = fixture("valid.toml");
208 let ec = ExspecConfig::from_toml(&content).unwrap();
209 let config: Config = ec.into();
210 assert_eq!(config.mock_max, 10);
211 assert_eq!(config.mock_class_max, 5);
212 assert_eq!(config.test_max_lines, 100);
213 assert_eq!(config.parameterized_min_ratio, 0.2);
214 assert_eq!(config.fixture_max, 10);
215 assert_eq!(config.min_assertions_for_t105, 8);
216 assert_eq!(config.min_duplicate_count, 4);
217 assert_eq!(config.disabled_rules.len(), 2);
218 assert_eq!(config.disabled_rules[0].0, "T004");
219 assert_eq!(config.disabled_rules[1].0, "T005");
220 }
221
222 #[test]
223 fn convert_partial_config_uses_defaults() {
224 let content = fixture("partial.toml");
225 let ec = ExspecConfig::from_toml(&content).unwrap();
226 let config: Config = ec.into();
227 let defaults = Config::default();
228 assert_eq!(config.mock_max, 8);
229 assert_eq!(config.mock_class_max, defaults.mock_class_max);
230 assert_eq!(config.test_max_lines, defaults.test_max_lines);
231 assert_eq!(
232 config.parameterized_min_ratio,
233 defaults.parameterized_min_ratio
234 );
235 assert!(config.disabled_rules.is_empty());
236 }
237
238 #[test]
239 fn convert_negative_ratio_clamped_to_zero() {
240 let ec = ExspecConfig {
241 thresholds: ThresholdsConfig {
242 parameterized_min_ratio: Some(-0.5),
243 ..Default::default()
244 },
245 ..Default::default()
246 };
247 let config: Config = ec.into();
248 assert_eq!(config.parameterized_min_ratio, 0.0);
249 }
250
251 #[test]
252 fn convert_zero_ratio_stays_zero() {
253 let ec = ExspecConfig {
254 thresholds: ThresholdsConfig {
255 parameterized_min_ratio: Some(0.0),
256 ..Default::default()
257 },
258 ..Default::default()
259 };
260 let config: Config = ec.into();
261 assert_eq!(config.parameterized_min_ratio, 0.0);
262 }
263
264 #[test]
265 fn convert_positive_ratio_unchanged() {
266 let ec = ExspecConfig {
267 thresholds: ThresholdsConfig {
268 parameterized_min_ratio: Some(0.3),
269 ..Default::default()
270 },
271 ..Default::default()
272 };
273 let config: Config = ec.into();
274 assert_eq!(config.parameterized_min_ratio, 0.3);
275 }
276
277 #[test]
278 fn convert_ratio_above_one_clamped_to_one() {
279 let ec = ExspecConfig {
280 thresholds: ThresholdsConfig {
281 parameterized_min_ratio: Some(1.5),
282 ..Default::default()
283 },
284 ..Default::default()
285 };
286 let config: Config = ec.into();
287 assert_eq!(config.parameterized_min_ratio, 1.0);
288 }
289
290 #[test]
291 fn convert_nan_ratio_falls_back_to_default() {
292 let content = fixture("nan_ratio.toml");
293 let ec = ExspecConfig::from_toml(&content).unwrap();
294 let config: Config = ec.into();
295 let defaults = Config::default();
296 assert_eq!(
297 config.parameterized_min_ratio, defaults.parameterized_min_ratio,
298 "NaN should fall back to default"
299 );
300 }
301
302 #[test]
303 fn convert_inf_ratio_falls_back_to_default() {
304 let content = fixture("inf_ratio.toml");
305 let ec = ExspecConfig::from_toml(&content).unwrap();
306 let config: Config = ec.into();
307 let defaults = Config::default();
308 assert_eq!(
309 config.parameterized_min_ratio, defaults.parameterized_min_ratio,
310 "Inf should fall back to default"
311 );
312 }
313
314 #[test]
315 fn convert_neg_inf_ratio_falls_back_to_default() {
316 let content = fixture("neg_inf_ratio.toml");
317 let ec = ExspecConfig::from_toml(&content).unwrap();
318 let config: Config = ec.into();
319 let defaults = Config::default();
320 assert_eq!(
321 config.parameterized_min_ratio, defaults.parameterized_min_ratio,
322 "-Inf should fall back to default"
323 );
324 }
325
326 #[test]
328 fn parse_custom_assertions_config() {
329 let content = fixture("custom_assertions.toml");
330 let ec = ExspecConfig::from_toml(&content).unwrap();
331 assert_eq!(
332 ec.assertions.custom_patterns,
333 vec!["util.assertEqual(", "myAssert(", "customCheck("]
334 );
335 }
336
337 #[test]
339 fn parse_config_without_assertions_section() {
340 let content = fixture("valid.toml");
341 let ec = ExspecConfig::from_toml(&content).unwrap();
342 assert!(ec.assertions.custom_patterns.is_empty());
343 }
344
345 #[test]
347 fn convert_config_preserves_custom_assertion_patterns() {
348 let ec = ExspecConfig {
349 assertions: AssertionsConfig {
350 custom_patterns: vec!["myAssert(".to_string()],
351 },
352 ..Default::default()
353 };
354 let config: Config = ec.into();
355 assert_eq!(config.custom_assertion_patterns, vec!["myAssert("]);
356 }
357
358 #[test]
359 fn convert_config_empty_assertions_gives_empty_patterns() {
360 let ec = ExspecConfig::default();
361 let config: Config = ec.into();
362 assert!(config.custom_assertion_patterns.is_empty());
363 }
364
365 #[test]
367 fn convert_config_propagates_ignore_patterns() {
368 let content = fixture("valid.toml");
369 let ec = ExspecConfig::from_toml(&content).unwrap();
370 let config: Config = ec.into();
371 assert_eq!(config.ignore_patterns, vec!["node_modules", ".venv"]);
372 }
373
374 #[test]
375 fn convert_config_empty_ignore_gives_empty_patterns() {
376 let ec = ExspecConfig::default();
377 let config: Config = ec.into();
378 assert!(config.ignore_patterns.is_empty());
379 }
380
381 #[test]
384 fn parse_output_min_severity() {
385 let content = fixture("min_severity.toml");
386 let ec = ExspecConfig::from_toml(&content).unwrap();
387 assert_eq!(ec.output.min_severity, Some("warn".to_string()));
388 }
389
390 #[test]
391 fn parse_config_without_output_section() {
392 let content = fixture("empty.toml");
393 let ec = ExspecConfig::from_toml(&content).unwrap();
394 assert_eq!(ec.output.min_severity, None);
395 }
396
397 #[test]
398 fn convert_output_min_severity_block() {
399 let ec = ExspecConfig {
400 output: OutputConfig {
401 min_severity: Some("BLOCK".to_string()),
402 },
403 ..Default::default()
404 };
405 let config: Config = ec.into();
406 assert_eq!(config.min_severity, Severity::Block);
407 }
408
409 #[test]
410 fn convert_no_min_severity_defaults_to_info() {
411 let ec = ExspecConfig::default();
412 let config: Config = ec.into();
413 assert_eq!(config.min_severity, Severity::Info);
414 }
415
416 #[test]
417 fn convert_invalid_min_severity_string_falls_back_to_info() {
418 let ec = ExspecConfig {
419 output: OutputConfig {
420 min_severity: Some("BLOKC".to_string()),
421 },
422 ..Default::default()
423 };
424 let config: Config = ec.into();
425 assert_eq!(config.min_severity, Severity::Info);
426 }
427
428 #[test]
431 fn parse_severity_override_toml() {
432 let content = fixture("severity_override.toml");
433 let ec = ExspecConfig::from_toml(&content).unwrap();
434 assert_eq!(ec.rules.severity.get("T107").unwrap(), "off");
435 assert_eq!(ec.rules.severity.get("T101").unwrap(), "info");
436 }
437
438 #[test]
439 fn convert_severity_off_adds_to_disabled_rules() {
440 let mut severity = std::collections::HashMap::new();
441 severity.insert("T107".to_string(), "off".to_string());
442 let ec = ExspecConfig {
443 rules: RulesConfig {
444 severity,
445 ..Default::default()
446 },
447 ..Default::default()
448 };
449 let config: Config = ec.into();
450 assert!(config.disabled_rules.iter().any(|r| r.0 == "T107"));
451 assert!(!config.severity_overrides.contains_key("T107"));
452 }
453
454 #[test]
455 fn convert_severity_valid_adds_to_overrides() {
456 let mut severity = std::collections::HashMap::new();
457 severity.insert("T101".to_string(), "info".to_string());
458 let ec = ExspecConfig {
459 rules: RulesConfig {
460 severity,
461 ..Default::default()
462 },
463 ..Default::default()
464 };
465 let config: Config = ec.into();
466 assert_eq!(config.severity_overrides.get("T101"), Some(&Severity::Info));
467 }
468
469 #[test]
470 fn convert_severity_invalid_string_skipped() {
471 let mut severity = std::collections::HashMap::new();
472 severity.insert("T001".to_string(), "blokc".to_string());
473 let ec = ExspecConfig {
474 rules: RulesConfig {
475 severity,
476 ..Default::default()
477 },
478 ..Default::default()
479 };
480 let config: Config = ec.into();
481 assert!(!config.severity_overrides.contains_key("T001"));
482 }
483
484 #[test]
485 fn convert_severity_backward_compat_disable_and_off() {
486 let content = fixture("severity_override.toml");
487 let ec = ExspecConfig::from_toml(&content).unwrap();
488 let config: Config = ec.into();
489 assert!(config.disabled_rules.iter().any(|r| r.0 == "T004"));
491 assert!(config.disabled_rules.iter().any(|r| r.0 == "T107"));
492 }
493
494 #[test]
495 fn convert_severity_dedup_disable_and_off() {
496 let mut severity = std::collections::HashMap::new();
497 severity.insert("T107".to_string(), "off".to_string());
498 let ec = ExspecConfig {
499 rules: RulesConfig {
500 disable: vec!["T107".to_string()],
501 severity,
502 },
503 ..Default::default()
504 };
505 let config: Config = ec.into();
506 let count = config
507 .disabled_rules
508 .iter()
509 .filter(|r| r.0 == "T107")
510 .count();
511 assert_eq!(
512 count, 1,
513 "T107 should appear exactly once in disabled_rules"
514 );
515 }
516
517 #[test]
518 fn convert_severity_unknown_rule_discarded() {
519 let mut severity = std::collections::HashMap::new();
520 severity.insert("T999".to_string(), "warn".to_string());
521 let ec = ExspecConfig {
522 rules: RulesConfig {
523 severity,
524 ..Default::default()
525 },
526 ..Default::default()
527 };
528 let config: Config = ec.into();
529 assert!(!config.severity_overrides.contains_key("T999"));
530 }
531
532 #[test]
533 fn convert_empty_config_all_defaults() {
534 let content = fixture("empty.toml");
535 let ec = ExspecConfig::from_toml(&content).unwrap();
536 let config: Config = ec.into();
537 let defaults = Config::default();
538 assert_eq!(config.mock_max, defaults.mock_max);
539 assert_eq!(config.mock_class_max, defaults.mock_class_max);
540 assert_eq!(config.test_max_lines, defaults.test_max_lines);
541 assert_eq!(
542 config.parameterized_min_ratio,
543 defaults.parameterized_min_ratio
544 );
545 assert!(config.disabled_rules.is_empty());
546 }
547}