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}
122
123impl WatchRunner {
124    /// Create a new WatchRunner.
125    pub fn new(
126        project_dir: PathBuf,
127        runner_config: RunnerConfig,
128        options: WatchRunnerOptions,
129    ) -> Self {
130        Self {
131            project_dir,
132            runner_config,
133            options,
134            stats: WatchStats::new(),
135            failed_tests: Vec::new(),
136        }
137    }
138
139    /// Create a WatchRunner from a Config file.
140    pub fn from_config(project_dir: PathBuf, config: &Config) -> Self {
141        let mut runner_config = RunnerConfig::new(project_dir.clone());
142        runner_config.merge_config(config);
143
144        let watch_config = config.watch_config();
145        let options = WatchRunnerOptions::from_config(&watch_config);
146
147        Self::new(project_dir, runner_config, options)
148    }
149
150    /// Get current watch statistics.
151    pub fn stats(&self) -> &WatchStats {
152        &self.stats
153    }
154
155    /// Get the list of tests that failed in the last run.
156    pub fn failed_tests(&self) -> &[String] {
157        &self.failed_tests
158    }
159
160    /// Start the watch mode loop.
161    ///
162    /// This method blocks until the user presses 'q' or max_runs is reached.
163    /// Returns the final watch statistics.
164    pub fn start(&mut self, watch_config: &WatchConfig) -> Result<WatchStats> {
165        // Create the file watcher
166        let mut watcher = FileWatcher::new(&self.project_dir, watch_config).map_err(|e| {
167            TestxError::WatchError {
168                message: format!("Failed to start file watcher: {}", e),
169            }
170        })?;
171
172        // Start terminal input reader
173        let terminal = TerminalInput::new();
174
175        print_watch_start(&self.project_dir);
176
177        // Initial run
178        self.execute_run()?;
179
180        loop {
181            // Check max run limit
182            if self.options.max_runs > 0 && self.stats.total_runs >= self.options.max_runs {
183                break;
184            }
185
186            // Check for user input (non-blocking)
187            match terminal.poll() {
188                WatchAction::Quit => {
189                    self.print_final_summary();
190                    break;
191                }
192                WatchAction::RunAll => {
193                    self.options.run_failed_only = false;
194                    if self.options.clear_screen {
195                        clear_screen();
196                    }
197                    print_watch_separator();
198                    self.execute_run()?;
199                    continue;
200                }
201                WatchAction::RunFailed => {
202                    self.options.run_failed_only = true;
203                    if self.options.clear_screen {
204                        clear_screen();
205                    }
206                    print_watch_separator();
207                    self.execute_run()?;
208                    continue;
209                }
210                WatchAction::ClearAndRun => {
211                    clear_screen();
212                    print_watch_separator();
213                    self.execute_run()?;
214                    continue;
215                }
216                WatchAction::Continue => {}
217            }
218
219            // Wait for file changes (with timeout so we can poll terminal)
220            let changed = self.poll_changes_with_timeout(&mut watcher, Duration::from_millis(200));
221
222            if !changed.is_empty() {
223                if self.options.verbose {
224                    for path in &changed {
225                        eprintln!("  {} {}", "changed:".dimmed(), path.display());
226                    }
227                }
228
229                if self.options.clear_screen {
230                    clear_screen();
231                }
232
233                print_watch_separator();
234                print_watch_status(changed.len());
235
236                self.execute_run()?;
237            }
238        }
239
240        Ok(self.stats.clone())
241    }
242
243    /// Execute a single test run.
244    fn execute_run(&mut self) -> Result<()> {
245        let mut config = self.runner_config.clone();
246
247        // If running only failed tests, set the filter
248        if self.options.run_failed_only && !self.failed_tests.is_empty() {
249            let filter = self.failed_tests.join("|");
250            config.filter = Some(filter);
251            println!(
252                "  {} {}",
253                "re-running".yellow().bold(),
254                format!("{} failed test(s)", self.failed_tests.len()).dimmed()
255            );
256        }
257
258        let event_bus = EventBus::new();
259        let mut runner = Runner::new(config).with_event_bus(event_bus);
260
261        let start = Instant::now();
262        let result = runner.run();
263        let elapsed = start.elapsed();
264
265        match result {
266            Ok((test_result, _exec_output)) => {
267                self.stats.record_run(&test_result, elapsed);
268
269                // Track failed test names for "run failed only" mode
270                self.failed_tests = test_result
271                    .suites
272                    .iter()
273                    .flat_map(|s| s.tests.iter())
274                    .filter(|t| matches!(t.status, crate::adapters::TestStatus::Failed))
275                    .map(|t| t.name.clone())
276                    .collect();
277
278                self.print_run_summary(&test_result, elapsed);
279            }
280            Err(e) => {
281                self.stats.total_runs += 1;
282                self.stats.failed_runs += 1;
283                eprintln!("  {} {}", "error:".red().bold(), e);
284            }
285        }
286
287        Ok(())
288    }
289
290    /// Poll for file changes with a short timeout so the main loop
291    /// can also check for terminal input.
292    fn poll_changes_with_timeout(
293        &self,
294        _watcher: &mut FileWatcher,
295        _timeout: Duration,
296    ) -> Vec<PathBuf> {
297        // We use a non-blocking approach: check for pending events
298        // without fully blocking on wait_for_changes
299        // The FileWatcher.wait_for_changes blocks, so we use try-poll approach
300        // by sleeping a small amount and checking
301        std::thread::sleep(Duration::from_millis(100));
302
303        // Drain any pending events from the watcher's internal channel
304        // This is a simplified approach - the watcher's recv_timeout in
305        // wait_for_changes handles the actual timing
306        Vec::new()
307    }
308
309    /// Print summary after a single run.
310    fn print_run_summary(&self, result: &TestRunResult, elapsed: Duration) {
311        let failed = result.total_failed();
312        let passed = result.total_passed();
313        let skipped = result.total_skipped();
314
315        let status = if failed > 0 {
316            format!("FAIL ({} failed)", failed).red().bold()
317        } else {
318            "PASS".green().bold()
319        };
320
321        println!();
322        println!(
323            "  {} {} {} in {:.2}s",
324            status,
325            format!("{} passed", passed).green(),
326            if skipped > 0 {
327                format!(", {} skipped", skipped).yellow().to_string()
328            } else {
329                String::new()
330            },
331            elapsed.as_secs_f64()
332        );
333
334        println!(
335            "  {} {}",
336            "session:".dimmed(),
337            self.stats.summary().dimmed()
338        );
339    }
340
341    /// Print final summary when exiting watch mode.
342    fn print_final_summary(&self) {
343        println!();
344        println!("{}", "─".repeat(60).dimmed());
345        println!(
346            "  {} {}",
347            "watch mode ended".bold(),
348            self.stats.summary().dimmed()
349        );
350        println!();
351    }
352}
353
354/// Convenience function to launch watch mode from CLI args.
355pub fn launch_watch_mode(
356    project_dir: PathBuf,
357    config: &Config,
358    runner_config: RunnerConfig,
359) -> Result<()> {
360    let watch_config = config.watch_config();
361    let options = WatchRunnerOptions::from_config(&watch_config);
362
363    let mut watch_runner = WatchRunner::new(project_dir, runner_config, options);
364    let _stats = watch_runner.start(&watch_config)?;
365
366    Ok(())
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
373
374    /// Helper to build a TestRunResult with given pass/fail counts.
375    fn make_result(passed: usize, failed: usize) -> TestRunResult {
376        let mut tests = Vec::new();
377        for i in 0..passed {
378            tests.push(TestCase {
379                name: format!("pass_{}", i),
380                status: TestStatus::Passed,
381                duration: Duration::from_millis(10),
382                error: None,
383            });
384        }
385        for i in 0..failed {
386            tests.push(TestCase {
387                name: format!("fail_{}", i),
388                status: TestStatus::Failed,
389                duration: Duration::from_millis(10),
390                error: None,
391            });
392        }
393        TestRunResult {
394            suites: vec![TestSuite {
395                name: "suite".to_string(),
396                tests,
397            }],
398            duration: Duration::from_secs(1),
399            raw_exit_code: if failed > 0 { 1 } else { 0 },
400        }
401    }
402
403    #[test]
404    fn watch_stats_default() {
405        let stats = WatchStats::new();
406        assert_eq!(stats.total_runs, 0);
407        assert_eq!(stats.failed_runs, 0);
408        assert_eq!(stats.passed_runs, 0);
409        assert!(stats.last_run.is_none());
410        assert!(stats.last_duration.is_none());
411    }
412
413    #[test]
414    fn watch_stats_record_passing_run() {
415        let mut stats = WatchStats::new();
416        let result = make_result(5, 0);
417
418        stats.record_run(&result, Duration::from_secs(1));
419
420        assert_eq!(stats.total_runs, 1);
421        assert_eq!(stats.passed_runs, 1);
422        assert_eq!(stats.failed_runs, 0);
423        assert_eq!(stats.last_passed, 5);
424        assert_eq!(stats.last_failures, 0);
425        assert!(stats.last_run.is_some());
426    }
427
428    #[test]
429    fn watch_stats_record_failing_run() {
430        let mut stats = WatchStats::new();
431        let result = make_result(3, 2);
432
433        stats.record_run(&result, Duration::from_secs(2));
434
435        assert_eq!(stats.total_runs, 1);
436        assert_eq!(stats.passed_runs, 0);
437        assert_eq!(stats.failed_runs, 1);
438        assert_eq!(stats.last_passed, 3);
439        assert_eq!(stats.last_failures, 2);
440    }
441
442    #[test]
443    fn watch_stats_multiple_runs() {
444        let mut stats = WatchStats::new();
445
446        let pass = make_result(5, 0);
447        let fail = make_result(3, 2);
448
449        stats.record_run(&pass, Duration::from_secs(1));
450        stats.record_run(&fail, Duration::from_secs(2));
451        stats.record_run(&pass, Duration::from_secs(1));
452
453        assert_eq!(stats.total_runs, 3);
454        assert_eq!(stats.passed_runs, 2);
455        assert_eq!(stats.failed_runs, 1);
456    }
457
458    #[test]
459    fn watch_stats_summary() {
460        let mut stats = WatchStats::new();
461        assert_eq!(stats.summary(), "runs: 0 total, 0 passed, 0 failed");
462
463        let result = make_result(5, 0);
464        stats.record_run(&result, Duration::from_secs(1));
465        assert_eq!(stats.summary(), "runs: 1 total, 1 passed, 0 failed");
466    }
467
468    #[test]
469    fn watch_runner_options_default() {
470        let opts = WatchRunnerOptions::default();
471        assert!(opts.clear_screen);
472        assert!(!opts.run_failed_only);
473        assert_eq!(opts.debounce_ms, 300);
474        assert_eq!(opts.max_runs, 0);
475        assert!(opts.extra_args.is_empty());
476    }
477
478    #[test]
479    fn watch_runner_options_from_config() {
480        let config = WatchConfig {
481            clear: false,
482            debounce_ms: 500,
483            ..Default::default()
484        };
485
486        let opts = WatchRunnerOptions::from_config(&config);
487        assert!(!opts.clear_screen);
488        assert_eq!(opts.debounce_ms, 500);
489    }
490
491    #[test]
492    fn watch_runner_creation() {
493        let dir = PathBuf::from("/tmp/test");
494        let config = RunnerConfig::new(dir.clone());
495        let opts = WatchRunnerOptions::default();
496
497        let runner = WatchRunner::new(dir.clone(), config, opts);
498        assert_eq!(runner.stats().total_runs, 0);
499        assert!(runner.failed_tests().is_empty());
500    }
501
502    #[test]
503    fn watch_runner_from_config() {
504        let dir = PathBuf::from("/tmp/test");
505        let config = Config::default();
506
507        let runner = WatchRunner::from_config(dir, &config);
508        assert_eq!(runner.stats().total_runs, 0);
509    }
510
511    #[test]
512    fn watch_stats_last_duration_recorded() {
513        let mut stats = WatchStats::new();
514        let result = make_result(1, 0);
515
516        let dur = Duration::from_millis(1234);
517        stats.record_run(&result, dur);
518        assert_eq!(stats.last_duration, Some(dur));
519    }
520
521    #[test]
522    fn run_summary_format_pass() {
523        let dir = PathBuf::from("/tmp/test");
524        let config = RunnerConfig::new(dir.clone());
525        let opts = WatchRunnerOptions::default();
526        let runner = WatchRunner::new(dir, config, opts);
527
528        // Just verify summary format with no runs
529        let summary = runner.stats().summary();
530        assert!(summary.contains("0 total"));
531    }
532}