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    #[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    // --- TC-01: custom_patterns populated from toml ---
359    #[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    // --- TC-02: missing [assertions] section -> empty vec ---
370    #[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    // --- TC-03: ExspecConfig -> Config preserves custom_assertion_patterns ---
378    #[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    // --- TC: ignore_patterns propagated from ExspecConfig ---
398    #[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    // --- #59: OutputConfig parsing ---
414
415    #[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    // --- #60: Per-rule severity override ---
461
462    #[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        // T004 from disable, T107 from severity "off"
545        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}