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