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 pub detect: String,
133 pub command: String,
135 #[serde(default)]
137 pub args: Vec<String>,
138 #[serde(default = "default_parser")]
140 pub parse: String,
141 #[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#[derive(Debug, Clone, Default, Deserialize)]
156#[serde(default)]
157pub struct CoverageConfig {
158 pub enabled: bool,
160 pub format: Option<String>,
162 pub output_dir: Option<String>,
164 pub threshold: Option<f64>,
166}
167
168#[derive(Debug, Clone, Deserialize)]
170#[serde(default)]
171pub struct HistoryConfig {
172 pub enabled: bool,
174 pub max_age_days: Option<u32>,
176 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 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 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 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 pub fn watch_config(&self) -> WatchConfig {
235 self.watch.clone().unwrap_or_default()
236 }
237
238 pub fn output_config(&self) -> OutputConfig {
240 self.output.clone().unwrap_or_default()
241 }
242
243 pub fn filter_config(&self) -> FilterConfig {
245 self.filter.clone().unwrap_or_default()
246 }
247
248 pub fn coverage_config(&self) -> CoverageConfig {
250 self.coverage.clone().unwrap_or_default()
251 }
252
253 pub fn history_config(&self) -> HistoryConfig {
255 self.history.clone().unwrap_or_default()
256 }
257
258 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 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}