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    /// Detection configuration
132    #[serde(default)]
133    pub detect: CustomDetectConfig,
134    /// Command to run
135    pub command: String,
136    /// Default arguments
137    #[serde(default)]
138    pub args: Vec<String>,
139    /// Output parser: "json", "junit", "tap", "lines", "regex"
140    #[serde(default = "default_parser", alias = "parse")]
141    pub output: String,
142    /// Detection confidence (0.0 to 1.0)
143    #[serde(default = "default_confidence")]
144    pub confidence: f32,
145    /// Verify runner is installed before executing
146    pub check: Option<String>,
147    /// Working directory relative to project root
148    pub working_dir: Option<String>,
149    /// Environment variables to set
150    #[serde(default)]
151    pub env: HashMap<String, String>,
152}
153
154/// Detection configuration for custom adapters.
155///
156/// Supports two TOML forms:
157/// - Short: `detect = "Makefile"` (equivalent to `detect = { files = ["Makefile"] }`)
158/// - Full:  `[custom_adapter.detect]` with files, commands, env, content, search_depth
159#[derive(Debug, Clone, Default)]
160pub struct CustomDetectConfig {
161    /// File patterns whose presence triggers detection
162    pub files: Vec<String>,
163    /// Commands that must succeed (exit 0) for detection
164    pub commands: Vec<String>,
165    /// Environment variables that must be set
166    pub env_vars: Vec<String>,
167    /// File content matching rules
168    pub content: Vec<ContentMatch>,
169    /// Subdirectory search depth for markers (0 = root only)
170    pub search_depth: usize,
171}
172
173impl<'de> serde::Deserialize<'de> for CustomDetectConfig {
174    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
175    where
176        D: serde::Deserializer<'de>,
177    {
178        use serde::de;
179
180        struct DetectVisitor;
181
182        impl<'de> de::Visitor<'de> for DetectVisitor {
183            type Value = CustomDetectConfig;
184
185            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
186                formatter.write_str("a string (file name) or a detect config table")
187            }
188
189            fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
190                Ok(CustomDetectConfig {
191                    files: vec![value.to_string()],
192                    ..Default::default()
193                })
194            }
195
196            fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
197                #[derive(Default, Deserialize)]
198                #[serde(default)]
199                struct Inner {
200                    files: Vec<String>,
201                    commands: Vec<String>,
202                    #[serde(rename = "env")]
203                    env_vars: Vec<String>,
204                    content: Vec<ContentMatch>,
205                    search_depth: usize,
206                }
207
208                let inner = Inner::deserialize(de::value::MapAccessDeserializer::new(map))?;
209                Ok(CustomDetectConfig {
210                    files: inner.files,
211                    commands: inner.commands,
212                    env_vars: inner.env_vars,
213                    content: inner.content,
214                    search_depth: inner.search_depth,
215                })
216            }
217        }
218
219        deserializer.deserialize_any(DetectVisitor)
220    }
221}
222
223/// Content matching rule for custom adapter detection.
224#[derive(Debug, Clone, Deserialize)]
225pub struct ContentMatch {
226    /// File to check
227    pub file: String,
228    /// String that must be present in the file
229    pub contains: String,
230}
231
232fn default_parser() -> String {
233    "lines".into()
234}
235
236fn default_confidence() -> f32 {
237    0.5
238}
239
240/// Coverage configuration section.
241#[derive(Debug, Clone, Default, Deserialize)]
242#[serde(default)]
243pub struct CoverageConfig {
244    /// Enable coverage collection
245    pub enabled: bool,
246    /// Output format: "summary", "lcov", "html", "cobertura"
247    pub format: Option<String>,
248    /// Output directory for coverage reports
249    pub output_dir: Option<String>,
250    /// Minimum coverage threshold (fail below this)
251    pub threshold: Option<f64>,
252}
253
254/// History/analytics configuration section.
255#[derive(Debug, Clone, Deserialize)]
256#[serde(default)]
257pub struct HistoryConfig {
258    /// Enable history recording (default: true)
259    pub enabled: bool,
260    /// Maximum age of history entries in days
261    pub max_age_days: Option<u32>,
262    /// Database path (default: .testx/history.db)
263    pub db_path: Option<String>,
264}
265
266impl Default for HistoryConfig {
267    fn default() -> Self {
268        Self {
269            enabled: true,
270            max_age_days: None,
271            db_path: None,
272        }
273    }
274}
275
276impl Config {
277    /// Load config from `testx.toml` in the given directory.
278    /// Returns `Config::default()` if no config file exists.
279    pub fn load(project_dir: &Path) -> Self {
280        let config_path = project_dir.join("testx.toml");
281        if !config_path.exists() {
282            return Self::default();
283        }
284
285        match std::fs::read_to_string(&config_path) {
286            Ok(content) => match toml::from_str::<Config>(&content) {
287                Ok(mut config) => {
288                    // Clamp custom adapter confidence values to [0.0, 1.0]
289                    if let Some(adapters) = &mut config.custom_adapter {
290                        for adapter in adapters {
291                            adapter.confidence = adapter.confidence.clamp(0.0, 1.0);
292                        }
293                    }
294                    config
295                }
296                Err(e) => {
297                    eprintln!("⚠ warning: failed to parse testx.toml: {e}");
298                    eprintln!(
299                        "  Using default configuration. Fix testx.toml to apply your settings."
300                    );
301                    Self::default()
302                }
303            },
304            Err(e) => {
305                eprintln!("⚠ warning: failed to read testx.toml: {e}");
306                eprintln!("  Using default configuration. Check file permissions.");
307                Self::default()
308            }
309        }
310    }
311
312    /// Get adapter-specific config if available.
313    pub fn adapter_config(&self, adapter_name: &str) -> Option<&AdapterConfig> {
314        self.adapters
315            .as_ref()
316            .and_then(|m| m.get(&adapter_name.to_lowercase()))
317    }
318
319    /// Get watch config, or default.
320    pub fn watch_config(&self) -> WatchConfig {
321        self.watch.clone().unwrap_or_default()
322    }
323
324    /// Get output config, or default.
325    pub fn output_config(&self) -> OutputConfig {
326        self.output.clone().unwrap_or_default()
327    }
328
329    /// Get filter config, or default.
330    pub fn filter_config(&self) -> FilterConfig {
331        self.filter.clone().unwrap_or_default()
332    }
333
334    /// Get coverage config, or default.
335    pub fn coverage_config(&self) -> CoverageConfig {
336        self.coverage.clone().unwrap_or_default()
337    }
338
339    /// Get history config, or default.
340    pub fn history_config(&self) -> HistoryConfig {
341        self.history.clone().unwrap_or_default()
342    }
343
344    /// Check if watch mode is enabled (via config or CLI).
345    pub fn is_watch_enabled(&self) -> bool {
346        self.watch.as_ref().map(|w| w.enabled).unwrap_or(false)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn load_missing_config() {
356        let dir = tempfile::tempdir().unwrap();
357        let config = Config::load(dir.path());
358        assert!(config.adapter.is_none());
359        assert!(config.args.is_empty());
360        assert!(config.timeout.is_none());
361        assert!(config.env.is_empty());
362    }
363
364    #[test]
365    fn load_minimal_config() {
366        let dir = tempfile::tempdir().unwrap();
367        std::fs::write(
368            dir.path().join("testx.toml"),
369            r#"adapter = "python"
370"#,
371        )
372        .unwrap();
373        let config = Config::load(dir.path());
374        assert_eq!(config.adapter.as_deref(), Some("python"));
375    }
376
377    #[test]
378    fn load_full_config() {
379        let dir = tempfile::tempdir().unwrap();
380        std::fs::write(
381            dir.path().join("testx.toml"),
382            r#"
383adapter = "rust"
384args = ["--release", "--", "--nocapture"]
385timeout = 60
386
387[env]
388RUST_LOG = "debug"
389CI = "true"
390"#,
391        )
392        .unwrap();
393        let config = Config::load(dir.path());
394        assert_eq!(config.adapter.as_deref(), Some("rust"));
395        assert_eq!(config.args, vec!["--release", "--", "--nocapture"]);
396        assert_eq!(config.timeout, Some(60));
397        assert_eq!(
398            config.env.get("RUST_LOG").map(|s| s.as_str()),
399            Some("debug")
400        );
401        assert_eq!(config.env.get("CI").map(|s| s.as_str()), Some("true"));
402    }
403
404    #[test]
405    fn load_invalid_config_returns_default() {
406        let dir = tempfile::tempdir().unwrap();
407        std::fs::write(dir.path().join("testx.toml"), "this is not valid toml {{{}").unwrap();
408        let config = Config::load(dir.path());
409        assert!(config.adapter.is_none());
410    }
411
412    #[test]
413    fn load_config_with_only_args() {
414        let dir = tempfile::tempdir().unwrap();
415        std::fs::write(
416            dir.path().join("testx.toml"),
417            r#"args = ["-v", "--no-header"]"#,
418        )
419        .unwrap();
420        let config = Config::load(dir.path());
421        assert!(config.adapter.is_none());
422        assert_eq!(config.args.len(), 2);
423    }
424
425    #[test]
426    fn load_config_with_filter() {
427        let dir = tempfile::tempdir().unwrap();
428        std::fs::write(
429            dir.path().join("testx.toml"),
430            r#"
431[filter]
432include = "test_auth*"
433exclude = "test_slow*"
434"#,
435        )
436        .unwrap();
437        let config = Config::load(dir.path());
438        let filter = config.filter_config();
439        assert_eq!(filter.include.as_deref(), Some("test_auth*"));
440        assert_eq!(filter.exclude.as_deref(), Some("test_slow*"));
441    }
442
443    #[test]
444    fn load_config_with_watch() {
445        let dir = tempfile::tempdir().unwrap();
446        std::fs::write(
447            dir.path().join("testx.toml"),
448            r#"
449[watch]
450enabled = true
451clear = false
452debounce_ms = 500
453ignore = ["*.pyc", ".git"]
454"#,
455        )
456        .unwrap();
457        let config = Config::load(dir.path());
458        assert!(config.is_watch_enabled());
459        let watch = config.watch_config();
460        assert!(!watch.clear);
461        assert_eq!(watch.debounce_ms, 500);
462        assert_eq!(watch.ignore.len(), 2);
463    }
464
465    #[test]
466    fn load_config_with_output() {
467        let dir = tempfile::tempdir().unwrap();
468        std::fs::write(
469            dir.path().join("testx.toml"),
470            r#"
471[output]
472format = "json"
473slowest = 5
474verbose = true
475colors = "never"
476"#,
477        )
478        .unwrap();
479        let config = Config::load(dir.path());
480        let output = config.output_config();
481        assert_eq!(output.format.as_deref(), Some("json"));
482        assert_eq!(output.slowest, Some(5));
483        assert_eq!(output.verbose, Some(true));
484        assert_eq!(output.colors.as_deref(), Some("never"));
485    }
486
487    #[test]
488    fn load_config_with_adapter_overrides() {
489        let dir = tempfile::tempdir().unwrap();
490        std::fs::write(
491            dir.path().join("testx.toml"),
492            r#"
493[adapters.python]
494runner = "pytest"
495args = ["-x", "--tb=short"]
496timeout = 120
497
498[adapters.javascript]
499runner = "vitest"
500args = ["--reporter=verbose"]
501"#,
502        )
503        .unwrap();
504        let config = Config::load(dir.path());
505        let py = config.adapter_config("python").unwrap();
506        assert_eq!(py.runner.as_deref(), Some("pytest"));
507        assert_eq!(py.args, vec!["-x", "--tb=short"]);
508        assert_eq!(py.timeout, Some(120));
509
510        let js = config.adapter_config("javascript").unwrap();
511        assert_eq!(js.runner.as_deref(), Some("vitest"));
512    }
513
514    #[test]
515    fn load_config_with_custom_adapter() {
516        let dir = tempfile::tempdir().unwrap();
517        std::fs::write(
518            dir.path().join("testx.toml"),
519            r#"
520[[custom_adapter]]
521name = "bazel"
522detect = "BUILD"
523command = "bazel test //..."
524args = ["--test_output=all"]
525parse = "tap"
526confidence = 0.7
527"#,
528        )
529        .unwrap();
530        let config = Config::load(dir.path());
531        let custom = config.custom_adapter.as_ref().unwrap();
532        assert_eq!(custom.len(), 1);
533        assert_eq!(custom[0].name, "bazel");
534        assert_eq!(custom[0].detect.files, vec!["BUILD"]);
535        assert_eq!(custom[0].command, "bazel test //...");
536        assert_eq!(custom[0].output, "tap");
537        assert!((custom[0].confidence - 0.7).abs() < f32::EPSILON);
538    }
539
540    #[test]
541    fn load_config_with_custom_adapter_full_detect() {
542        let dir = tempfile::tempdir().unwrap();
543        std::fs::write(
544            dir.path().join("testx.toml"),
545            r#"
546[[custom_adapter]]
547name = "custom-runner"
548command = "my-runner test"
549output = "json"
550confidence = 0.8
551check = "my-runner --version"
552working_dir = "tests"
553
554[custom_adapter.detect]
555files = ["my-runner.toml", "test.config"]
556commands = ["my-runner --version"]
557env = ["MY_RUNNER_HOME"]
558search_depth = 2
559
560[[custom_adapter.detect.content]]
561file = "package.json"
562contains = "my-runner"
563"#,
564        )
565        .unwrap();
566        let config = Config::load(dir.path());
567        let custom = config.custom_adapter.as_ref().unwrap();
568        assert_eq!(custom.len(), 1);
569        assert_eq!(custom[0].name, "custom-runner");
570        assert_eq!(custom[0].output, "json");
571        assert_eq!(
572            custom[0].detect.files,
573            vec!["my-runner.toml", "test.config"]
574        );
575        assert_eq!(custom[0].detect.commands, vec!["my-runner --version"]);
576        assert_eq!(custom[0].detect.env_vars, vec!["MY_RUNNER_HOME"]);
577        assert_eq!(custom[0].detect.search_depth, 2);
578        assert_eq!(custom[0].detect.content.len(), 1);
579        assert_eq!(custom[0].detect.content[0].file, "package.json");
580        assert_eq!(custom[0].detect.content[0].contains, "my-runner");
581        assert_eq!(custom[0].check.as_deref(), Some("my-runner --version"));
582        assert_eq!(custom[0].working_dir.as_deref(), Some("tests"));
583    }
584
585    #[test]
586    fn load_config_with_coverage() {
587        let dir = tempfile::tempdir().unwrap();
588        std::fs::write(
589            dir.path().join("testx.toml"),
590            r#"
591[coverage]
592enabled = true
593format = "lcov"
594threshold = 80.0
595"#,
596        )
597        .unwrap();
598        let config = Config::load(dir.path());
599        let cov = config.coverage_config();
600        assert!(cov.enabled);
601        assert_eq!(cov.format.as_deref(), Some("lcov"));
602        assert_eq!(cov.threshold, Some(80.0));
603    }
604
605    #[test]
606    fn load_config_with_history() {
607        let dir = tempfile::tempdir().unwrap();
608        std::fs::write(
609            dir.path().join("testx.toml"),
610            r#"
611[history]
612enabled = true
613max_age_days = 90
614db_path = ".testx/data.db"
615"#,
616        )
617        .unwrap();
618        let config = Config::load(dir.path());
619        let hist = config.history_config();
620        assert!(hist.enabled);
621        assert_eq!(hist.max_age_days, Some(90));
622        assert_eq!(hist.db_path.as_deref(), Some(".testx/data.db"));
623    }
624
625    #[test]
626    fn load_config_fail_fast_and_retries() {
627        let dir = tempfile::tempdir().unwrap();
628        std::fs::write(
629            dir.path().join("testx.toml"),
630            r#"
631fail_fast = true
632retries = 3
633parallel = true
634"#,
635        )
636        .unwrap();
637        let config = Config::load(dir.path());
638        assert_eq!(config.fail_fast, Some(true));
639        assert_eq!(config.retries, Some(3));
640        assert_eq!(config.parallel, Some(true));
641    }
642
643    #[test]
644    fn default_watch_config() {
645        let watch = WatchConfig::default();
646        assert!(!watch.enabled);
647        assert!(watch.clear);
648        assert_eq!(watch.debounce_ms, 300);
649        assert!(watch.ignore.contains(&".git".to_string()));
650        assert!(watch.ignore.contains(&"node_modules".to_string()));
651    }
652
653    #[test]
654    fn adapter_config_case_insensitive() {
655        let dir = tempfile::tempdir().unwrap();
656        std::fs::write(
657            dir.path().join("testx.toml"),
658            r#"
659[adapters.python]
660runner = "pytest"
661"#,
662        )
663        .unwrap();
664        let config = Config::load(dir.path());
665        // adapter_config lowercases the input, so both work
666        assert!(config.adapter_config("Python").is_some());
667        assert!(config.adapter_config("python").is_some());
668    }
669
670    #[test]
671    fn watch_not_enabled_by_default() {
672        let config = Config::default();
673        assert!(!config.is_watch_enabled());
674    }
675
676    #[test]
677    fn default_configs_return_defaults() {
678        let config = Config::default();
679        let _ = config.filter_config();
680        let _ = config.output_config();
681        let _ = config.coverage_config();
682        let _ = config.history_config();
683        let _ = config.watch_config();
684    }
685}