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#[derive(Debug, Clone, Default)]
19pub struct WatchStats {
20 pub total_runs: u32,
22 pub failed_runs: u32,
24 pub passed_runs: u32,
26 pub last_run: Option<Instant>,
28 pub last_duration: Option<Duration>,
30 pub last_failures: u32,
32 pub last_passed: u32,
34}
35
36impl WatchStats {
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 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 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#[derive(Debug, Clone)]
67pub struct WatchRunnerOptions {
68 pub clear_screen: bool,
70 pub run_failed_only: bool,
72 pub debounce_ms: u64,
74 pub max_runs: u32,
76 pub extra_args: Vec<String>,
78 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 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
106pub struct WatchRunner {
111 project_dir: PathBuf,
113 runner_config: RunnerConfig,
115 options: WatchRunnerOptions,
117 stats: WatchStats,
119 failed_tests: Vec<String>,
121 cached_adapter: Option<String>,
123}
124
125impl WatchRunner {
126 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 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 pub fn stats(&self) -> &WatchStats {
155 &self.stats
156 }
157
158 pub fn failed_tests(&self) -> &[String] {
160 &self.failed_tests
161 }
162
163 pub fn start(&mut self, watch_config: &WatchConfig) -> Result<WatchStats> {
168 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 let terminal = TerminalInput::new();
177
178 print_watch_start(&self.project_dir);
179
180 self.execute_run()?;
182
183 loop {
184 if self.options.max_runs > 0 && self.stats.total_runs >= self.options.max_runs {
186 break;
187 }
188
189 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 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 fn execute_run(&mut self) -> Result<()> {
248 let mut config = self.runner_config.clone();
249
250 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 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 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 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 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 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 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
365pub 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 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 let summary = runner.stats().summary();
541 assert!(summary.contains("0 total"));
542 }
543}