1use std::collections::HashMap;
2use std::io::{BufRead, BufReader};
3use std::path::PathBuf;
4use std::process::{Command, Stdio};
5use std::sync::mpsc;
6use std::thread;
7use std::time::{Duration, Instant};
8
9use crate::adapters::TestRunResult;
10use crate::config::Config;
11use crate::detection::DetectionEngine;
12use crate::error::{Result, TestxError};
13use crate::events::{EventBus, Stream, TestEvent};
14
15#[derive(Debug, Clone)]
17pub struct RunnerConfig {
18 pub project_dir: PathBuf,
20
21 pub adapter_override: Option<String>,
23
24 pub extra_args: Vec<String>,
26
27 pub timeout: Option<Duration>,
29
30 pub env: HashMap<String, String>,
32
33 pub retries: u32,
35
36 pub fail_fast: bool,
38
39 pub filter: Option<String>,
41
42 pub exclude: Option<String>,
44
45 pub verbose: bool,
47}
48
49impl Default for RunnerConfig {
50 fn default() -> Self {
51 Self {
52 project_dir: PathBuf::from("."),
53 adapter_override: None,
54 extra_args: Vec::new(),
55 timeout: None,
56 env: HashMap::new(),
57 retries: 0,
58 fail_fast: false,
59 filter: None,
60 exclude: None,
61 verbose: false,
62 }
63 }
64}
65
66impl RunnerConfig {
67 pub fn new(project_dir: PathBuf) -> Self {
68 Self {
69 project_dir,
70 ..Default::default()
71 }
72 }
73
74 pub fn merge_config(&mut self, config: &Config) {
76 if self.adapter_override.is_none() {
77 self.adapter_override = config.adapter.clone();
78 }
79 if self.extra_args.is_empty() {
80 self.extra_args = config.args.clone();
81 }
82 if self.timeout.is_none() {
83 self.timeout = config.timeout.map(Duration::from_secs);
84 }
85 for (key, value) in &config.env {
86 self.env.entry(key.clone()).or_insert_with(|| value.clone());
87 }
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct ExecutionOutput {
94 pub stdout: String,
95 pub stderr: String,
96 pub exit_code: i32,
97 pub duration: Duration,
98 pub timed_out: bool,
99}
100
101pub struct Runner {
103 engine: DetectionEngine,
104 config: RunnerConfig,
105 event_bus: EventBus,
106}
107
108impl Runner {
109 pub fn new(config: RunnerConfig) -> Self {
110 Self {
111 engine: DetectionEngine::new(),
112 config,
113 event_bus: EventBus::new(),
114 }
115 }
116
117 pub fn with_event_bus(mut self, event_bus: EventBus) -> Self {
118 self.event_bus = event_bus;
119 self
120 }
121
122 pub fn event_bus(&self) -> &EventBus {
123 &self.event_bus
124 }
125
126 pub fn event_bus_mut(&mut self) -> &mut EventBus {
127 &mut self.event_bus
128 }
129
130 pub fn config(&self) -> &RunnerConfig {
131 &self.config
132 }
133
134 pub fn engine(&self) -> &DetectionEngine {
135 &self.engine
136 }
137
138 pub fn run(&mut self) -> Result<(TestRunResult, ExecutionOutput)> {
140 let (adapter_index, adapter_name, framework) = self.resolve_adapter()?;
141
142 self.event_bus.emit(TestEvent::RunStarted {
143 adapter: adapter_name.clone(),
144 framework: framework.clone(),
145 project_dir: self.config.project_dir.clone(),
146 });
147
148 let (mut cmd, _adapter_name_check) = {
150 let adapter = self.engine.adapter(adapter_index);
151
152 if let Some(missing) = adapter.check_runner() {
153 return Err(TestxError::RunnerNotFound { runner: missing });
154 }
155
156 let cmd = adapter
157 .build_command(&self.config.project_dir, &self.config.extra_args)
158 .map_err(|e| TestxError::ExecutionFailed {
159 command: adapter_name.clone(),
160 source: std::io::Error::other(e.to_string()),
161 })?;
162
163 (cmd, adapter.name().to_string())
164 };
165
166 for (key, value) in &self.config.env {
168 cmd.env(key, value);
169 }
170
171 if self.config.verbose {
172 eprintln!("cmd: {:?}", cmd);
173 }
174
175 let exec_output = self.execute_command(&mut cmd)?;
177
178 let adapter = self.engine.adapter(adapter_index);
180 let mut result = adapter.parse_output(
181 &exec_output.stdout,
182 &exec_output.stderr,
183 exec_output.exit_code,
184 );
185
186 if result.duration.as_millis() == 0 {
188 result.duration = exec_output.duration;
189 }
190
191 self.event_bus.emit(TestEvent::RunFinished {
192 result: result.clone(),
193 });
194 self.event_bus.flush();
195
196 Ok((result, exec_output))
197 }
198
199 pub fn run_with_adapter(
201 &mut self,
202 adapter_index: usize,
203 ) -> Result<(TestRunResult, ExecutionOutput)> {
204 let (mut cmd, adapter_name) = {
206 let adapter = self.engine.adapter(adapter_index);
207 let name = adapter.name().to_string();
208
209 if let Some(missing) = adapter.check_runner() {
210 return Err(TestxError::RunnerNotFound { runner: missing });
211 }
212
213 let cmd = adapter
214 .build_command(&self.config.project_dir, &self.config.extra_args)
215 .map_err(|e| TestxError::ExecutionFailed {
216 command: name.clone(),
217 source: std::io::Error::other(e.to_string()),
218 })?;
219
220 (cmd, name)
221 };
222
223 self.event_bus.emit(TestEvent::RunStarted {
224 adapter: adapter_name.clone(),
225 framework: adapter_name.clone(),
226 project_dir: self.config.project_dir.clone(),
227 });
228
229 for (key, value) in &self.config.env {
230 cmd.env(key, value);
231 }
232
233 let exec_output = self.execute_command(&mut cmd)?;
235
236 let adapter = self.engine.adapter(adapter_index);
238 let mut result = adapter.parse_output(
239 &exec_output.stdout,
240 &exec_output.stderr,
241 exec_output.exit_code,
242 );
243
244 if result.duration.as_millis() == 0 {
245 result.duration = exec_output.duration;
246 }
247
248 self.event_bus.emit(TestEvent::RunFinished {
249 result: result.clone(),
250 });
251 self.event_bus.flush();
252
253 Ok((result, exec_output))
254 }
255
256 fn resolve_adapter(&self) -> Result<(usize, String, String)> {
258 if let Some(name) = &self.config.adapter_override {
259 let index = self
260 .engine
261 .adapters()
262 .iter()
263 .position(|a| a.name().to_lowercase() == name.to_lowercase())
264 .ok_or_else(|| TestxError::AdapterNotFound { name: name.clone() })?;
265
266 let adapter = self.engine.adapter(index);
267 Ok((
268 index,
269 adapter.name().to_string(),
270 adapter.name().to_string(),
271 ))
272 } else {
273 let detected = self
274 .engine
275 .detect(&self.config.project_dir)
276 .ok_or_else(|| TestxError::NoFrameworkDetected {
277 path: self.config.project_dir.clone(),
278 })?;
279
280 let adapter = self.engine.adapter(detected.adapter_index);
281 Ok((
282 detected.adapter_index,
283 adapter.name().to_string(),
284 detected.detection.framework.clone(),
285 ))
286 }
287 }
288
289 fn execute_command(&mut self, cmd: &mut Command) -> Result<ExecutionOutput> {
291 let start = Instant::now();
292
293 let mut child = cmd
294 .stdout(Stdio::piped())
295 .stderr(Stdio::piped())
296 .spawn()
297 .map_err(|e| TestxError::ExecutionFailed {
298 command: format!("{:?}", cmd),
299 source: e,
300 })?;
301
302 let child_stdout = child.stdout.take();
304 let child_stderr = child.stderr.take();
305
306 let (tx, rx) = mpsc::channel();
308
309 let tx_out = tx.clone();
311 let stdout_handle = thread::spawn(move || {
312 let mut lines = Vec::new();
313 if let Some(pipe) = child_stdout {
314 let reader = BufReader::new(pipe);
315 for line in reader.lines().map_while(|r| r.ok()) {
316 let _ = tx_out.send((Stream::Stdout, line.clone()));
317 lines.push(line);
318 }
319 }
320 lines
321 });
322
323 let stderr_handle = thread::spawn(move || {
325 let mut lines = Vec::new();
326 if let Some(pipe) = child_stderr {
327 let reader = BufReader::new(pipe);
328 for line in reader.lines().map_while(|r| r.ok()) {
329 let _ = tx.send((Stream::Stderr, line.clone()));
330 lines.push(line);
331 }
332 }
333 lines
334 });
335
336 let timeout = self.config.timeout;
338 let mut timed_out = false;
339
340 if let Some(timeout_dur) = timeout {
342 loop {
343 match rx.recv_timeout(Duration::from_millis(100)) {
344 Ok((stream, line)) => {
345 self.event_bus.emit(TestEvent::RawOutput { stream, line });
346 }
347 Err(mpsc::RecvTimeoutError::Timeout) => {
348 if start.elapsed() > timeout_dur {
349 timed_out = true;
350 let _ = child.kill();
351 let _ = child.wait();
352 break;
353 }
354 }
355 Err(mpsc::RecvTimeoutError::Disconnected) => break,
356 }
357 }
358 } else {
359 for (stream, line) in rx {
361 self.event_bus.emit(TestEvent::RawOutput { stream, line });
362 }
363 }
364
365 let stdout_lines = stdout_handle.join().unwrap_or_default();
367 let stderr_lines = stderr_handle.join().unwrap_or_default();
368
369 let exit_code = if timed_out {
370 124
371 } else {
372 child.wait().map(|s| s.code().unwrap_or(1)).unwrap_or(1)
373 };
374
375 let duration = start.elapsed();
376
377 if timed_out && let Some(secs) = self.config.timeout {
378 self.event_bus.emit(TestEvent::Warning {
379 message: format!("Test timed out after {}s", secs.as_secs()),
380 });
381 }
382
383 Ok(ExecutionOutput {
384 stdout: stdout_lines.join("\n"),
385 stderr: stderr_lines.join("\n"),
386 exit_code,
387 duration,
388 timed_out,
389 })
390 }
391}
392
393pub fn build_runner_config(
395 project_dir: PathBuf,
396 config: &Config,
397 extra_args: Vec<String>,
398 timeout: Option<u64>,
399 verbose: bool,
400) -> RunnerConfig {
401 let mut rc = RunnerConfig::new(project_dir);
402 rc.extra_args = extra_args;
403 rc.timeout = timeout.map(Duration::from_secs);
404 rc.verbose = verbose;
405 rc.merge_config(config);
406 rc
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn runner_config_default() {
415 let cfg = RunnerConfig::default();
416 assert_eq!(cfg.project_dir, PathBuf::from("."));
417 assert!(cfg.adapter_override.is_none());
418 assert!(cfg.extra_args.is_empty());
419 assert!(cfg.timeout.is_none());
420 assert!(cfg.env.is_empty());
421 assert_eq!(cfg.retries, 0);
422 assert!(!cfg.fail_fast);
423 assert!(cfg.filter.is_none());
424 assert!(cfg.exclude.is_none());
425 assert!(!cfg.verbose);
426 }
427
428 #[test]
429 fn runner_config_new() {
430 let cfg = RunnerConfig::new(PathBuf::from("/tmp/project"));
431 assert_eq!(cfg.project_dir, PathBuf::from("/tmp/project"));
432 }
433
434 #[test]
435 fn runner_config_merge_config() {
436 let mut rc = RunnerConfig::new(PathBuf::from("."));
437
438 let config = Config {
439 adapter: Some("python".into()),
440 args: vec!["-v".into()],
441 timeout: Some(60),
442 env: HashMap::from([("CI".into(), "true".into())]),
443 ..Default::default()
444 };
445
446 rc.merge_config(&config);
447
448 assert_eq!(rc.adapter_override.as_deref(), Some("python"));
449 assert_eq!(rc.extra_args, vec!["-v"]);
450 assert_eq!(rc.timeout, Some(Duration::from_secs(60)));
451 assert_eq!(rc.env.get("CI").map(|s| s.as_str()), Some("true"));
452 }
453
454 #[test]
455 fn runner_config_merge_cli_takes_precedence() {
456 let mut rc = RunnerConfig::new(PathBuf::from("."));
457 rc.adapter_override = Some("rust".into());
458 rc.extra_args = vec!["--release".into()];
459 rc.timeout = Some(Duration::from_secs(30));
460 rc.env.insert("CI".into(), "false".into());
461
462 let config = Config {
463 adapter: Some("python".into()),
464 args: vec!["-v".into()],
465 timeout: Some(60),
466 env: HashMap::from([("CI".into(), "true".into())]),
467 ..Default::default()
468 };
469
470 rc.merge_config(&config);
471
472 assert_eq!(rc.adapter_override.as_deref(), Some("rust"));
474 assert_eq!(rc.extra_args, vec!["--release"]);
475 assert_eq!(rc.timeout, Some(Duration::from_secs(30)));
476 assert_eq!(rc.env.get("CI").map(|s| s.as_str()), Some("false"));
477 }
478
479 #[test]
480 fn build_runner_config_function() {
481 let mut config = Config::default();
482 config.env.insert("FOO".into(), "bar".into());
483
484 let rc = build_runner_config(
485 PathBuf::from("/tmp"),
486 &config,
487 vec!["--arg".into()],
488 Some(30),
489 true,
490 );
491
492 assert_eq!(rc.project_dir, PathBuf::from("/tmp"));
493 assert_eq!(rc.extra_args, vec!["--arg"]);
494 assert_eq!(rc.timeout, Some(Duration::from_secs(30)));
495 assert!(rc.verbose);
496 assert_eq!(rc.env.get("FOO").map(|s| s.as_str()), Some("bar"));
497 }
498
499 #[test]
500 fn runner_new() {
501 let cfg = RunnerConfig::new(PathBuf::from("."));
502 let runner = Runner::new(cfg);
503 assert_eq!(runner.config().project_dir, PathBuf::from("."));
504 assert_eq!(runner.event_bus().handler_count(), 0);
505 }
506
507 #[test]
508 fn runner_with_event_bus() {
509 use crate::events::CountingHandler;
510
511 let cfg = RunnerConfig::new(PathBuf::from("."));
512 let mut bus = EventBus::new();
513 bus.subscribe(Box::new(CountingHandler::default()));
514
515 let runner = Runner::new(cfg).with_event_bus(bus);
516 assert_eq!(runner.event_bus().handler_count(), 1);
517 }
518
519 #[test]
520 fn runner_resolve_adapter_not_found() {
521 let mut cfg = RunnerConfig::new(PathBuf::from("."));
522 cfg.adapter_override = Some("nonexistent_language".into());
523
524 let runner = Runner::new(cfg);
525 let result = runner.resolve_adapter();
526 assert!(result.is_err());
527
528 match result.unwrap_err() {
529 TestxError::AdapterNotFound { name } => {
530 assert_eq!(name, "nonexistent_language");
531 }
532 other => panic!("expected AdapterNotFound, got: {}", other),
533 }
534 }
535
536 #[test]
537 fn runner_resolve_adapter_by_name() {
538 let dir = tempfile::tempdir().unwrap();
539 let mut cfg = RunnerConfig::new(dir.path().to_path_buf());
540 cfg.adapter_override = Some("Rust".into());
541
542 let runner = Runner::new(cfg);
543 let (index, name, _) = runner.resolve_adapter().unwrap();
544 assert_eq!(name, "Rust");
545 assert!(index < runner.engine().adapters().len());
546 }
547
548 #[test]
549 fn runner_resolve_adapter_case_insensitive() {
550 let dir = tempfile::tempdir().unwrap();
551 let mut cfg = RunnerConfig::new(dir.path().to_path_buf());
552 cfg.adapter_override = Some("python".into());
553
554 let runner = Runner::new(cfg);
555 let (_, name, _) = runner.resolve_adapter().unwrap();
556 assert_eq!(name, "Python");
557 }
558
559 #[test]
560 fn runner_resolve_adapter_auto_detect() {
561 let dir = tempfile::tempdir().unwrap();
562 std::fs::write(
563 dir.path().join("Cargo.toml"),
564 "[package]\nname = \"test\"\n",
565 )
566 .unwrap();
567
568 let cfg = RunnerConfig::new(dir.path().to_path_buf());
569 let runner = Runner::new(cfg);
570 let (_, name, framework) = runner.resolve_adapter().unwrap();
571 assert_eq!(name, "Rust");
572 assert_eq!(framework, "cargo test");
573 }
574
575 #[test]
576 fn runner_resolve_adapter_no_framework() {
577 let dir = tempfile::tempdir().unwrap();
578 let cfg = RunnerConfig::new(dir.path().to_path_buf());
579 let runner = Runner::new(cfg);
580 let result = runner.resolve_adapter();
581 assert!(result.is_err());
582
583 match result.unwrap_err() {
584 TestxError::NoFrameworkDetected { path } => {
585 assert_eq!(path, dir.path().to_path_buf());
586 }
587 other => panic!("expected NoFrameworkDetected, got: {}", other),
588 }
589 }
590
591 #[test]
592 fn execution_output_fields() {
593 let output = ExecutionOutput {
594 stdout: "hello".into(),
595 stderr: "world".into(),
596 exit_code: 0,
597 duration: Duration::from_millis(100),
598 timed_out: false,
599 };
600
601 assert_eq!(output.stdout, "hello");
602 assert_eq!(output.stderr, "world");
603 assert_eq!(output.exit_code, 0);
604 assert!(!output.timed_out);
605 }
606
607 #[test]
608 fn execution_output_timed_out() {
609 let output = ExecutionOutput {
610 stdout: String::new(),
611 stderr: "Timed out".into(),
612 exit_code: 124,
613 duration: Duration::from_secs(30),
614 timed_out: true,
615 };
616
617 assert!(output.timed_out);
618 assert_eq!(output.exit_code, 124);
619 }
620}