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            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    // --- TC-01: custom_patterns populated from toml ---
360    #[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    // --- TC-02: missing [assertions] section -> empty vec ---
371    #[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    // --- TC-03: ExspecConfig -> Config preserves custom_assertion_patterns ---
379    #[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    // --- TC: ignore_patterns propagated from ExspecConfig ---
399    #[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    // --- #59: OutputConfig parsing ---
415
416    #[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    // --- #60: Per-rule severity override ---
462
463    #[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        // T004 from disable, T107 from severity "off"
546        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}