Skip to main content

testx/watcher/
runner.rs

1use std::path::PathBuf;
2use std::time::{Duration, Instant};
3
4use colored::Colorize;
5
6use crate::adapters::TestRunResult;
7use crate::config::{Config, WatchConfig};
8use crate::error::{Result, TestxError};
9use crate::events::EventBus;
10use crate::runner::{Runner, RunnerConfig};
11use crate::watcher::file_watcher::FileWatcher;
12use crate::watcher::terminal::{
13    TerminalInput, WatchAction, clear_screen, print_watch_separator, print_watch_start,
14    print_watch_status,
15};
16
17/// Statistics tracked across watch mode re-runs.
18#[derive(Debug, Clone, Default)]
19pub struct WatchStats {
20    /// Total number of re-runs performed.
21    pub total_runs: u32,
22    /// Number of runs that had failures.
23    pub failed_runs: u32,
24    /// Number of runs that passed completely.
25    pub passed_runs: u32,
26    /// Time of the last run.
27    pub last_run: Option<Instant>,
28    /// Duration of the last run.
29    pub last_duration: Option<Duration>,
30    /// Number of tests that failed in the last run.
31    pub last_failures: u32,
32    /// Number of tests that passed in the last run.
33    pub last_passed: u32,
34}
35
36impl WatchStats {
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Record the result of a test run.
42    pub fn record_run(&mut self, result: &TestRunResult, duration: Duration) {
43        self.total_runs += 1;
44        self.last_run = Some(Instant::now());
45        self.last_duration = Some(duration);
46        self.last_failures = result.total_failed() as u32;
47        self.last_passed = result.total_passed() as u32;
48
49        if result.total_failed() > 0 {
50            self.failed_runs += 1;
51        } else {
52            self.passed_runs += 1;
53        }
54    }
55
56    /// Format a summary line for display.
57    pub fn summary(&self) -> String {
58        format!(
59            "runs: {} total, {} passed, {} failed",
60            self.total_runs, self.passed_runs, self.failed_runs
61        )
62    }
63}
64
65/// Options controlling watch runner behavior.
66#[derive(Debug, Clone)]
67pub struct WatchRunnerOptions {
68    /// Clear screen between runs.
69    pub clear_screen: bool,
70    /// Only re-run failed tests (when triggered by 'f' key).
71    pub run_failed_only: bool,
72    /// Debounce time in milliseconds.
73    pub debounce_ms: u64,
74    /// Maximum number of re-runs (0 = unlimited, useful for testing).
75    pub max_runs: u32,
76    /// Extra arguments to pass to the test runner.
77    pub extra_args: Vec<String>,
78    /// Verbose mode.
79    pub verbose: bool,
80}
81
82impl Default for WatchRunnerOptions {
83    fn default() -> Self {
84        Self {
85            clear_screen: true,
86            run_failed_only: false,
87            debounce_ms: 300,
88            max_runs: 0,
89            extra_args: Vec::new(),
90            verbose: false,
91        }
92    }
93}
94
95impl WatchRunnerOptions {
96    /// Create options from a WatchConfig and extra CLI settings.
97    pub fn from_config(config: &WatchConfig) -> Self {
98        Self {
99            clear_screen: config.clear,
100            debounce_ms: config.debounce_ms,
101            ..Default::default()
102        }
103    }
104}
105
106/// The main watch mode runner loop.
107///
108/// Orchestrates file watching, terminal input, and test execution
109/// in a continuous loop until the user quits.
110pub struct WatchRunner {
111    /// Root project directory.
112    project_dir: PathBuf,
113    /// Runner configuration template for each run.
114    runner_config: RunnerConfig,
115    /// Watch-specific options.
116    options: WatchRunnerOptions,
117    /// Accumulated statistics.
118    stats: WatchStats,
119    /// Names of tests that failed in the last run.
120    failed_tests: Vec<String>,
121    /// Cached adapter name from first detection (avoids rescanning all adapters).
122    cached_adapter: Option<String>,
123}
124
125impl WatchRunner {
126    /// Create a new WatchRunner.
127    pub fn new(
128        project_dir: PathBuf,
129        runner_config: RunnerConfig,
130        options: WatchRunnerOptions,
131    ) -> Self {
132        Self {
133            project_dir,
134            runner_config,
135            options,
136            stats: WatchStats::new(),
137            failed_tests: Vec::new(),
138            cached_adapter: None,
139        }
140    }
141
142    /// Create a WatchRunner from a Config file.
143    pub fn from_config(project_dir: PathBuf, config: &Config) -> Self {
144        let mut runner_config = RunnerConfig::new(project_dir.clone());
145        runner_config.merge_config(config);
146
147        let watch_config = config.watch_config();
148        let options = WatchRunnerOptions::from_config(&watch_config);
149
150        Self::new(project_dir, runner_config, options)
151    }
152
153    /// Get current watch statistics.
154    pub fn stats(&self) -> &WatchStats {
155        &self.stats
156    }
157
158    /// Get the list of tests that failed in the last run.
159    pub fn failed_tests(&self) -> &[String] {
160        &self.failed_tests
161    }
162
163    /// Start the watch mode loop.
164    ///
165    /// This method blocks until the user presses 'q' or max_runs is reached.
166    /// Returns the final watch statistics.
167    pub fn start(&mut self, watch_config: &WatchConfig) -> Result<WatchStats> {
168        // Create the file watcher
169        let mut watcher = FileWatcher::new(&self.project_dir, watch_config).map_err(|e| {
170            TestxError::WatchError {
171                message: format!("Failed to start file watcher: {}", e),
172            }
173        })?;
174
175        // Start terminal input reader
176        let terminal = TerminalInput::new();
177
178        print_watch_start(&self.project_dir);
179
180        // Initial run
181        self.execute_run()?;
182
183        loop {
184            // Check max run limit
185            if self.options.max_runs > 0 && self.stats.total_runs >= self.options.max_runs {
186                break;
187            }
188
189            // Check for user input (non-blocking)
190            match terminal.poll() {
191                WatchAction::Quit => {
192                    self.print_final_summary();
193                    break;
194                }
195                WatchAction::RunAll => {
196                    self.options.run_failed_only = false;
197                    if self.options.clear_screen {
198                        clear_screen();
199                    }
200                    print_watch_separator();
201                    self.execute_run()?;
202                    continue;
203                }
204                WatchAction::RunFailed => {
205                    self.options.run_failed_only = true;
206                    if self.options.clear_screen {
207                        clear_screen();
208                    }
209                    print_watch_separator();
210                    self.execute_run()?;
211                    continue;
212                }
213                WatchAction::ClearAndRun => {
214                    clear_screen();
215                    print_watch_separator();
216                    self.execute_run()?;
217                    continue;
218                }
219                WatchAction::Continue => {}
220            }
221
222            // Wait for file changes (with timeout so we can poll terminal)
223            let changed = self.poll_changes_with_timeout(&mut watcher, Duration::from_millis(200));
224
225            if !changed.is_empty() {
226                if self.options.verbose {
227                    for path in &changed {
228                        eprintln!("  {} {}", "changed:".dimmed(), path.display());
229                    }
230                }
231
232                if self.options.clear_screen {
233                    clear_screen();
234                }
235
236                print_watch_separator();
237                print_watch_status(changed.len());
238
239                self.execute_run()?;
240            }
241        }
242
243        Ok(self.stats.clone())
244    }
245
246    /// Execute a single test run.
247    fn execute_run(&mut self) -> Result<()> {
248        let mut config = self.runner_config.clone();
249
250        // If running only failed tests, set the filter
251        if self.options.run_failed_only && !self.failed_tests.is_empty() {
252            let filter = self.failed_tests.join("|");
253            config.filter = Some(filter);
254            println!(
255                "  {} {}",
256                "re-running".yellow().bold(),
257                format!("{} failed test(s)", self.failed_tests.len()).dimmed()
258            );
259        }
260
261        // Reuse the adapter detected on the first run so we don't rescan
262        // all 11 adapters on every file change.
263        if config.adapter_override.is_none()
264            && let Some(ref cached) = self.cached_adapter
265        {
266            config.adapter_override = Some(cached.clone());
267        }
268
269        let event_bus = EventBus::new();
270        let mut runner = Runner::new(config).with_event_bus(event_bus);
271
272        // Cache the adapter name after the first successful detection
273        if self.cached_adapter.is_none() {
274            let engine = runner.engine();
275            if let Some(detected) = engine.detect(&self.project_dir) {
276                let name = engine.adapter(detected.adapter_index).name().to_string();
277                self.cached_adapter = Some(name);
278            }
279        }
280
281        let start = Instant::now();
282        let result = runner.run();
283        let elapsed = start.elapsed();
284
285        match result {
286            Ok((test_result, _exec_output)) => {
287                self.stats.record_run(&test_result, elapsed);
288
289                // Track failed test names for "run failed only" mode
290                self.failed_tests = test_result
291                    .suites
292                    .iter()
293                    .flat_map(|s| s.tests.iter())
294                    .filter(|t| matches!(t.status, crate::adapters::TestStatus::Failed))
295                    .map(|t| t.name.clone())
296                    .collect();
297
298                self.print_run_summary(&test_result, elapsed);
299            }
300            Err(e) => {
301                self.stats.total_runs += 1;
302                self.stats.failed_runs += 1;
303                eprintln!("  {} {}", "error:".red().bold(), e);
304            }
305        }
306
307        Ok(())
308    }
309
310    /// Poll for file changes with a short timeout so the main loop
311    /// can also check for terminal input.
312    fn poll_changes_with_timeout(
313        &self,
314        watcher: &mut FileWatcher,
315        timeout: Duration,
316    ) -> Vec<PathBuf> {
317        watcher.poll_changes(timeout)
318    }
319
320    /// Print summary after a single run.
321    fn print_run_summary(&self, result: &TestRunResult, elapsed: Duration) {
322        let failed = result.total_failed();
323        let passed = result.total_passed();
324        let skipped = result.total_skipped();
325
326        let status = if failed > 0 {
327            format!("FAIL ({} failed)", failed).red().bold()
328        } else {
329            "PASS".green().bold()
330        };
331
332        println!();
333        println!(
334            "  {} {} {} in {:.2}s",
335            status,
336            format!("{} passed", passed).green(),
337            if skipped > 0 {
338                format!(", {} skipped", skipped).yellow().to_string()
339            } else {
340                String::new()
341            },
342            elapsed.as_secs_f64()
343        );
344
345        println!(
346            "  {} {}",
347            "session:".dimmed(),
348            self.stats.summary().dimmed()
349        );
350    }
351
352    /// Print final summary when exiting watch mode.
353    fn print_final_summary(&self) {
354        println!();
355        println!("{}", "─".repeat(60).dimmed());
356        println!(
357            "  {} {}",
358            "watch mode ended".bold(),
359            self.stats.summary().dimmed()
360        );
361        println!();
362    }
363}
364
365/// Convenience function to launch watch mode from CLI args.
366pub fn launch_watch_mode(
367    project_dir: PathBuf,
368    config: &Config,
369    runner_config: RunnerConfig,
370) -> Result<()> {
371    let watch_config = config.watch_config();
372    let options = WatchRunnerOptions::from_config(&watch_config);
373
374    let mut watch_runner = WatchRunner::new(project_dir, runner_config, options);
375    let _stats = watch_runner.start(&watch_config)?;
376
377    Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
384
385    /// Helper to build a TestRunResult with given pass/fail counts.
386    fn make_result(passed: usize, failed: usize) -> TestRunResult {
387        let mut tests = Vec::new();
388        for i in 0..passed {
389            tests.push(TestCase {
390                name: format!("pass_{}", i),
391                status: TestStatus::Passed,
392                duration: Duration::from_millis(10),
393                error: None,
394            });
395        }
396        for i in 0..failed {
397            tests.push(TestCase {
398                name: format!("fail_{}", i),
399                status: TestStatus::Failed,
400                duration: Duration::from_millis(10),
401                error: None,
402            });
403        }
404        TestRunResult {
405            suites: vec![TestSuite {
406                name: "suite".to_string(),
407                tests,
408            }],
409            duration: Duration::from_secs(1),
410            raw_exit_code: if failed > 0 { 1 } else { 0 },
411        }
412    }
413
414    #[test]
415    fn watch_stats_default() {
416        let stats = WatchStats::new();
417        assert_eq!(stats.total_runs, 0);
418        assert_eq!(stats.failed_runs, 0);
419        assert_eq!(stats.passed_runs, 0);
420        assert!(stats.last_run.is_none());
421        assert!(stats.last_duration.is_none());
422    }
423
424    #[test]
425    fn watch_stats_record_passing_run() {
426        let mut stats = WatchStats::new();
427        let result = make_result(5, 0);
428
429        stats.record_run(&result, Duration::from_secs(1));
430
431        assert_eq!(stats.total_runs, 1);
432        assert_eq!(stats.passed_runs, 1);
433        assert_eq!(stats.failed_runs, 0);
434        assert_eq!(stats.last_passed, 5);
435        assert_eq!(stats.last_failures, 0);
436        assert!(stats.last_run.is_some());
437    }
438
439    #[test]
440    fn watch_stats_record_failing_run() {
441        let mut stats = WatchStats::new();
442        let result = make_result(3, 2);
443
444        stats.record_run(&result, Duration::from_secs(2));
445
446        assert_eq!(stats.total_runs, 1);
447        assert_eq!(stats.passed_runs, 0);
448        assert_eq!(stats.failed_runs, 1);
449        assert_eq!(stats.last_passed, 3);
450        assert_eq!(stats.last_failures, 2);
451    }
452
453    #[test]
454    fn watch_stats_multiple_runs() {
455        let mut stats = WatchStats::new();
456
457        let pass = make_result(5, 0);
458        let fail = make_result(3, 2);
459
460        stats.record_run(&pass, Duration::from_secs(1));
461        stats.record_run(&fail, Duration::from_secs(2));
462        stats.record_run(&pass, Duration::from_secs(1));
463
464        assert_eq!(stats.total_runs, 3);
465        assert_eq!(stats.passed_runs, 2);
466        assert_eq!(stats.failed_runs, 1);
467    }
468
469    #[test]
470    fn watch_stats_summary() {
471        let mut stats = WatchStats::new();
472        assert_eq!(stats.summary(), "runs: 0 total, 0 passed, 0 failed");
473
474        let result = make_result(5, 0);
475        stats.record_run(&result, Duration::from_secs(1));
476        assert_eq!(stats.summary(), "runs: 1 total, 1 passed, 0 failed");
477    }
478
479    #[test]
480    fn watch_runner_options_default() {
481        let opts = WatchRunnerOptions::default();
482        assert!(opts.clear_screen);
483        assert!(!opts.run_failed_only);
484        assert_eq!(opts.debounce_ms, 300);
485        assert_eq!(opts.max_runs, 0);
486        assert!(opts.extra_args.is_empty());
487    }
488
489    #[test]
490    fn watch_runner_options_from_config() {
491        let config = WatchConfig {
492            clear: false,
493            debounce_ms: 500,
494            ..Default::default()
495        };
496
497        let opts = WatchRunnerOptions::from_config(&config);
498        assert!(!opts.clear_screen);
499        assert_eq!(opts.debounce_ms, 500);
500    }
501
502    #[test]
503    fn watch_runner_creation() {
504        let dir = PathBuf::from("/tmp/test");
505        let config = RunnerConfig::new(dir.clone());
506        let opts = WatchRunnerOptions::default();
507
508        let runner = WatchRunner::new(dir.clone(), config, opts);
509        assert_eq!(runner.stats().total_runs, 0);
510        assert!(runner.failed_tests().is_empty());
511    }
512
513    #[test]
514    fn watch_runner_from_config() {
515        let dir = PathBuf::from("/tmp/test");
516        let config = Config::default();
517
518        let runner = WatchRunner::from_config(dir, &config);
519        assert_eq!(runner.stats().total_runs, 0);
520    }
521
522    #[test]
523    fn watch_stats_last_duration_recorded() {
524        let mut stats = WatchStats::new();
525        let result = make_result(1, 0);
526
527        let dur = Duration::from_millis(1234);
528        stats.record_run(&result, dur);
529        assert_eq!(stats.last_duration, Some(dur));
530    }
531
532    #[test]
533    fn run_summary_format_pass() {
534        let dir = PathBuf::from("/tmp/test");
535        let config = RunnerConfig::new(dir.clone());
536        let opts = WatchRunnerOptions::default();
537        let runner = WatchRunner::new(dir, config, opts);
538
539        // Just verify summary format with no runs
540        let summary = runner.stats().summary();
541        assert!(summary.contains("0 total"));
542    }
543}