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}
122
123impl WatchRunner {
124 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 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 pub fn stats(&self) -> &WatchStats {
152 &self.stats
153 }
154
155 pub fn failed_tests(&self) -> &[String] {
157 &self.failed_tests
158 }
159
160 pub fn start(&mut self, watch_config: &WatchConfig) -> Result<WatchStats> {
165 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 let terminal = TerminalInput::new();
174
175 print_watch_start(&self.project_dir);
176
177 self.execute_run()?;
179
180 loop {
181 if self.options.max_runs > 0 && self.stats.total_runs >= self.options.max_runs {
183 break;
184 }
185
186 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 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 fn execute_run(&mut self) -> Result<()> {
245 let mut config = self.runner_config.clone();
246
247 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 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 fn poll_changes_with_timeout(
293 &self,
294 _watcher: &mut FileWatcher,
295 _timeout: Duration,
296 ) -> Vec<PathBuf> {
297 std::thread::sleep(Duration::from_millis(100));
302
303 Vec::new()
307 }
308
309 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 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
354pub 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 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 let summary = runner.stats().summary();
530 assert!(summary.contains("0 total"));
531 }
532}