1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::Deserialize;
5
6#[derive(Debug, Clone, Default, Deserialize)]
8#[serde(default)]
9pub struct Config {
10 pub adapter: Option<String>,
12
13 pub args: Vec<String>,
15
16 pub timeout: Option<u64>,
18
19 pub fail_fast: Option<bool>,
21
22 pub retries: Option<u32>,
24
25 pub parallel: Option<bool>,
27
28 pub env: HashMap<String, String>,
30
31 pub filter: Option<FilterConfig>,
33
34 pub watch: Option<WatchConfig>,
36
37 pub output: Option<OutputConfig>,
39
40 pub adapters: Option<HashMap<String, AdapterConfig>>,
42
43 pub custom_adapter: Option<Vec<CustomAdapterConfig>>,
45
46 pub coverage: Option<CoverageConfig>,
48
49 pub history: Option<HistoryConfig>,
51}
52
53#[derive(Debug, Clone, Default, Deserialize)]
55#[serde(default)]
56pub struct FilterConfig {
57 pub include: Option<String>,
59 pub exclude: Option<String>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
65#[serde(default)]
66pub struct WatchConfig {
67 pub enabled: bool,
69 pub clear: bool,
71 pub debounce_ms: u64,
73 pub ignore: Vec<String>,
75 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#[derive(Debug, Clone, Default, Deserialize)]
100#[serde(default)]
101pub struct OutputConfig {
102 pub format: Option<String>,
104 pub slowest: Option<usize>,
106 pub verbose: Option<bool>,
108 pub colors: Option<String>,
110}
111
112#[derive(Debug, Clone, Default, Deserialize)]
114#[serde(default)]
115pub struct AdapterConfig {
116 pub runner: Option<String>,
118 pub args: Vec<String>,
120 pub env: HashMap<String, String>,
122 pub timeout: Option<u64>,
124}
125
126#[derive(Debug, Clone, Deserialize)]
128pub struct CustomAdapterConfig {
129 pub name: String,
131 #[serde(default)]
133 pub detect: CustomDetectConfig,
134 pub command: String,
136 #[serde(default)]
138 pub args: Vec<String>,
139 #[serde(default = "default_parser", alias = "parse")]
141 pub output: String,
142 #[serde(default = "default_confidence")]
144 pub confidence: f32,
145 pub check: Option<String>,
147 pub working_dir: Option<String>,
149 #[serde(default)]
151 pub env: HashMap<String, String>,
152}
153
154#[derive(Debug, Clone, Default)]
160pub struct CustomDetectConfig {
161 pub files: Vec<String>,
163 pub commands: Vec<String>,
165 pub env_vars: Vec<String>,
167 pub content: Vec<ContentMatch>,
169 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#[derive(Debug, Clone, Deserialize)]
225pub struct ContentMatch {
226 pub file: String,
228 pub contains: String,
230}
231
232fn default_parser() -> String {
233 "lines".into()
234}
235
236fn default_confidence() -> f32 {
237 0.5
238}
239
240#[derive(Debug, Clone, Default, Deserialize)]
242#[serde(default)]
243pub struct CoverageConfig {
244 pub enabled: bool,
246 pub format: Option<String>,
248 pub output_dir: Option<String>,
250 pub threshold: Option<f64>,
252}
253
254#[derive(Debug, Clone, Deserialize)]
256#[serde(default)]
257pub struct HistoryConfig {
258 pub enabled: bool,
260 pub max_age_days: Option<u32>,
262 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 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 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 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 pub fn watch_config(&self) -> WatchConfig {
321 self.watch.clone().unwrap_or_default()
322 }
323
324 pub fn output_config(&self) -> OutputConfig {
326 self.output.clone().unwrap_or_default()
327 }
328
329 pub fn filter_config(&self) -> FilterConfig {
331 self.filter.clone().unwrap_or_default()
332 }
333
334 pub fn coverage_config(&self) -> CoverageConfig {
336 self.coverage.clone().unwrap_or_default()
337 }
338
339 pub fn history_config(&self) -> HistoryConfig {
341 self.history.clone().unwrap_or_default()
342 }
343
344 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 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}