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}