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: 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    // --- TC-01: custom_patterns populated from toml ---
327    #[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    // --- TC-02: missing [assertions] section -> empty vec ---
338    #[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    // --- TC-03: ExspecConfig -> Config preserves custom_assertion_patterns ---
346    #[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    // --- TC: ignore_patterns propagated from ExspecConfig ---
366    #[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    // --- #59: OutputConfig parsing ---
382
383    #[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    // --- #60: Per-rule severity override ---
429
430    #[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        // T004 from disable, T107 from severity "off"
490        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}