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 = defaults.disabled_rules.clone();
79 let mut severity_overrides = HashMap::new();
80
81 for rule_id in &ec.rules.disable {
82 if !disabled_rules.iter().any(|r| r.0 == *rule_id) {
83 disabled_rules.push(RuleId::new(rule_id));
84 }
85 }
86
87 for (rule_id, severity_str) in &ec.rules.severity {
88 if !KNOWN_RULE_IDS.contains(&rule_id.as_str()) {
89 eprintln!("warning: unknown rule '{rule_id}' in [rules.severity] config");
90 continue;
91 }
92
93 if severity_str.eq_ignore_ascii_case("off") {
94 if !disabled_rules.iter().any(|r| r.0 == *rule_id) {
95 disabled_rules.push(RuleId::new(rule_id));
96 }
97 } else {
98 match Severity::from_str(severity_str) {
99 Ok(sev) => {
100 disabled_rules.retain(|r| r.0 != *rule_id);
101 severity_overrides.insert(rule_id.clone(), sev);
102 }
103 Err(_) => {
104 eprintln!(
105 "warning: invalid severity '{severity_str}' for rule {rule_id}, skipping"
106 );
107 }
108 }
109 }
110 }
111
112 Config {
113 mock_max: ec.thresholds.mock_max.unwrap_or(defaults.mock_max),
114 mock_class_max: ec
115 .thresholds
116 .mock_class_max
117 .unwrap_or(defaults.mock_class_max),
118 test_max_lines: ec
119 .thresholds
120 .test_max_lines
121 .unwrap_or(defaults.test_max_lines),
122 parameterized_min_ratio: ec
123 .thresholds
124 .parameterized_min_ratio
125 .filter(|v| v.is_finite())
126 .unwrap_or(defaults.parameterized_min_ratio)
127 .clamp(0.0, 1.0),
128 fixture_max: ec.thresholds.fixture_max.unwrap_or(defaults.fixture_max),
129 min_assertions_for_t105: ec
130 .thresholds
131 .min_assertions_for_t105
132 .unwrap_or(defaults.min_assertions_for_t105),
133 min_duplicate_count: ec
134 .thresholds
135 .min_duplicate_count
136 .unwrap_or(defaults.min_duplicate_count),
137 disabled_rules,
138 custom_assertion_patterns: ec.assertions.custom_patterns,
139 ignore_patterns: ec.paths.ignore,
140 min_severity: ec
141 .output
142 .min_severity
143 .as_deref()
144 .map(|s| {
145 Severity::from_str(s).unwrap_or_else(|_| {
146 eprintln!("warning: invalid min_severity '{s}', using default");
147 defaults.min_severity
148 })
149 })
150 .unwrap_or(defaults.min_severity),
151 severity_overrides,
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn count_disabled(config: &Config, rule_id: &str) -> usize {
161 config
162 .disabled_rules
163 .iter()
164 .filter(|r| r.0 == rule_id)
165 .count()
166 }
167
168 fn fixture(name: &str) -> String {
169 let path = format!(
170 "{}/tests/fixtures/config/{}",
171 env!("CARGO_MANIFEST_DIR").replace("/crates/core", ""),
172 name,
173 );
174 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
175 }
176
177 #[test]
178 fn parse_valid_config() {
179 let content = fixture("valid.toml");
180 let ec = ExspecConfig::from_toml(&content).unwrap();
181 assert_eq!(ec.general.lang, vec!["python", "typescript"]);
182 assert_eq!(ec.rules.disable, vec!["T004", "T005"]);
183 assert_eq!(ec.thresholds.mock_max, Some(10));
184 assert_eq!(ec.thresholds.mock_class_max, Some(5));
185 assert_eq!(ec.thresholds.test_max_lines, Some(100));
186 assert_eq!(ec.thresholds.parameterized_min_ratio, Some(0.2));
187 assert_eq!(ec.thresholds.fixture_max, Some(10));
188 assert_eq!(ec.thresholds.min_assertions_for_t105, Some(8));
189 assert_eq!(ec.thresholds.min_duplicate_count, Some(4));
190 assert_eq!(ec.paths.test_patterns, vec!["tests/**", "**/*_test.*"]);
191 assert_eq!(ec.paths.ignore, vec!["node_modules", ".venv"]);
192 }
193
194 #[test]
195 fn parse_partial_config() {
196 let content = fixture("partial.toml");
197 let ec = ExspecConfig::from_toml(&content).unwrap();
198 assert_eq!(ec.thresholds.mock_max, Some(8));
199 assert_eq!(ec.thresholds.mock_class_max, None);
200 assert!(ec.rules.disable.is_empty());
201 }
202
203 #[test]
204 fn parse_empty_config() {
205 let content = fixture("empty.toml");
206 let ec = ExspecConfig::from_toml(&content).unwrap();
207 assert!(ec.general.lang.is_empty());
208 assert!(ec.rules.disable.is_empty());
209 assert_eq!(ec.thresholds.mock_max, None);
210 }
211
212 #[test]
213 fn parse_invalid_config_returns_error() {
214 let content = fixture("invalid.toml");
215 let result = ExspecConfig::from_toml(&content);
216 assert!(result.is_err());
217 }
218
219 #[test]
220 fn convert_full_config_to_rules_config() {
221 let content = fixture("valid.toml");
222 let ec = ExspecConfig::from_toml(&content).unwrap();
223 let config: Config = ec.into();
224 assert_eq!(config.mock_max, 10);
225 assert_eq!(config.mock_class_max, 5);
226 assert_eq!(config.test_max_lines, 100);
227 assert_eq!(config.parameterized_min_ratio, 0.2);
228 assert_eq!(config.fixture_max, 10);
229 assert_eq!(config.min_assertions_for_t105, 8);
230 assert_eq!(config.min_duplicate_count, 4);
231 assert_eq!(config.disabled_rules.len(), 3);
232 assert!(config.disabled_rules.iter().any(|r| r.0 == "T106"));
233 assert!(config.disabled_rules.iter().any(|r| r.0 == "T004"));
234 assert!(config.disabled_rules.iter().any(|r| r.0 == "T005"));
235 }
236
237 #[test]
238 fn convert_partial_config_uses_defaults() {
239 let content = fixture("partial.toml");
240 let ec = ExspecConfig::from_toml(&content).unwrap();
241 let config: Config = ec.into();
242 let defaults = Config::default();
243 assert_eq!(config.mock_max, 8);
244 assert_eq!(config.mock_class_max, defaults.mock_class_max);
245 assert_eq!(config.test_max_lines, defaults.test_max_lines);
246 assert_eq!(
247 config.parameterized_min_ratio,
248 defaults.parameterized_min_ratio
249 );
250 assert_eq!(config.disabled_rules.len(), defaults.disabled_rules.len());
251 assert!(config.disabled_rules.iter().any(|r| r.0 == "T106"));
252 }
253
254 #[test]
255 fn convert_negative_ratio_clamped_to_zero() {
256 let ec = ExspecConfig {
257 thresholds: ThresholdsConfig {
258 parameterized_min_ratio: Some(-0.5),
259 ..Default::default()
260 },
261 ..Default::default()
262 };
263 let config: Config = ec.into();
264 assert_eq!(config.parameterized_min_ratio, 0.0);
265 }
266
267 #[test]
268 fn convert_zero_ratio_stays_zero() {
269 let ec = ExspecConfig {
270 thresholds: ThresholdsConfig {
271 parameterized_min_ratio: Some(0.0),
272 ..Default::default()
273 },
274 ..Default::default()
275 };
276 let config: Config = ec.into();
277 assert_eq!(config.parameterized_min_ratio, 0.0);
278 }
279
280 #[test]
281 fn convert_positive_ratio_unchanged() {
282 let ec = ExspecConfig {
283 thresholds: ThresholdsConfig {
284 parameterized_min_ratio: Some(0.3),
285 ..Default::default()
286 },
287 ..Default::default()
288 };
289 let config: Config = ec.into();
290 assert_eq!(config.parameterized_min_ratio, 0.3);
291 }
292
293 #[test]
294 fn convert_ratio_above_one_clamped_to_one() {
295 let ec = ExspecConfig {
296 thresholds: ThresholdsConfig {
297 parameterized_min_ratio: Some(1.5),
298 ..Default::default()
299 },
300 ..Default::default()
301 };
302 let config: Config = ec.into();
303 assert_eq!(config.parameterized_min_ratio, 1.0);
304 }
305
306 #[test]
307 fn convert_nan_ratio_falls_back_to_default() {
308 let content = fixture("nan_ratio.toml");
309 let ec = ExspecConfig::from_toml(&content).unwrap();
310 let config: Config = ec.into();
311 let defaults = Config::default();
312 assert_eq!(
313 config.parameterized_min_ratio, defaults.parameterized_min_ratio,
314 "NaN should fall back to default"
315 );
316 }
317
318 #[test]
319 fn convert_inf_ratio_falls_back_to_default() {
320 let content = fixture("inf_ratio.toml");
321 let ec = ExspecConfig::from_toml(&content).unwrap();
322 let config: Config = ec.into();
323 let defaults = Config::default();
324 assert_eq!(
325 config.parameterized_min_ratio, defaults.parameterized_min_ratio,
326 "Inf should fall back to default"
327 );
328 }
329
330 #[test]
331 fn convert_neg_inf_ratio_falls_back_to_default() {
332 let content = fixture("neg_inf_ratio.toml");
333 let ec = ExspecConfig::from_toml(&content).unwrap();
334 let config: Config = ec.into();
335 let defaults = Config::default();
336 assert_eq!(
337 config.parameterized_min_ratio, defaults.parameterized_min_ratio,
338 "-Inf should fall back to default"
339 );
340 }
341
342 #[test]
344 fn parse_custom_assertions_config() {
345 let content = fixture("custom_assertions.toml");
346 let ec = ExspecConfig::from_toml(&content).unwrap();
347 assert_eq!(
348 ec.assertions.custom_patterns,
349 vec!["util.assertEqual(", "myAssert(", "customCheck("]
350 );
351 }
352
353 #[test]
355 fn parse_config_without_assertions_section() {
356 let content = fixture("valid.toml");
357 let ec = ExspecConfig::from_toml(&content).unwrap();
358 assert!(ec.assertions.custom_patterns.is_empty());
359 }
360
361 #[test]
363 fn convert_config_preserves_custom_assertion_patterns() {
364 let ec = ExspecConfig {
365 assertions: AssertionsConfig {
366 custom_patterns: vec!["myAssert(".to_string()],
367 },
368 ..Default::default()
369 };
370 let config: Config = ec.into();
371 assert_eq!(config.custom_assertion_patterns, vec!["myAssert("]);
372 }
373
374 #[test]
375 fn convert_config_empty_assertions_gives_empty_patterns() {
376 let ec = ExspecConfig::default();
377 let config: Config = ec.into();
378 assert!(config.custom_assertion_patterns.is_empty());
379 }
380
381 #[test]
383 fn convert_config_propagates_ignore_patterns() {
384 let content = fixture("valid.toml");
385 let ec = ExspecConfig::from_toml(&content).unwrap();
386 let config: Config = ec.into();
387 assert_eq!(config.ignore_patterns, vec!["node_modules", ".venv"]);
388 }
389
390 #[test]
391 fn convert_config_empty_ignore_gives_empty_patterns() {
392 let ec = ExspecConfig::default();
393 let config: Config = ec.into();
394 assert!(config.ignore_patterns.is_empty());
395 }
396
397 #[test]
400 fn parse_output_min_severity() {
401 let content = fixture("min_severity.toml");
402 let ec = ExspecConfig::from_toml(&content).unwrap();
403 assert_eq!(ec.output.min_severity, Some("warn".to_string()));
404 }
405
406 #[test]
407 fn parse_config_without_output_section() {
408 let content = fixture("empty.toml");
409 let ec = ExspecConfig::from_toml(&content).unwrap();
410 assert_eq!(ec.output.min_severity, None);
411 }
412
413 #[test]
414 fn convert_output_min_severity_block() {
415 let ec = ExspecConfig {
416 output: OutputConfig {
417 min_severity: Some("BLOCK".to_string()),
418 },
419 ..Default::default()
420 };
421 let config: Config = ec.into();
422 assert_eq!(config.min_severity, Severity::Block);
423 }
424
425 #[test]
426 fn convert_no_min_severity_defaults_to_info() {
427 let ec = ExspecConfig::default();
428 let config: Config = ec.into();
429 assert_eq!(config.min_severity, Severity::Info);
430 }
431
432 #[test]
433 fn convert_invalid_min_severity_string_falls_back_to_info() {
434 let ec = ExspecConfig {
435 output: OutputConfig {
436 min_severity: Some("BLOKC".to_string()),
437 },
438 ..Default::default()
439 };
440 let config: Config = ec.into();
441 assert_eq!(config.min_severity, Severity::Info);
442 }
443
444 #[test]
447 fn parse_severity_override_toml() {
448 let content = fixture("severity_override.toml");
449 let ec = ExspecConfig::from_toml(&content).unwrap();
450 assert_eq!(ec.rules.severity.get("T107").unwrap(), "off");
451 assert_eq!(ec.rules.severity.get("T101").unwrap(), "info");
452 }
453
454 #[test]
455 fn convert_severity_off_adds_to_disabled_rules() {
456 let mut severity = std::collections::HashMap::new();
457 severity.insert("T107".to_string(), "off".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!(config.disabled_rules.iter().any(|r| r.0 == "T107"));
467 assert!(!config.severity_overrides.contains_key("T107"));
468 }
469
470 #[test]
471 fn convert_severity_valid_adds_to_overrides() {
472 let mut severity = std::collections::HashMap::new();
473 severity.insert("T101".to_string(), "info".to_string());
474 let ec = ExspecConfig {
475 rules: RulesConfig {
476 severity,
477 ..Default::default()
478 },
479 ..Default::default()
480 };
481 let config: Config = ec.into();
482 assert_eq!(config.severity_overrides.get("T101"), Some(&Severity::Info));
483 }
484
485 #[test]
486 fn convert_empty_config_inherits_default_disabled_rules() {
487 let ec = ExspecConfig::default();
488 let config: Config = ec.into();
489 assert!(config.disabled_rules.iter().any(|r| r.0 == "T106"));
490 }
491
492 #[test]
493 fn convert_severity_reenables_default_disabled_rule() {
494 let mut severity = std::collections::HashMap::new();
495 severity.insert("T106".to_string(), "info".to_string());
496 let ec = ExspecConfig {
497 rules: RulesConfig {
498 severity,
499 ..Default::default()
500 },
501 ..Default::default()
502 };
503 let config: Config = ec.into();
504 assert!(!config.disabled_rules.iter().any(|r| r.0 == "T106"));
505 assert_eq!(config.severity_overrides.get("T106"), Some(&Severity::Info));
506 }
507
508 #[test]
509 fn convert_severity_invalid_string_skipped() {
510 let mut severity = std::collections::HashMap::new();
511 severity.insert("T001".to_string(), "blokc".to_string());
512 let ec = ExspecConfig {
513 rules: RulesConfig {
514 severity,
515 ..Default::default()
516 },
517 ..Default::default()
518 };
519 let config: Config = ec.into();
520 assert!(!config.severity_overrides.contains_key("T001"));
521 }
522
523 #[test]
524 fn convert_severity_backward_compat_disable_and_off() {
525 let content = fixture("severity_override.toml");
526 let ec = ExspecConfig::from_toml(&content).unwrap();
527 let config: Config = ec.into();
528 assert!(config.disabled_rules.iter().any(|r| r.0 == "T004"));
530 assert!(config.disabled_rules.iter().any(|r| r.0 == "T107"));
531 }
532
533 #[test]
534 fn convert_severity_dedup_disable_and_off() {
535 let mut severity = std::collections::HashMap::new();
536 severity.insert("T107".to_string(), "off".to_string());
537 let ec = ExspecConfig {
538 rules: RulesConfig {
539 disable: vec!["T107".to_string()],
540 severity,
541 },
542 ..Default::default()
543 };
544 let config: Config = ec.into();
545 assert_eq!(count_disabled(&config, "T107"), 1);
546 }
547
548 #[test]
549 fn convert_default_disabled_rule_dedup_with_disable() {
550 let ec = ExspecConfig {
551 rules: RulesConfig {
552 disable: vec!["T106".to_string()],
553 ..Default::default()
554 },
555 ..Default::default()
556 };
557 let config: Config = ec.into();
558 assert_eq!(count_disabled(&config, "T106"), 1);
559 }
560
561 #[test]
562 fn convert_default_disabled_rule_dedup_with_severity_off() {
563 let mut severity = std::collections::HashMap::new();
564 severity.insert("T106".to_string(), "off".to_string());
565 let ec = ExspecConfig {
566 rules: RulesConfig {
567 severity,
568 ..Default::default()
569 },
570 ..Default::default()
571 };
572 let config: Config = ec.into();
573 assert_eq!(count_disabled(&config, "T106"), 1);
574 }
575
576 #[test]
577 fn convert_disable_then_severity_info_reenables_rule() {
578 let mut severity = std::collections::HashMap::new();
579 severity.insert("T106".to_string(), "info".to_string());
580 let ec = ExspecConfig {
581 rules: RulesConfig {
582 disable: vec!["T106".to_string()],
583 severity,
584 },
585 ..Default::default()
586 };
587 let config: Config = ec.into();
588 assert!(!config.disabled_rules.iter().any(|r| r.0 == "T106"));
589 assert_eq!(config.severity_overrides.get("T106"), Some(&Severity::Info));
590 }
591
592 #[test]
593 fn convert_default_disable_then_explicit_off_keeps_single_disabled_entry() {
594 let mut severity = std::collections::HashMap::new();
595 severity.insert("T106".to_string(), "off".to_string());
596 let ec = ExspecConfig {
597 rules: RulesConfig {
598 disable: vec!["T106".to_string()],
599 severity,
600 },
601 ..Default::default()
602 };
603 let config: Config = ec.into();
604 assert_eq!(count_disabled(&config, "T106"), 1);
605 assert!(!config.severity_overrides.contains_key("T106"));
606 }
607
608 #[test]
609 fn convert_severity_unknown_rule_discarded() {
610 let mut severity = std::collections::HashMap::new();
611 severity.insert("T999".to_string(), "warn".to_string());
612 let ec = ExspecConfig {
613 rules: RulesConfig {
614 severity,
615 ..Default::default()
616 },
617 ..Default::default()
618 };
619 let config: Config = ec.into();
620 assert!(!config.severity_overrides.contains_key("T999"));
621 }
622
623 #[test]
624 fn convert_empty_config_all_defaults() {
625 let content = fixture("empty.toml");
626 let ec = ExspecConfig::from_toml(&content).unwrap();
627 let config: Config = ec.into();
628 let defaults = Config::default();
629 assert_eq!(config.mock_max, defaults.mock_max);
630 assert_eq!(config.mock_class_max, defaults.mock_class_max);
631 assert_eq!(config.test_max_lines, defaults.test_max_lines);
632 assert_eq!(
633 config.parameterized_min_ratio,
634 defaults.parameterized_min_ratio
635 );
636 assert_eq!(config.disabled_rules.len(), defaults.disabled_rules.len());
637 assert!(config.disabled_rules.iter().any(|r| r.0 == "T106"));
638 }
639}