Skip to main content

testx/
config.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::Deserialize;
5
6/// Configuration loaded from `testx.toml`.
7#[derive(Debug, Clone, Default, Deserialize)]
8#[serde(default)]
9pub struct Config {
10    /// Override adapter selection (e.g. "python", "rust", "java")
11    pub adapter: Option<String>,
12
13    /// Extra arguments to pass to the test runner
14    pub args: Vec<String>,
15
16    /// Timeout in seconds (0 = no timeout)
17    pub timeout: Option<u64>,
18
19    /// Stop on first failure
20    pub fail_fast: Option<bool>,
21
22    /// Number of retries for failed tests
23    pub retries: Option<u32>,
24
25    /// Run all detected adapters in parallel
26    pub parallel: Option<bool>,
27
28    /// Environment variables to set before running tests
29    pub env: HashMap<String, String>,
30
31    /// Filtering configuration
32    pub filter: Option<FilterConfig>,
33
34    /// Watch mode configuration
35    pub watch: Option<WatchConfig>,
36
37    /// Output configuration
38    pub output: Option<OutputConfig>,
39
40    /// Per-adapter configuration overrides
41    pub adapters: Option<HashMap<String, AdapterConfig>>,
42
43    /// Custom adapter definitions
44    pub custom_adapter: Option<Vec<CustomAdapterConfig>>,
45
46    /// Coverage configuration
47    pub coverage: Option<CoverageConfig>,
48
49    /// History/analytics configuration
50    pub history: Option<HistoryConfig>,
51}
52
53/// Filter configuration section.
54#[derive(Debug, Clone, Default, Deserialize)]
55#[serde(default)]
56pub struct FilterConfig {
57    /// Include pattern (glob or regex)
58    pub include: Option<String>,
59    /// Exclude pattern (glob or regex)
60    pub exclude: Option<String>,
61}
62
63/// Watch mode configuration section.
64#[derive(Debug, Clone, Deserialize)]
65#[serde(default)]
66pub struct WatchConfig {
67    /// Enable watch mode by default
68    pub enabled: bool,
69    /// Clear screen between runs
70    pub clear: bool,
71    /// Debounce time in milliseconds
72    pub debounce_ms: u64,
73    /// Patterns to ignore
74    pub ignore: Vec<String>,
75    /// Poll interval for network filesystems (ms, 0 = use native events)
76    pub poll_ms: Option<u64>,
77}
78
79impl Default for WatchConfig {
80    fn default() -> Self {
81        Self {
82            enabled: false,
83            clear: true,
84            debounce_ms: 300,
85            ignore: vec![
86                "*.pyc".into(),
87                "__pycache__".into(),
88                ".git".into(),
89                "node_modules".into(),
90                "target".into(),
91                ".testx".into(),
92            ],
93            poll_ms: None,
94        }
95    }
96}
97
98/// Output configuration section.
99#[derive(Debug, Clone, Default, Deserialize)]
100#[serde(default)]
101pub struct OutputConfig {
102    /// Default output format
103    pub format: Option<String>,
104    /// Show N slowest tests
105    pub slowest: Option<usize>,
106    /// Verbose mode
107    pub verbose: Option<bool>,
108    /// Color mode: auto, always, never
109    pub colors: Option<String>,
110}
111
112/// Per-adapter configuration overrides.
113#[derive(Debug, Clone, Default, Deserialize)]
114#[serde(default)]
115pub struct AdapterConfig {
116    /// Override runner (e.g., "pytest" vs "unittest")
117    pub runner: Option<String>,
118    /// Extra arguments for this specific adapter
119    pub args: Vec<String>,
120    /// Environment variables specific to this adapter
121    pub env: HashMap<String, String>,
122    /// Timeout override for this adapter
123    pub timeout: Option<u64>,
124}
125
126/// Custom adapter definition.
127#[derive(Debug, Clone, Deserialize)]
128pub struct CustomAdapterConfig {
129    /// Name for the custom adapter
130    pub name: String,
131    /// File whose presence triggers detection
132    pub detect: String,
133    /// Command to run
134    pub command: String,
135    /// Default arguments
136    #[serde(default)]
137    pub args: Vec<String>,
138    /// Output parser: "json", "junit", "tap", "lines"
139    #[serde(default = "default_parser")]
140    pub parse: String,
141    /// Detection confidence (0.0 to 1.0)
142    #[serde(default = "default_confidence")]
143    pub confidence: f32,
144}
145
146fn default_parser() -> String {
147    "lines".into()
148}
149
150fn default_confidence() -> f32 {
151    0.5
152}
153
154/// Coverage configuration section.
155#[derive(Debug, Clone, Default, Deserialize)]
156#[serde(default)]
157pub struct CoverageConfig {
158    /// Enable coverage collection
159    pub enabled: bool,
160    /// Output format: "summary", "lcov", "html", "cobertura"
161    pub format: Option<String>,
162    /// Output directory for coverage reports
163    pub output_dir: Option<String>,
164    /// Minimum coverage threshold (fail below this)
165    pub threshold: Option<f64>,
166}
167
168/// History/analytics configuration section.
169#[derive(Debug, Clone, Deserialize)]
170#[serde(default)]
171pub struct HistoryConfig {
172    /// Enable history recording (default: true)
173    pub enabled: bool,
174    /// Maximum age of history entries in days
175    pub max_age_days: Option<u32>,
176    /// Database path (default: .testx/history.db)
177    pub db_path: Option<String>,
178}
179
180impl Default for HistoryConfig {
181    fn default() -> Self {
182        Self {
183            enabled: true,
184            max_age_days: None,
185            db_path: None,
186        }
187    }
188}
189
190impl Config {
191    /// Load config from `testx.toml` in the given directory.
192    /// Returns `Config::default()` if no config file exists.
193    pub fn load(project_dir: &Path) -> Self {
194        let config_path = project_dir.join("testx.toml");
195        if !config_path.exists() {
196            return Self::default();
197        }
198
199        match std::fs::read_to_string(&config_path) {
200            Ok(content) => match toml::from_str::<Config>(&content) {
201                Ok(mut config) => {
202                    // Clamp custom adapter confidence values to [0.0, 1.0]
203                    if let Some(adapters) = &mut config.custom_adapter {
204                        for adapter in adapters {
205                            adapter.confidence = adapter.confidence.clamp(0.0, 1.0);
206                        }
207                    }
208                    config
209                }
210                Err(e) => {
211                    eprintln!("⚠ warning: failed to parse testx.toml: {e}");
212                    eprintln!(
213                        "  Using default configuration. Fix testx.toml to apply your settings."
214                    );
215                    Self::default()
216                }
217            },
218            Err(e) => {
219                eprintln!("⚠ warning: failed to read testx.toml: {e}");
220                eprintln!("  Using default configuration. Check file permissions.");
221                Self::default()
222            }
223        }
224    }
225
226    /// Get adapter-specific config if available.
227    pub fn adapter_config(&self, adapter_name: &str) -> Option<&AdapterConfig> {
228        self.adapters
229            .as_ref()
230            .and_then(|m| m.get(&adapter_name.to_lowercase()))
231    }
232
233    /// Get watch config, or default.
234    pub fn watch_config(&self) -> WatchConfig {
235        self.watch.clone().unwrap_or_default()
236    }
237
238    /// Get output config, or default.
239    pub fn output_config(&self) -> OutputConfig {
240        self.output.clone().unwrap_or_default()
241    }
242
243    /// Get filter config, or default.
244    pub fn filter_config(&self) -> FilterConfig {
245        self.filter.clone().unwrap_or_default()
246    }
247
248    /// Get coverage config, or default.
249    pub fn coverage_config(&self) -> CoverageConfig {
250        self.coverage.clone().unwrap_or_default()
251    }
252
253    /// Get history config, or default.
254    pub fn history_config(&self) -> HistoryConfig {
255        self.history.clone().unwrap_or_default()
256    }
257
258    /// Check if watch mode is enabled (via config or CLI).
259    pub fn is_watch_enabled(&self) -> bool {
260        self.watch.as_ref().map(|w| w.enabled).unwrap_or(false)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn load_missing_config() {
270        let dir = tempfile::tempdir().unwrap();
271        let config = Config::load(dir.path());
272        assert!(config.adapter.is_none());
273        assert!(config.args.is_empty());
274        assert!(config.timeout.is_none());
275        assert!(config.env.is_empty());
276    }
277
278    #[test]
279    fn load_minimal_config() {
280        let dir = tempfile::tempdir().unwrap();
281        std::fs::write(
282            dir.path().join("testx.toml"),
283            r#"adapter = "python"
284"#,
285        )
286        .unwrap();
287        let config = Config::load(dir.path());
288        assert_eq!(config.adapter.as_deref(), Some("python"));
289    }
290
291    #[test]
292    fn load_full_config() {
293        let dir = tempfile::tempdir().unwrap();
294        std::fs::write(
295            dir.path().join("testx.toml"),
296            r#"
297adapter = "rust"
298args = ["--release", "--", "--nocapture"]
299timeout = 60
300
301[env]
302RUST_LOG = "debug"
303CI = "true"
304"#,
305        )
306        .unwrap();
307        let config = Config::load(dir.path());
308        assert_eq!(config.adapter.as_deref(), Some("rust"));
309        assert_eq!(config.args, vec!["--release", "--", "--nocapture"]);
310        assert_eq!(config.timeout, Some(60));
311        assert_eq!(
312            config.env.get("RUST_LOG").map(|s| s.as_str()),
313            Some("debug")
314        );
315        assert_eq!(config.env.get("CI").map(|s| s.as_str()), Some("true"));
316    }
317
318    #[test]
319    fn load_invalid_config_returns_default() {
320        let dir = tempfile::tempdir().unwrap();
321        std::fs::write(dir.path().join("testx.toml"), "this is not valid toml {{{}").unwrap();
322        let config = Config::load(dir.path());
323        assert!(config.adapter.is_none());
324    }
325
326    #[test]
327    fn load_config_with_only_args() {
328        let dir = tempfile::tempdir().unwrap();
329        std::fs::write(
330            dir.path().join("testx.toml"),
331            r#"args = ["-v", "--no-header"]"#,
332        )
333        .unwrap();
334        let config = Config::load(dir.path());
335        assert!(config.adapter.is_none());
336        assert_eq!(config.args.len(), 2);
337    }
338
339    #[test]
340    fn load_config_with_filter() {
341        let dir = tempfile::tempdir().unwrap();
342        std::fs::write(
343            dir.path().join("testx.toml"),
344            r#"
345[filter]
346include = "test_auth*"
347exclude = "test_slow*"
348"#,
349        )
350        .unwrap();
351        let config = Config::load(dir.path());
352        let filter = config.filter_config();
353        assert_eq!(filter.include.as_deref(), Some("test_auth*"));
354        assert_eq!(filter.exclude.as_deref(), Some("test_slow*"));
355    }
356
357    #[test]
358    fn load_config_with_watch() {
359        let dir = tempfile::tempdir().unwrap();
360        std::fs::write(
361            dir.path().join("testx.toml"),
362            r#"
363[watch]
364enabled = true
365clear = false
366debounce_ms = 500
367ignore = ["*.pyc", ".git"]
368"#,
369        )
370        .unwrap();
371        let config = Config::load(dir.path());
372        assert!(config.is_watch_enabled());
373        let watch = config.watch_config();
374        assert!(!watch.clear);
375        assert_eq!(watch.debounce_ms, 500);
376        assert_eq!(watch.ignore.len(), 2);
377    }
378
379    #[test]
380    fn load_config_with_output() {
381        let dir = tempfile::tempdir().unwrap();
382        std::fs::write(
383            dir.path().join("testx.toml"),
384            r#"
385[output]
386format = "json"
387slowest = 5
388verbose = true
389colors = "never"
390"#,
391        )
392        .unwrap();
393        let config = Config::load(dir.path());
394        let output = config.output_config();
395        assert_eq!(output.format.as_deref(), Some("json"));
396        assert_eq!(output.slowest, Some(5));
397        assert_eq!(output.verbose, Some(true));
398        assert_eq!(output.colors.as_deref(), Some("never"));
399    }
400
401    #[test]
402    fn load_config_with_adapter_overrides() {
403        let dir = tempfile::tempdir().unwrap();
404        std::fs::write(
405            dir.path().join("testx.toml"),
406            r#"
407[adapters.python]
408runner = "pytest"
409args = ["-x", "--tb=short"]
410timeout = 120
411
412[adapters.javascript]
413runner = "vitest"
414args = ["--reporter=verbose"]
415"#,
416        )
417        .unwrap();
418        let config = Config::load(dir.path());
419        let py = config.adapter_config("python").unwrap();
420        assert_eq!(py.runner.as_deref(), Some("pytest"));
421        assert_eq!(py.args, vec!["-x", "--tb=short"]);
422        assert_eq!(py.timeout, Some(120));
423
424        let js = config.adapter_config("javascript").unwrap();
425        assert_eq!(js.runner.as_deref(), Some("vitest"));
426    }
427
428    #[test]
429    fn load_config_with_custom_adapter() {
430        let dir = tempfile::tempdir().unwrap();
431        std::fs::write(
432            dir.path().join("testx.toml"),
433            r#"
434[[custom_adapter]]
435name = "bazel"
436detect = "BUILD"
437command = "bazel test //..."
438args = ["--test_output=all"]
439parse = "tap"
440confidence = 0.7
441"#,
442        )
443        .unwrap();
444        let config = Config::load(dir.path());
445        let custom = config.custom_adapter.as_ref().unwrap();
446        assert_eq!(custom.len(), 1);
447        assert_eq!(custom[0].name, "bazel");
448        assert_eq!(custom[0].detect, "BUILD");
449        assert_eq!(custom[0].command, "bazel test //...");
450        assert_eq!(custom[0].parse, "tap");
451        assert!((custom[0].confidence - 0.7).abs() < f32::EPSILON);
452    }
453
454    #[test]
455    fn load_config_with_coverage() {
456        let dir = tempfile::tempdir().unwrap();
457        std::fs::write(
458            dir.path().join("testx.toml"),
459            r#"
460[coverage]
461enabled = true
462format = "lcov"
463threshold = 80.0
464"#,
465        )
466        .unwrap();
467        let config = Config::load(dir.path());
468        let cov = config.coverage_config();
469        assert!(cov.enabled);
470        assert_eq!(cov.format.as_deref(), Some("lcov"));
471        assert_eq!(cov.threshold, Some(80.0));
472    }
473
474    #[test]
475    fn load_config_with_history() {
476        let dir = tempfile::tempdir().unwrap();
477        std::fs::write(
478            dir.path().join("testx.toml"),
479            r#"
480[history]
481enabled = true
482max_age_days = 90
483db_path = ".testx/data.db"
484"#,
485        )
486        .unwrap();
487        let config = Config::load(dir.path());
488        let hist = config.history_config();
489        assert!(hist.enabled);
490        assert_eq!(hist.max_age_days, Some(90));
491        assert_eq!(hist.db_path.as_deref(), Some(".testx/data.db"));
492    }
493
494    #[test]
495    fn load_config_fail_fast_and_retries() {
496        let dir = tempfile::tempdir().unwrap();
497        std::fs::write(
498            dir.path().join("testx.toml"),
499            r#"
500fail_fast = true
501retries = 3
502parallel = true
503"#,
504        )
505        .unwrap();
506        let config = Config::load(dir.path());
507        assert_eq!(config.fail_fast, Some(true));
508        assert_eq!(config.retries, Some(3));
509        assert_eq!(config.parallel, Some(true));
510    }
511
512    #[test]
513    fn default_watch_config() {
514        let watch = WatchConfig::default();
515        assert!(!watch.enabled);
516        assert!(watch.clear);
517        assert_eq!(watch.debounce_ms, 300);
518        assert!(watch.ignore.contains(&".git".to_string()));
519        assert!(watch.ignore.contains(&"node_modules".to_string()));
520    }
521
522    #[test]
523    fn adapter_config_case_insensitive() {
524        let dir = tempfile::tempdir().unwrap();
525        std::fs::write(
526            dir.path().join("testx.toml"),
527            r#"
528[adapters.python]
529runner = "pytest"
530"#,
531        )
532        .unwrap();
533        let config = Config::load(dir.path());
534        // adapter_config lowercases the input, so both work
535        assert!(config.adapter_config("Python").is_some());
536        assert!(config.adapter_config("python").is_some());
537    }
538
539    #[test]
540    fn watch_not_enabled_by_default() {
541        let config = Config::default();
542        assert!(!config.is_watch_enabled());
543    }
544
545    #[test]
546    fn default_configs_return_defaults() {
547        let config = Config::default();
548        let _ = config.filter_config();
549        let _ = config.output_config();
550        let _ = config.coverage_config();
551        let _ = config.history_config();
552        let _ = config.watch_config();
553    }
554}