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