Skip to main content

exspec_core/
config.rs

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    // --- TC-01: custom_patterns populated from toml ---
343    #[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    // --- TC-02: missing [assertions] section -> empty vec ---
354    #[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    // --- TC-03: ExspecConfig -> Config preserves custom_assertion_patterns ---
362    #[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    // --- TC: ignore_patterns propagated from ExspecConfig ---
382    #[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    // --- #59: OutputConfig parsing ---
398
399    #[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    // --- #60: Per-rule severity override ---
445
446    #[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        // T004 from disable, T107 from severity "off"
529        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}