Skip to main content

testx/
runner.rs

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/// Configuration for a test run.
16#[derive(Debug, Clone)]
17pub struct RunnerConfig {
18    /// Project directory to run tests in.
19    pub project_dir: PathBuf,
20
21    /// Override adapter selection by name.
22    pub adapter_override: Option<String>,
23
24    /// Extra arguments to pass to the test runner.
25    pub extra_args: Vec<String>,
26
27    /// Maximum time to wait for test completion.
28    pub timeout: Option<Duration>,
29
30    /// Environment variables to set for the test process.
31    pub env: HashMap<String, String>,
32
33    /// Number of times to retry failed tests.
34    pub retries: u32,
35
36    /// Stop on first test failure.
37    pub fail_fast: bool,
38
39    /// Test name filter pattern.
40    pub filter: Option<String>,
41
42    /// Exclude pattern.
43    pub exclude: Option<String>,
44
45    /// Verbose mode (print commands, detection details).
46    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    /// Merge values from a Config file (CLI args take precedence).
75    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/// Execution result with raw output captured for display purposes.
92#[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
101/// The main test runner engine.
102pub 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    /// Run tests, auto-detecting the adapter or using the configured override.
139    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        // Phase 1: borrow engine immutably to build command and check runner
149        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        // Set environment variables
167        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        // Phase 2: execute (borrows self mutably for event bus)
176        let exec_output = self.execute_command(&mut cmd)?;
177
178        // Phase 3: parse (borrows engine immutably again)
179        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        // Use wall-clock time if parser didn't capture duration
187        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    /// Run tests using a specific adapter by index.
200    pub fn run_with_adapter(
201        &mut self,
202        adapter_index: usize,
203    ) -> Result<(TestRunResult, ExecutionOutput)> {
204        // Phase 1: borrow engine to build command
205        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        // Phase 2: execute
234        let exec_output = self.execute_command(&mut cmd)?;
235
236        // Phase 3: parse
237        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    /// Resolve which adapter to use: explicit override or auto-detect.
257    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    /// Execute a command, streaming output line-by-line and respecting timeouts.
290    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        // Take ownership of stdout/stderr pipes
303        let child_stdout = child.stdout.take();
304        let child_stderr = child.stderr.take();
305
306        // Channel for collecting lines from both streams
307        let (tx, rx) = mpsc::channel();
308
309        // Spawn stdout reader thread
310        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        // Spawn stderr reader thread
324        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        // Process events from stream readers
337        let timeout = self.config.timeout;
338        let mut timed_out = false;
339
340        // Drop rx in a non-blocking way if we have a timeout
341        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            // No timeout — just drain events
360            for (stream, line) in rx {
361                self.event_bus.emit(TestEvent::RawOutput { stream, line });
362            }
363        }
364
365        // Collect results from threads
366        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
393/// Build a RunnerConfig from CLI args and config file.
394pub 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        // CLI values should win
473        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}