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}