clnrm_core/watch/
watcher.rs

1//! File watching implementation using notify crate
2//!
3//! This module provides the file watching abstraction and concrete implementation
4//! following London School TDD principles.
5//!
6//! # London TDD Approach
7//!
8//! - `FileWatcher` trait defines the contract for file watching
9//! - `MockFileWatcher` in tests verifies interactions
10//! - `NotifyWatcher` is the production implementation
11//! - Tests focus on behavior and interactions, not implementation details
12//!
13//! # Core Team Compliance
14//!
15//! - ✅ Proper error handling with CleanroomError
16//! - ✅ No unwrap() or expect() calls
17//! - ✅ Sync trait methods (dyn compatible)
18//! - ✅ Async operations handled via channels
19
20use 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/// File system event
29#[derive(Debug, Clone)]
30pub struct WatchEvent {
31    /// Path to the file that changed
32    pub path: PathBuf,
33    /// Type of change (create, modify, delete)
34    pub kind: WatchEventKind,
35}
36
37/// Type of file system change
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum WatchEventKind {
40    /// File was created
41    Create,
42    /// File was modified
43    Modify,
44    /// File was deleted
45    Delete,
46    /// Other event type
47    Other,
48}
49
50/// Configuration for file watching
51#[derive(Debug, Clone)]
52pub struct WatchConfig {
53    /// Paths to watch (files or directories)
54    pub paths: Vec<PathBuf>,
55    /// Debounce delay in milliseconds
56    pub debounce_ms: u64,
57    /// Whether to clear screen between test runs
58    pub clear_screen: bool,
59    /// CLI configuration for test execution
60    pub cli_config: CliConfig,
61    /// Optional filter pattern for scenario selection (substring match on path)
62    pub filter_pattern: Option<String>,
63    /// Optional timebox limit in milliseconds per scenario
64    pub timebox_ms: Option<u64>,
65}
66
67impl WatchConfig {
68    /// Create new watch configuration
69    ///
70    /// # Arguments
71    ///
72    /// * `paths` - Paths to watch for changes
73    /// * `debounce_ms` - Milliseconds to wait before triggering (typically 200-500ms)
74    /// * `clear_screen` - Whether to clear terminal between runs
75    ///
76    /// # Example
77    ///
78    /// ```
79    /// use clnrm_core::watch::WatchConfig;
80    /// use std::path::PathBuf;
81    ///
82    /// let config = WatchConfig::new(
83    ///     vec![PathBuf::from("tests/")],
84    ///     300,
85    ///     true
86    /// );
87    /// ```
88    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    /// Add CLI configuration for test execution
100    ///
101    /// # Arguments
102    ///
103    /// * `cli_config` - CLI configuration to use when running tests
104    ///
105    /// # Example
106    ///
107    /// ```
108    /// use clnrm_core::watch::WatchConfig;
109    /// use clnrm_core::cli::types::CliConfig;
110    /// use std::path::PathBuf;
111    ///
112    /// let config = WatchConfig::new(
113    ///     vec![PathBuf::from("tests/")],
114    ///     300,
115    ///     false
116    /// ).with_cli_config(CliConfig::default());
117    /// ```
118    pub fn with_cli_config(mut self, cli_config: CliConfig) -> Self {
119        self.cli_config = cli_config;
120        self
121    }
122
123    /// Add filter pattern for scenario selection
124    ///
125    /// Only scenarios whose paths contain this substring will be executed.
126    ///
127    /// # Arguments
128    ///
129    /// * `pattern` - Substring to match against scenario file paths
130    ///
131    /// # Example
132    ///
133    /// ```
134    /// use clnrm_core::watch::WatchConfig;
135    /// use std::path::PathBuf;
136    ///
137    /// let config = WatchConfig::new(
138    ///     vec![PathBuf::from("tests/")],
139    ///     300,
140    ///     false
141    /// ).with_filter_pattern("otel".to_string());
142    /// ```
143    pub fn with_filter_pattern(mut self, pattern: String) -> Self {
144        self.filter_pattern = Some(pattern);
145        self
146    }
147
148    /// Add timebox limit for scenario execution
149    ///
150    /// Scenarios that exceed this time limit will be terminated.
151    ///
152    /// # Arguments
153    ///
154    /// * `timebox_ms` - Maximum execution time in milliseconds
155    ///
156    /// # Example
157    ///
158    /// ```
159    /// use clnrm_core::watch::WatchConfig;
160    /// use std::path::PathBuf;
161    ///
162    /// let config = WatchConfig::new(
163    ///     vec![PathBuf::from("tests/")],
164    ///     300,
165    ///     false
166    /// ).with_timebox(5000);
167    /// ```
168    pub fn with_timebox(mut self, timebox_ms: u64) -> Self {
169        self.timebox_ms = Some(timebox_ms);
170        self
171    }
172
173    /// Check if a filter pattern is set
174    pub fn has_filter_pattern(&self) -> bool {
175        self.filter_pattern.is_some()
176    }
177
178    /// Check if a timebox is set
179    pub fn has_timebox(&self) -> bool {
180        self.timebox_ms.is_some()
181    }
182}
183
184/// File watcher trait for testability
185///
186/// This trait allows mocking file watching behavior in tests,
187/// following London School TDD principles of defining contracts
188/// through interfaces.
189pub trait FileWatcher: Send + Sync {
190    /// Start watching for file changes
191    ///
192    /// # Returns
193    ///
194    /// Result indicating success or failure
195    fn start(&self) -> Result<()>;
196
197    /// Stop watching for file changes
198    fn stop(&self) -> Result<()>;
199}
200
201/// Production file watcher using notify crate
202///
203/// Watches file system for changes and sends events to a channel.
204/// Uses `notify::RecommendedWatcher` which selects the best backend
205/// for the current platform (inotify on Linux, FSEvents on macOS, etc).
206#[derive(Debug)]
207pub struct NotifyWatcher {
208    /// Paths being watched
209    _paths: Vec<PathBuf>,
210    /// Internal watcher instance (kept alive)
211    _watcher: RecommendedWatcher,
212}
213
214impl NotifyWatcher {
215    /// Create new notify-based file watcher
216    ///
217    /// # Arguments
218    ///
219    /// * `paths` - Paths to watch (files or directories)
220    /// * `tx` - Channel sender for watch events
221    ///
222    /// # Returns
223    ///
224    /// Result containing the watcher or an error
225    ///
226    /// # Errors
227    ///
228    /// Returns error if:
229    /// - Watcher creation fails
230    /// - Path watching fails
231    /// - Path does not exist
232    ///
233    /// # Example
234    ///
235    /// ```no_run
236    /// use clnrm_core::watch::NotifyWatcher;
237    /// use std::path::PathBuf;
238    /// use tokio::sync::mpsc;
239    ///
240    /// # async fn example() -> clnrm_core::error::Result<()> {
241    /// let (tx, mut rx) = mpsc::channel(100);
242    /// let watcher = NotifyWatcher::new(
243    ///     vec![PathBuf::from("tests/")],
244    ///     tx
245    /// )?;
246    /// # Ok(())
247    /// # }
248    /// ```
249    pub fn new(paths: Vec<PathBuf>, tx: mpsc::Sender<WatchEvent>) -> Result<Self> {
250        info!("Creating file watcher for {} path(s)", paths.len());
251
252        // Create standard library channel for notify crate
253        // (notify uses std::sync::mpsc, not tokio::sync::mpsc)
254        let (std_tx, std_rx) = std_mpsc::channel::<notify::Result<Event>>();
255
256        // Create watcher with event handler
257        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        // Watch all specified paths
262        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        // Spawn background task to bridge std::mpsc to tokio::mpsc
282        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                        // Convert notify event to our WatchEvent
289                        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                            // Send to tokio channel
300                            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        // Watcher starts automatically when created
324        debug!("Watcher already running");
325        Ok(())
326    }
327
328    fn stop(&self) -> Result<()> {
329        // Watcher stops when dropped
330        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    // =================================================================
342    // LONDON SCHOOL TDD: Mock-based tests for FileWatcher trait
343    // =================================================================
344
345    /// Mock file watcher for testing
346    ///
347    /// Allows verification of watcher interactions without file system I/O.
348    /// This follows London School TDD - testing object collaborations.
349    #[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        // Arrange
386        let watcher = MockFileWatcher::new();
387
388        // Act
389        watcher.start()?;
390
391        // Assert - Verify interaction occurred
392        assert!(watcher.was_started(), "Watcher should have been started");
393        Ok(())
394    }
395
396    #[test]
397    fn test_mock_watcher_stops() -> Result<()> {
398        // Arrange
399        let watcher = MockFileWatcher::new();
400
401        // Act
402        watcher.stop()?;
403
404        // Assert - Verify interaction occurred
405        assert!(watcher.was_stopped(), "Watcher should have been stopped");
406        Ok(())
407    }
408
409    #[test]
410    fn test_mock_watcher_lifecycle() -> Result<()> {
411        // Arrange
412        let watcher = MockFileWatcher::new();
413
414        // Act - Start then stop
415        watcher.start()?;
416        watcher.stop()?;
417
418        // Assert - Verify both interactions
419        assert!(watcher.was_started(), "Watcher should have been started");
420        assert!(watcher.was_stopped(), "Watcher should have been stopped");
421        Ok(())
422    }
423
424    // =================================================================
425    // WatchConfig tests
426    // =================================================================
427
428    #[test]
429    fn test_watch_config_creation() {
430        // Arrange & Act
431        let config = WatchConfig::new(vec![PathBuf::from("tests/")], 300, true);
432
433        // Assert
434        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        // Arrange
442        let cli_config = CliConfig {
443            parallel: true,
444            jobs: 4,
445            ..Default::default()
446        };
447
448        // Act
449        let config =
450            WatchConfig::new(vec![PathBuf::from("tests/")], 300, false).with_cli_config(cli_config);
451
452        // Assert
453        assert!(config.cli_config.parallel);
454        assert_eq!(config.cli_config.jobs, 4);
455    }
456
457    // =================================================================
458    // WatchEvent tests
459    // =================================================================
460
461    #[test]
462    fn test_watch_event_creation() {
463        // Arrange & Act
464        let event = WatchEvent {
465            path: PathBuf::from("test.toml.tera"),
466            kind: WatchEventKind::Modify,
467        };
468
469        // Assert
470        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 - Verify all event kinds
477        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    // =================================================================
486    // NotifyWatcher integration tests
487    // =================================================================
488
489    #[tokio::test]
490    async fn test_notify_watcher_rejects_nonexistent_path() {
491        // Arrange
492        let (tx, _rx) = mpsc::channel(100);
493        let paths = vec![PathBuf::from("/nonexistent/path/that/does/not/exist")];
494
495        // Act
496        let result = NotifyWatcher::new(paths, tx);
497
498        // Assert
499        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        // Arrange
507        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        // Act
513        let result = NotifyWatcher::new(vec![temp_dir.path().to_path_buf()], tx);
514
515        // Assert
516        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        // Arrange
524        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        // Wait for watcher to initialize
532        tokio::time::sleep(Duration::from_millis(100)).await;
533
534        // Act - Create a file
535        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        // Wait for event
540        tokio::time::sleep(Duration::from_millis(200)).await;
541
542        // Assert - Should receive create event
543        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        // Arrange
559        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        // Create file before starting watcher
565        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        // Wait for watcher to initialize
572        tokio::time::sleep(Duration::from_millis(100)).await;
573
574        // Act - Modify the file
575        fs::write(&test_file, "# modified")
576            .map_err(|e| CleanroomError::internal_error(format!("Failed to write file: {}", e)))?;
577
578        // Wait for event
579        tokio::time::sleep(Duration::from_millis(200)).await;
580
581        // Assert - Should receive modify event
582        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        // Arrange
601        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        // Wait for watcher to initialize
618        tokio::time::sleep(Duration::from_millis(100)).await;
619
620        // Act - Create files in both directories
621        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        // Wait for events
630        tokio::time::sleep(Duration::from_millis(200)).await;
631
632        // Assert - Should receive events from both paths
633        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}