1use crate::cli::types::CliConfig;
21use crate::error::{CleanroomError, Result};
22use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcherTrait};
23use std::path::PathBuf;
24use std::sync::mpsc as std_mpsc;
25use tokio::sync::mpsc;
26use tracing::{debug, error, info};
27
28#[derive(Debug, Clone)]
30pub struct WatchEvent {
31 pub path: PathBuf,
33 pub kind: WatchEventKind,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum WatchEventKind {
40 Create,
42 Modify,
44 Delete,
46 Other,
48}
49
50#[derive(Debug, Clone)]
52pub struct WatchConfig {
53 pub paths: Vec<PathBuf>,
55 pub debounce_ms: u64,
57 pub clear_screen: bool,
59 pub cli_config: CliConfig,
61 pub filter_pattern: Option<String>,
63 pub timebox_ms: Option<u64>,
65}
66
67impl WatchConfig {
68 pub fn new(paths: Vec<PathBuf>, debounce_ms: u64, clear_screen: bool) -> Self {
89 Self {
90 paths,
91 debounce_ms,
92 clear_screen,
93 cli_config: CliConfig::default(),
94 filter_pattern: None,
95 timebox_ms: None,
96 }
97 }
98
99 pub fn with_cli_config(mut self, cli_config: CliConfig) -> Self {
119 self.cli_config = cli_config;
120 self
121 }
122
123 pub fn with_filter_pattern(mut self, pattern: String) -> Self {
144 self.filter_pattern = Some(pattern);
145 self
146 }
147
148 pub fn with_timebox(mut self, timebox_ms: u64) -> Self {
169 self.timebox_ms = Some(timebox_ms);
170 self
171 }
172
173 pub fn has_filter_pattern(&self) -> bool {
175 self.filter_pattern.is_some()
176 }
177
178 pub fn has_timebox(&self) -> bool {
180 self.timebox_ms.is_some()
181 }
182}
183
184pub trait FileWatcher: Send + Sync {
190 fn start(&self) -> Result<()>;
196
197 fn stop(&self) -> Result<()>;
199}
200
201#[derive(Debug)]
207pub struct NotifyWatcher {
208 _paths: Vec<PathBuf>,
210 _watcher: RecommendedWatcher,
212}
213
214impl NotifyWatcher {
215 pub fn new(paths: Vec<PathBuf>, tx: mpsc::Sender<WatchEvent>) -> Result<Self> {
250 info!("Creating file watcher for {} path(s)", paths.len());
251
252 let (std_tx, std_rx) = std_mpsc::channel::<notify::Result<Event>>();
255
256 let mut watcher = notify::recommended_watcher(std_tx).map_err(|e| {
258 CleanroomError::internal_error(format!("Failed to create file watcher: {}", e))
259 })?;
260
261 for path in &paths {
263 if !path.exists() {
264 return Err(CleanroomError::validation_error(format!(
265 "Cannot watch non-existent path: {}",
266 path.display()
267 ))
268 .with_context("Path must exist before watching"));
269 }
270
271 info!("Watching path: {}", path.display());
272 watcher.watch(path, RecursiveMode::Recursive).map_err(|e| {
273 CleanroomError::internal_error(format!(
274 "Failed to watch path {}: {}",
275 path.display(),
276 e
277 ))
278 })?;
279 }
280
281 tokio::spawn(async move {
283 while let Ok(res) = std_rx.recv() {
284 match res {
285 Ok(event) => {
286 debug!("File system event: {:?}", event);
287
288 for path in event.paths {
290 let kind = match event.kind {
291 notify::EventKind::Create(_) => WatchEventKind::Create,
292 notify::EventKind::Modify(_) => WatchEventKind::Modify,
293 notify::EventKind::Remove(_) => WatchEventKind::Delete,
294 _ => WatchEventKind::Other,
295 };
296
297 let watch_event = WatchEvent { path, kind };
298
299 if let Err(e) = tx.send(watch_event).await {
301 error!("Failed to send watch event: {}", e);
302 break;
303 }
304 }
305 }
306 Err(e) => {
307 error!("Watch error: {}", e);
308 }
309 }
310 }
311 debug!("File watcher event loop terminated");
312 });
313
314 Ok(Self {
315 _paths: paths,
316 _watcher: watcher,
317 })
318 }
319}
320
321impl FileWatcher for NotifyWatcher {
322 fn start(&self) -> Result<()> {
323 debug!("Watcher already running");
325 Ok(())
326 }
327
328 fn stop(&self) -> Result<()> {
329 debug!("Watcher will stop on drop");
331 Ok(())
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use std::fs;
339 use std::time::Duration;
340
341 #[derive(Default)]
350 struct MockFileWatcher {
351 start_called: std::sync::Arc<std::sync::atomic::AtomicBool>,
352 stop_called: std::sync::Arc<std::sync::atomic::AtomicBool>,
353 }
354
355 impl MockFileWatcher {
356 fn new() -> Self {
357 Self::default()
358 }
359
360 fn was_started(&self) -> bool {
361 self.start_called.load(std::sync::atomic::Ordering::SeqCst)
362 }
363
364 fn was_stopped(&self) -> bool {
365 self.stop_called.load(std::sync::atomic::Ordering::SeqCst)
366 }
367 }
368
369 impl FileWatcher for MockFileWatcher {
370 fn start(&self) -> Result<()> {
371 self.start_called
372 .store(true, std::sync::atomic::Ordering::SeqCst);
373 Ok(())
374 }
375
376 fn stop(&self) -> Result<()> {
377 self.stop_called
378 .store(true, std::sync::atomic::Ordering::SeqCst);
379 Ok(())
380 }
381 }
382
383 #[test]
384 fn test_mock_watcher_starts() -> Result<()> {
385 let watcher = MockFileWatcher::new();
387
388 watcher.start()?;
390
391 assert!(watcher.was_started(), "Watcher should have been started");
393 Ok(())
394 }
395
396 #[test]
397 fn test_mock_watcher_stops() -> Result<()> {
398 let watcher = MockFileWatcher::new();
400
401 watcher.stop()?;
403
404 assert!(watcher.was_stopped(), "Watcher should have been stopped");
406 Ok(())
407 }
408
409 #[test]
410 fn test_mock_watcher_lifecycle() -> Result<()> {
411 let watcher = MockFileWatcher::new();
413
414 watcher.start()?;
416 watcher.stop()?;
417
418 assert!(watcher.was_started(), "Watcher should have been started");
420 assert!(watcher.was_stopped(), "Watcher should have been stopped");
421 Ok(())
422 }
423
424 #[test]
429 fn test_watch_config_creation() {
430 let config = WatchConfig::new(vec![PathBuf::from("tests/")], 300, true);
432
433 assert_eq!(config.paths.len(), 1);
435 assert_eq!(config.debounce_ms, 300);
436 assert!(config.clear_screen);
437 }
438
439 #[test]
440 fn test_watch_config_with_cli_config() {
441 let cli_config = CliConfig {
443 parallel: true,
444 jobs: 4,
445 ..Default::default()
446 };
447
448 let config =
450 WatchConfig::new(vec![PathBuf::from("tests/")], 300, false).with_cli_config(cli_config);
451
452 assert!(config.cli_config.parallel);
454 assert_eq!(config.cli_config.jobs, 4);
455 }
456
457 #[test]
462 fn test_watch_event_creation() {
463 let event = WatchEvent {
465 path: PathBuf::from("test.toml.tera"),
466 kind: WatchEventKind::Modify,
467 };
468
469 assert_eq!(event.path, PathBuf::from("test.toml.tera"));
471 assert_eq!(event.kind, WatchEventKind::Modify);
472 }
473
474 #[test]
475 fn test_watch_event_kinds() {
476 assert_eq!(WatchEventKind::Create, WatchEventKind::Create);
478 assert_eq!(WatchEventKind::Modify, WatchEventKind::Modify);
479 assert_eq!(WatchEventKind::Delete, WatchEventKind::Delete);
480 assert_eq!(WatchEventKind::Other, WatchEventKind::Other);
481
482 assert_ne!(WatchEventKind::Create, WatchEventKind::Modify);
483 }
484
485 #[tokio::test]
490 async fn test_notify_watcher_rejects_nonexistent_path() {
491 let (tx, _rx) = mpsc::channel(100);
493 let paths = vec![PathBuf::from("/nonexistent/path/that/does/not/exist")];
494
495 let result = NotifyWatcher::new(paths, tx);
497
498 assert!(result.is_err());
500 let err = result.unwrap_err();
501 assert!(err.message.contains("non-existent"));
502 }
503
504 #[tokio::test]
505 async fn test_notify_watcher_creates_successfully_with_valid_path() -> Result<()> {
506 let temp_dir = tempfile::tempdir().map_err(|e| {
508 CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
509 })?;
510 let (tx, _rx) = mpsc::channel(100);
511
512 let result = NotifyWatcher::new(vec![temp_dir.path().to_path_buf()], tx);
514
515 assert!(result.is_ok());
517 Ok(())
518 }
519
520 #[tokio::test]
521 #[ignore = "Requires filesystem watching - hangs in test runner"]
522 async fn test_notify_watcher_detects_file_creation() -> Result<()> {
523 let temp_dir = tempfile::tempdir().map_err(|e| {
525 CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
526 })?;
527 let (tx, mut rx) = mpsc::channel(100);
528
529 let _watcher = NotifyWatcher::new(vec![temp_dir.path().to_path_buf()], tx)?;
530
531 tokio::time::sleep(Duration::from_millis(100)).await;
533
534 let test_file = temp_dir.path().join("test.toml.tera");
536 fs::write(&test_file, "# test")
537 .map_err(|e| CleanroomError::internal_error(format!("Failed to write file: {}", e)))?;
538
539 tokio::time::sleep(Duration::from_millis(200)).await;
541
542 let mut received_event = false;
544 while let Ok(event) = rx.try_recv() {
545 if event.path == test_file {
546 received_event = true;
547 break;
548 }
549 }
550
551 assert!(received_event, "Should have received file creation event");
552 Ok(())
553 }
554
555 #[tokio::test]
556 #[ignore = "Requires filesystem watching - hangs in test runner"]
557 async fn test_notify_watcher_detects_file_modification() -> Result<()> {
558 let temp_dir = tempfile::tempdir().map_err(|e| {
560 CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
561 })?;
562 let test_file = temp_dir.path().join("test.toml.tera");
563
564 fs::write(&test_file, "# initial")
566 .map_err(|e| CleanroomError::internal_error(format!("Failed to write file: {}", e)))?;
567
568 let (tx, mut rx) = mpsc::channel(100);
569 let _watcher = NotifyWatcher::new(vec![temp_dir.path().to_path_buf()], tx)?;
570
571 tokio::time::sleep(Duration::from_millis(100)).await;
573
574 fs::write(&test_file, "# modified")
576 .map_err(|e| CleanroomError::internal_error(format!("Failed to write file: {}", e)))?;
577
578 tokio::time::sleep(Duration::from_millis(200)).await;
580
581 let mut received_event = false;
583 while let Ok(event) = rx.try_recv() {
584 if event.path == test_file && event.kind == WatchEventKind::Modify {
585 received_event = true;
586 break;
587 }
588 }
589
590 assert!(
591 received_event,
592 "Should have received file modification event"
593 );
594 Ok(())
595 }
596
597 #[tokio::test]
598 #[ignore = "Requires filesystem watching - hangs in test runner"]
599 async fn test_notify_watcher_watches_multiple_paths() -> Result<()> {
600 let temp_dir1 = tempfile::tempdir().map_err(|e| {
602 CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
603 })?;
604 let temp_dir2 = tempfile::tempdir().map_err(|e| {
605 CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
606 })?;
607
608 let (tx, mut rx) = mpsc::channel(100);
609 let _watcher = NotifyWatcher::new(
610 vec![
611 temp_dir1.path().to_path_buf(),
612 temp_dir2.path().to_path_buf(),
613 ],
614 tx,
615 )?;
616
617 tokio::time::sleep(Duration::from_millis(100)).await;
619
620 let file1 = temp_dir1.path().join("test1.toml.tera");
622 let file2 = temp_dir2.path().join("test2.toml.tera");
623
624 fs::write(&file1, "# test1")
625 .map_err(|e| CleanroomError::internal_error(format!("Failed to write file: {}", e)))?;
626 fs::write(&file2, "# test2")
627 .map_err(|e| CleanroomError::internal_error(format!("Failed to write file: {}", e)))?;
628
629 tokio::time::sleep(Duration::from_millis(200)).await;
631
632 let mut found_file1 = false;
634 let mut found_file2 = false;
635
636 while let Ok(event) = rx.try_recv() {
637 if event.path == file1 {
638 found_file1 = true;
639 }
640 if event.path == file2 {
641 found_file2 = true;
642 }
643 }
644
645 assert!(found_file1, "Should detect changes in first directory");
646 assert!(found_file2, "Should detect changes in second directory");
647 Ok(())
648 }
649}