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, Default, 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 Config {
181 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 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 pub fn watch_config(&self) -> WatchConfig {
213 self.watch.clone().unwrap_or_default()
214 }
215
216 pub fn output_config(&self) -> OutputConfig {
218 self.output.clone().unwrap_or_default()
219 }
220
221 pub fn filter_config(&self) -> FilterConfig {
223 self.filter.clone().unwrap_or_default()
224 }
225
226 pub fn coverage_config(&self) -> CoverageConfig {
228 self.coverage.clone().unwrap_or_default()
229 }
230
231 pub fn history_config(&self) -> HistoryConfig {
233 self.history.clone().unwrap_or_default()
234 }
235
236 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 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}