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, Default, Deserialize)]
170#[serde(default)]
171pub struct HistoryConfig {
172    /// Enable history recording
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 Config {
181    /// Load config from `testx.toml` in the given directory.
182    /// Returns `Config::default()` if no config file exists.
183    pub fn load(project_dir: &Path) -> Self {
184        let config_path = project_dir.join("testx.toml");
185        if !config_path.exists() {
186            return Self::default();
187        }
188
189        match std::fs::read_to_string(&config_path) {
190            Ok(content) => match toml::from_str(&content) {
191                Ok(config) => config,
192                Err(e) => {
193                    eprintln!("Warning: failed to parse testx.toml: {e}");
194                    Self::default()
195                }
196            },
197            Err(e) => {
198                eprintln!("Warning: failed to read testx.toml: {e}");
199                Self::default()
200            }
201        }
202    }
203
204    /// Get adapter-specific config if available.
205    pub fn adapter_config(&self, adapter_name: &str) -> Option<&AdapterConfig> {
206        self.adapters
207            .as_ref()
208            .and_then(|m| m.get(&adapter_name.to_lowercase()))
209    }
210
211    /// Get watch config, or default.
212    pub fn watch_config(&self) -> WatchConfig {
213        self.watch.clone().unwrap_or_default()
214    }
215
216    /// Get output config, or default.
217    pub fn output_config(&self) -> OutputConfig {
218        self.output.clone().unwrap_or_default()
219    }
220
221    /// Get filter config, or default.
222    pub fn filter_config(&self) -> FilterConfig {
223        self.filter.clone().unwrap_or_default()
224    }
225
226    /// Get coverage config, or default.
227    pub fn coverage_config(&self) -> CoverageConfig {
228        self.coverage.clone().unwrap_or_default()
229    }
230
231    /// Get history config, or default.
232    pub fn history_config(&self) -> HistoryConfig {
233        self.history.clone().unwrap_or_default()
234    }
235
236    /// Check if watch mode is enabled (via config or CLI).
237    pub fn is_watch_enabled(&self) -> bool {
238        self.watch.as_ref().map(|w| w.enabled).unwrap_or(false)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn load_missing_config() {
248        let dir = tempfile::tempdir().unwrap();
249        let config = Config::load(dir.path());
250        assert!(config.adapter.is_none());
251        assert!(config.args.is_empty());
252        assert!(config.timeout.is_none());
253        assert!(config.env.is_empty());
254    }
255
256    #[test]
257    fn load_minimal_config() {
258        let dir = tempfile::tempdir().unwrap();
259        std::fs::write(
260            dir.path().join("testx.toml"),
261            r#"adapter = "python"
262"#,
263        )
264        .unwrap();
265        let config = Config::load(dir.path());
266        assert_eq!(config.adapter.as_deref(), Some("python"));
267    }
268
269    #[test]
270    fn load_full_config() {
271        let dir = tempfile::tempdir().unwrap();
272        std::fs::write(
273            dir.path().join("testx.toml"),
274            r#"
275adapter = "rust"
276args = ["--release", "--", "--nocapture"]
277timeout = 60
278
279[env]
280RUST_LOG = "debug"
281CI = "true"
282"#,
283        )
284        .unwrap();
285        let config = Config::load(dir.path());
286        assert_eq!(config.adapter.as_deref(), Some("rust"));
287        assert_eq!(config.args, vec!["--release", "--", "--nocapture"]);
288        assert_eq!(config.timeout, Some(60));
289        assert_eq!(
290            config.env.get("RUST_LOG").map(|s| s.as_str()),
291            Some("debug")
292        );
293        assert_eq!(config.env.get("CI").map(|s| s.as_str()), Some("true"));
294    }
295
296    #[test]
297    fn load_invalid_config_returns_default() {
298        let dir = tempfile::tempdir().unwrap();
299        std::fs::write(dir.path().join("testx.toml"), "this is not valid toml {{{}").unwrap();
300        let config = Config::load(dir.path());
301        assert!(config.adapter.is_none());
302    }
303
304    #[test]
305    fn load_config_with_only_args() {
306        let dir = tempfile::tempdir().unwrap();
307        std::fs::write(
308            dir.path().join("testx.toml"),
309            r#"args = ["-v", "--no-header"]"#,
310        )
311        .unwrap();
312        let config = Config::load(dir.path());
313        assert!(config.adapter.is_none());
314        assert_eq!(config.args.len(), 2);
315    }
316
317    #[test]
318    fn load_config_with_filter() {
319        let dir = tempfile::tempdir().unwrap();
320        std::fs::write(
321            dir.path().join("testx.toml"),
322            r#"
323[filter]
324include = "test_auth*"
325exclude = "test_slow*"
326"#,
327        )
328        .unwrap();
329        let config = Config::load(dir.path());
330        let filter = config.filter_config();
331        assert_eq!(filter.include.as_deref(), Some("test_auth*"));
332        assert_eq!(filter.exclude.as_deref(), Some("test_slow*"));
333    }
334
335    #[test]
336    fn load_config_with_watch() {
337        let dir = tempfile::tempdir().unwrap();
338        std::fs::write(
339            dir.path().join("testx.toml"),
340            r#"
341[watch]
342enabled = true
343clear = false
344debounce_ms = 500
345ignore = ["*.pyc", ".git"]
346"#,
347        )
348        .unwrap();
349        let config = Config::load(dir.path());
350        assert!(config.is_watch_enabled());
351        let watch = config.watch_config();
352        assert!(!watch.clear);
353        assert_eq!(watch.debounce_ms, 500);
354        assert_eq!(watch.ignore.len(), 2);
355    }
356
357    #[test]
358    fn load_config_with_output() {
359        let dir = tempfile::tempdir().unwrap();
360        std::fs::write(
361            dir.path().join("testx.toml"),
362            r#"
363[output]
364format = "json"
365slowest = 5
366verbose = true
367colors = "never"
368"#,
369        )
370        .unwrap();
371        let config = Config::load(dir.path());
372        let output = config.output_config();
373        assert_eq!(output.format.as_deref(), Some("json"));
374        assert_eq!(output.slowest, Some(5));
375        assert_eq!(output.verbose, Some(true));
376        assert_eq!(output.colors.as_deref(), Some("never"));
377    }
378
379    #[test]
380    fn load_config_with_adapter_overrides() {
381        let dir = tempfile::tempdir().unwrap();
382        std::fs::write(
383            dir.path().join("testx.toml"),
384            r#"
385[adapters.python]
386runner = "pytest"
387args = ["-x", "--tb=short"]
388timeout = 120
389
390[adapters.javascript]
391runner = "vitest"
392args = ["--reporter=verbose"]
393"#,
394        )
395        .unwrap();
396        let config = Config::load(dir.path());
397        let py = config.adapter_config("python").unwrap();
398        assert_eq!(py.runner.as_deref(), Some("pytest"));
399        assert_eq!(py.args, vec!["-x", "--tb=short"]);
400        assert_eq!(py.timeout, Some(120));
401
402        let js = config.adapter_config("javascript").unwrap();
403        assert_eq!(js.runner.as_deref(), Some("vitest"));
404    }
405
406    #[test]
407    fn load_config_with_custom_adapter() {
408        let dir = tempfile::tempdir().unwrap();
409        std::fs::write(
410            dir.path().join("testx.toml"),
411            r#"
412[[custom_adapter]]
413name = "bazel"
414detect = "BUILD"
415command = "bazel test //..."
416args = ["--test_output=all"]
417parse = "tap"
418confidence = 0.7
419"#,
420        )
421        .unwrap();
422        let config = Config::load(dir.path());
423        let custom = config.custom_adapter.as_ref().unwrap();
424        assert_eq!(custom.len(), 1);
425        assert_eq!(custom[0].name, "bazel");
426        assert_eq!(custom[0].detect, "BUILD");
427        assert_eq!(custom[0].command, "bazel test //...");
428        assert_eq!(custom[0].parse, "tap");
429        assert!((custom[0].confidence - 0.7).abs() < f32::EPSILON);
430    }
431
432    #[test]
433    fn load_config_with_coverage() {
434        let dir = tempfile::tempdir().unwrap();
435        std::fs::write(
436            dir.path().join("testx.toml"),
437            r#"
438[coverage]
439enabled = true
440format = "lcov"
441threshold = 80.0
442"#,
443        )
444        .unwrap();
445        let config = Config::load(dir.path());
446        let cov = config.coverage_config();
447        assert!(cov.enabled);
448        assert_eq!(cov.format.as_deref(), Some("lcov"));
449        assert_eq!(cov.threshold, Some(80.0));
450    }
451
452    #[test]
453    fn load_config_with_history() {
454        let dir = tempfile::tempdir().unwrap();
455        std::fs::write(
456            dir.path().join("testx.toml"),
457            r#"
458[history]
459enabled = true
460max_age_days = 90
461db_path = ".testx/data.db"
462"#,
463        )
464        .unwrap();
465        let config = Config::load(dir.path());
466        let hist = config.history_config();
467        assert!(hist.enabled);
468        assert_eq!(hist.max_age_days, Some(90));
469        assert_eq!(hist.db_path.as_deref(), Some(".testx/data.db"));
470    }
471
472    #[test]
473    fn load_config_fail_fast_and_retries() {
474        let dir = tempfile::tempdir().unwrap();
475        std::fs::write(
476            dir.path().join("testx.toml"),
477            r#"
478fail_fast = true
479retries = 3
480parallel = true
481"#,
482        )
483        .unwrap();
484        let config = Config::load(dir.path());
485        assert_eq!(config.fail_fast, Some(true));
486        assert_eq!(config.retries, Some(3));
487        assert_eq!(config.parallel, Some(true));
488    }
489
490    #[test]
491    fn default_watch_config() {
492        let watch = WatchConfig::default();
493        assert!(!watch.enabled);
494        assert!(watch.clear);
495        assert_eq!(watch.debounce_ms, 300);
496        assert!(watch.ignore.contains(&".git".to_string()));
497        assert!(watch.ignore.contains(&"node_modules".to_string()));
498    }
499
500    #[test]
501    fn adapter_config_case_insensitive() {
502        let dir = tempfile::tempdir().unwrap();
503        std::fs::write(
504            dir.path().join("testx.toml"),
505            r#"
506[adapters.python]
507runner = "pytest"
508"#,
509        )
510        .unwrap();
511        let config = Config::load(dir.path());
512        // adapter_config lowercases the input, so both work
513        assert!(config.adapter_config("Python").is_some());
514        assert!(config.adapter_config("python").is_some());
515    }
516
517    #[test]
518    fn watch_not_enabled_by_default() {
519        let config = Config::default();
520        assert!(!config.is_watch_enabled());
521    }
522
523    #[test]
524    fn default_configs_return_defaults() {
525        let config = Config::default();
526        let _ = config.filter_config();
527        let _ = config.output_config();
528        let _ = config.coverage_config();
529        let _ = config.history_config();
530        let _ = config.watch_config();
531    }
532}