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.filter(|&t| t > 0).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 #[cfg(unix)]
295 {
296 use std::os::unix::process::CommandExt;
297 unsafe {
300 cmd.pre_exec(|| {
301 if libc::setpgid(0, 0) != 0 {
302 return Err(std::io::Error::last_os_error());
303 }
304 Ok(())
305 });
306 }
307 }
308
309 let mut child = cmd
310 .stdout(Stdio::piped())
311 .stderr(Stdio::piped())
312 .spawn()
313 .map_err(|e| TestxError::ExecutionFailed {
314 command: format!("{:?}", cmd),
315 source: e,
316 })?;
317
318 let child_stdout = child.stdout.take();
320 let child_stderr = child.stderr.take();
321
322 let (tx, rx) = mpsc::channel();
324
325 let tx_out = tx.clone();
327 let stdout_handle = thread::spawn(move || {
328 let mut lines = Vec::new();
329 if let Some(pipe) = child_stdout {
330 let reader = BufReader::new(pipe);
331 for line in reader.lines().map_while(|r| r.ok()) {
332 let _ = tx_out.send((Stream::Stdout, line.clone()));
333 lines.push(line);
334 }
335 }
336 lines
337 });
338
339 let stderr_handle = thread::spawn(move || {
341 let mut lines = Vec::new();
342 if let Some(pipe) = child_stderr {
343 let reader = BufReader::new(pipe);
344 for line in reader.lines().map_while(|r| r.ok()) {
345 let _ = tx.send((Stream::Stderr, line.clone()));
346 lines.push(line);
347 }
348 }
349 lines
350 });
351
352 let timeout = self.config.timeout;
354 let mut timed_out = false;
355
356 if let Some(timeout_dur) = timeout {
358 loop {
359 match rx.recv_timeout(Duration::from_millis(100)) {
360 Ok((stream, line)) => {
361 self.event_bus.emit(TestEvent::RawOutput { stream, line });
362 }
363 Err(mpsc::RecvTimeoutError::Timeout) => {
364 if start.elapsed() > timeout_dur {
365 timed_out = true;
366 #[cfg(unix)]
368 {
369 let pid = child.id() as libc::pid_t;
371 if unsafe { libc::kill(-pid, libc::SIGKILL) } != 0 {
372 eprintln!(
373 "warning: failed to kill process group {}: {}",
374 pid,
375 std::io::Error::last_os_error()
376 );
377 let _ = child.kill();
378 }
379 }
380 #[cfg(not(unix))]
381 {
382 let _ = child.kill();
383 }
384 let _ = child.wait();
385 break;
386 }
387 }
388 Err(mpsc::RecvTimeoutError::Disconnected) => break,
389 }
390 }
391 } else {
392 for (stream, line) in rx {
394 self.event_bus.emit(TestEvent::RawOutput { stream, line });
395 }
396 }
397
398 let stdout_lines = match stdout_handle.join() {
400 Ok(lines) => lines,
401 Err(_) => {
402 eprintln!("warning: stdout reader thread panicked");
403 Vec::new()
404 }
405 };
406 let stderr_lines = match stderr_handle.join() {
407 Ok(lines) => lines,
408 Err(_) => {
409 eprintln!("warning: stderr reader thread panicked");
410 Vec::new()
411 }
412 };
413
414 let exit_code = if timed_out {
415 124
416 } else {
417 child.wait().map(|s| s.code().unwrap_or(1)).unwrap_or(1)
418 };
419
420 let duration = start.elapsed();
421
422 if timed_out && let Some(secs) = self.config.timeout {
423 self.event_bus.emit(TestEvent::Warning {
424 message: format!("Test timed out after {}s", secs.as_secs()),
425 });
426 }
427
428 Ok(ExecutionOutput {
429 stdout: stdout_lines.join("\n"),
430 stderr: stderr_lines.join("\n"),
431 exit_code,
432 duration,
433 timed_out,
434 })
435 }
436}
437
438pub fn build_runner_config(
440 project_dir: PathBuf,
441 config: &Config,
442 extra_args: Vec<String>,
443 timeout: Option<u64>,
444 verbose: bool,
445) -> RunnerConfig {
446 let mut rc = RunnerConfig::new(project_dir);
447 rc.extra_args = extra_args;
448 rc.timeout = timeout.map(Duration::from_secs);
449 rc.verbose = verbose;
450 rc.merge_config(config);
451 rc
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn runner_config_default() {
460 let cfg = RunnerConfig::default();
461 assert_eq!(cfg.project_dir, PathBuf::from("."));
462 assert!(cfg.adapter_override.is_none());
463 assert!(cfg.extra_args.is_empty());
464 assert!(cfg.timeout.is_none());
465 assert!(cfg.env.is_empty());
466 assert_eq!(cfg.retries, 0);
467 assert!(!cfg.fail_fast);
468 assert!(cfg.filter.is_none());
469 assert!(cfg.exclude.is_none());
470 assert!(!cfg.verbose);
471 }
472
473 #[test]
474 fn runner_config_new() {
475 let cfg = RunnerConfig::new(PathBuf::from("/tmp/project"));
476 assert_eq!(cfg.project_dir, PathBuf::from("/tmp/project"));
477 }
478
479 #[test]
480 fn runner_config_merge_config() {
481 let mut rc = RunnerConfig::new(PathBuf::from("."));
482
483 let config = Config {
484 adapter: Some("python".into()),
485 args: vec!["-v".into()],
486 timeout: Some(60),
487 env: HashMap::from([("CI".into(), "true".into())]),
488 ..Default::default()
489 };
490
491 rc.merge_config(&config);
492
493 assert_eq!(rc.adapter_override.as_deref(), Some("python"));
494 assert_eq!(rc.extra_args, vec!["-v"]);
495 assert_eq!(rc.timeout, Some(Duration::from_secs(60)));
496 assert_eq!(rc.env.get("CI").map(|s| s.as_str()), Some("true"));
497 }
498
499 #[test]
500 fn runner_config_merge_cli_takes_precedence() {
501 let mut rc = RunnerConfig::new(PathBuf::from("."));
502 rc.adapter_override = Some("rust".into());
503 rc.extra_args = vec!["--release".into()];
504 rc.timeout = Some(Duration::from_secs(30));
505 rc.env.insert("CI".into(), "false".into());
506
507 let config = Config {
508 adapter: Some("python".into()),
509 args: vec!["-v".into()],
510 timeout: Some(60),
511 env: HashMap::from([("CI".into(), "true".into())]),
512 ..Default::default()
513 };
514
515 rc.merge_config(&config);
516
517 assert_eq!(rc.adapter_override.as_deref(), Some("rust"));
519 assert_eq!(rc.extra_args, vec!["--release"]);
520 assert_eq!(rc.timeout, Some(Duration::from_secs(30)));
521 assert_eq!(rc.env.get("CI").map(|s| s.as_str()), Some("false"));
522 }
523
524 #[test]
525 fn build_runner_config_function() {
526 let mut config = Config::default();
527 config.env.insert("FOO".into(), "bar".into());
528
529 let rc = build_runner_config(
530 PathBuf::from("/tmp"),
531 &config,
532 vec!["--arg".into()],
533 Some(30),
534 true,
535 );
536
537 assert_eq!(rc.project_dir, PathBuf::from("/tmp"));
538 assert_eq!(rc.extra_args, vec!["--arg"]);
539 assert_eq!(rc.timeout, Some(Duration::from_secs(30)));
540 assert!(rc.verbose);
541 assert_eq!(rc.env.get("FOO").map(|s| s.as_str()), Some("bar"));
542 }
543
544 #[test]
545 fn runner_new() {
546 let cfg = RunnerConfig::new(PathBuf::from("."));
547 let runner = Runner::new(cfg);
548 assert_eq!(runner.config().project_dir, PathBuf::from("."));
549 assert_eq!(runner.event_bus().handler_count(), 0);
550 }
551
552 #[test]
553 fn runner_with_event_bus() {
554 use crate::events::CountingHandler;
555
556 let cfg = RunnerConfig::new(PathBuf::from("."));
557 let mut bus = EventBus::new();
558 bus.subscribe(Box::new(CountingHandler::default()));
559
560 let runner = Runner::new(cfg).with_event_bus(bus);
561 assert_eq!(runner.event_bus().handler_count(), 1);
562 }
563
564 #[test]
565 fn runner_resolve_adapter_not_found() {
566 let mut cfg = RunnerConfig::new(PathBuf::from("."));
567 cfg.adapter_override = Some("nonexistent_language".into());
568
569 let runner = Runner::new(cfg);
570 let result = runner.resolve_adapter();
571 assert!(result.is_err());
572
573 match result.unwrap_err() {
574 TestxError::AdapterNotFound { name } => {
575 assert_eq!(name, "nonexistent_language");
576 }
577 other => panic!("expected AdapterNotFound, got: {}", other),
578 }
579 }
580
581 #[test]
582 fn runner_resolve_adapter_by_name() {
583 let dir = tempfile::tempdir().unwrap();
584 let mut cfg = RunnerConfig::new(dir.path().to_path_buf());
585 cfg.adapter_override = Some("Rust".into());
586
587 let runner = Runner::new(cfg);
588 let (index, name, _) = runner.resolve_adapter().unwrap();
589 assert_eq!(name, "Rust");
590 assert!(index < runner.engine().adapters().len());
591 }
592
593 #[test]
594 fn runner_resolve_adapter_case_insensitive() {
595 let dir = tempfile::tempdir().unwrap();
596 let mut cfg = RunnerConfig::new(dir.path().to_path_buf());
597 cfg.adapter_override = Some("python".into());
598
599 let runner = Runner::new(cfg);
600 let (_, name, _) = runner.resolve_adapter().unwrap();
601 assert_eq!(name, "Python");
602 }
603
604 #[test]
605 fn runner_resolve_adapter_auto_detect() {
606 let dir = tempfile::tempdir().unwrap();
607 std::fs::write(
608 dir.path().join("Cargo.toml"),
609 "[package]\nname = \"test\"\n",
610 )
611 .unwrap();
612
613 let cfg = RunnerConfig::new(dir.path().to_path_buf());
614 let runner = Runner::new(cfg);
615 let (_, name, framework) = runner.resolve_adapter().unwrap();
616 assert_eq!(name, "Rust");
617 assert_eq!(framework, "cargo test");
618 }
619
620 #[test]
621 fn runner_resolve_adapter_no_framework() {
622 let dir = tempfile::tempdir().unwrap();
623 let cfg = RunnerConfig::new(dir.path().to_path_buf());
624 let runner = Runner::new(cfg);
625 let result = runner.resolve_adapter();
626 assert!(result.is_err());
627
628 match result.unwrap_err() {
629 TestxError::NoFrameworkDetected { path } => {
630 assert_eq!(path, dir.path().to_path_buf());
631 }
632 other => panic!("expected NoFrameworkDetected, got: {}", other),
633 }
634 }
635
636 #[test]
637 fn execution_output_fields() {
638 let output = ExecutionOutput {
639 stdout: "hello".into(),
640 stderr: "world".into(),
641 exit_code: 0,
642 duration: Duration::from_millis(100),
643 timed_out: false,
644 };
645
646 assert_eq!(output.stdout, "hello");
647 assert_eq!(output.stderr, "world");
648 assert_eq!(output.exit_code, 0);
649 assert!(!output.timed_out);
650 }
651
652 #[test]
653 fn execution_output_timed_out() {
654 let output = ExecutionOutput {
655 stdout: String::new(),
656 stderr: "Timed out".into(),
657 exit_code: 124,
658 duration: Duration::from_secs(30),
659 timed_out: true,
660 };
661
662 assert!(output.timed_out);
663 assert_eq!(output.exit_code, 124);
664 }
665}