dampen_dev/watcher.rs
1//! File watching functionality for hot-reload
2//!
3//! This module wraps the `notify` crate to provide file system watching
4//! with debouncing and filtering for .dampen files.
5
6use crossbeam_channel::{Receiver, Sender};
7use notify::{RecursiveMode, Watcher};
8use notify_debouncer_full::{DebounceEventResult, Debouncer, FileIdMap, new_debouncer};
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12/// Configuration for file watcher behavior
13#[derive(Debug, Clone)]
14pub struct FileWatcherConfig {
15 /// Paths to watch (directories or specific files)
16 pub watch_paths: Vec<PathBuf>,
17
18 /// Debounce interval in milliseconds
19 pub debounce_ms: u64,
20
21 /// File extension filter (default: ".dampen")
22 pub extension_filter: String,
23
24 /// Whether to watch recursively
25 pub recursive: bool,
26}
27
28impl Default for FileWatcherConfig {
29 fn default() -> Self {
30 Self {
31 watch_paths: vec![PathBuf::from("src/ui")],
32 debounce_ms: 100,
33 extension_filter: ".dampen".to_string(),
34 recursive: true,
35 }
36 }
37}
38
39/// Runtime state of the file watcher
40///
41/// # State Machine
42///
43/// ```text
44/// ┌─────┐
45/// │ Idle │
46/// └──┬──┘
47/// │ watch() succeeds
48/// ▼
49/// ┌─────────┐
50/// │ Watching │
51/// └────┬────┘
52/// │ fatal error
53/// ▼
54/// ┌─────────┐
55/// │ Failed │────┐
56/// └─────────┘ │
57/// │ │ recover (unwatch + re-watch)
58/// │ └─────────────────────┐
59/// ▼ ▼
60/// (exit application) ┌─────┐
61/// │ Idle │
62/// └─────┘
63/// ```
64///
65/// # States
66///
67/// - **Idle**: Initial state after `FileWatcher::new()`. The watcher is
68/// created but not watching any paths yet.
69///
70/// - **Watching**: Active state after successful `watch()` call. The watcher
71/// monitors the configured paths and emits file change events.
72///
73/// - **Failed**: Error state if watcher initialization fails. This is typically
74/// due to OS limitations (e.g., reached maximum file descriptor limit).
75/// Recovery requires creating a new `FileWatcher` instance.
76///
77/// # Example
78///
79/// ```no_run
80/// use dampen_dev::watcher::{FileWatcher, FileWatcherConfig};
81/// use std::path::PathBuf;
82///
83/// let config = FileWatcherConfig::default();
84/// let mut watcher = FileWatcher::new(config).expect("Failed to create watcher");
85/// // State is now Idle
86///
87/// let path = PathBuf::from("src/ui");
88/// watcher.watch(path).expect("Failed to watch");
89/// // State is now Watching
90///
91/// // If fatal error occurs, state becomes Failed
92/// // Recovery requires creating new watcher
93/// ```
94#[derive(Debug)]
95pub enum FileWatcherState {
96 /// Watcher is initialized but not started
97 Idle,
98
99 /// Actively watching for changes
100 Watching {
101 /// Paths being watched
102 paths: Vec<PathBuf>,
103 },
104
105 /// Error state (watcher failed to initialize)
106 Failed {
107 /// Error description
108 error: String,
109 },
110}
111
112/// File watcher wrapper around notify crate
113///
114/// Wraps `notify::RecommendedWatcher` with debouncing and filtering
115/// for `.dampen` files. Provides a channel-based API for receiving
116/// file change events.
117pub struct FileWatcher {
118 config: FileWatcherConfig,
119 debouncer: Debouncer<notify::RecommendedWatcher, FileIdMap>,
120 receiver: Receiver<PathBuf>,
121}
122
123impl FileWatcher {
124 /// Create a new file watcher with the given configuration
125 ///
126 /// Sets up a debounced file watcher with crossbeam channels for
127 /// event communication. The watcher is created but not yet watching
128 /// any paths - use `watch()` to add paths.
129 ///
130 /// # Arguments
131 /// * `config` - File watcher configuration
132 ///
133 /// # Returns
134 /// A new FileWatcher instance or an error if watcher creation fails
135 ///
136 /// # Errors
137 /// Returns an error if:
138 /// - The file watcher cannot be initialized (OS limitations, permissions)
139 /// - The debouncer setup fails
140 ///
141 /// # Example
142 /// ```no_run
143 /// use dampen_dev::watcher::{FileWatcher, FileWatcherConfig};
144 ///
145 /// let config = FileWatcherConfig::default();
146 /// let watcher = FileWatcher::new(config).expect("Failed to create watcher");
147 /// ```
148 pub fn new(config: FileWatcherConfig) -> Result<Self, FileWatcherError> {
149 let (tx, rx) = crossbeam_channel::unbounded();
150 let extension_filter = config.extension_filter.clone();
151
152 // Create debouncer with configured interval
153 let debouncer = new_debouncer(
154 Duration::from_millis(config.debounce_ms),
155 None, // Use default tick rate
156 move |result: DebounceEventResult| {
157 handle_debounced_events(result, &tx, &extension_filter);
158 },
159 )
160 .map_err(|e| FileWatcherError::InitializationFailed(e.to_string()))?;
161
162 Ok(Self {
163 config,
164 debouncer,
165 receiver: rx,
166 })
167 }
168
169 /// Add a path to watch for changes
170 ///
171 /// Watches the specified path for file system changes. If the path is a directory
172 /// and `recursive` is enabled in the config, watches all subdirectories as well.
173 ///
174 /// # Arguments
175 /// * `path` - Path to watch (file or directory)
176 ///
177 /// # Errors
178 /// Returns an error if:
179 /// - The path does not exist
180 /// - Permission denied to watch the path
181 /// - The path is already being watched
182 /// - OS-specific watcher limitations reached
183 ///
184 /// # Example
185 /// ```no_run
186 /// use dampen_dev::watcher::{FileWatcher, FileWatcherConfig};
187 /// use std::path::PathBuf;
188 ///
189 /// let mut watcher = FileWatcher::new(FileWatcherConfig::default()).unwrap();
190 /// watcher.watch(PathBuf::from("src/ui")).expect("Failed to watch path");
191 /// ```
192 pub fn watch(&mut self, path: PathBuf) -> Result<(), FileWatcherError> {
193 // Check if path exists
194 if !path.exists() {
195 return Err(FileWatcherError::PathNotFound(path));
196 }
197
198 // Determine recursive mode from config
199 let recursive_mode = if self.config.recursive {
200 RecursiveMode::Recursive
201 } else {
202 RecursiveMode::NonRecursive
203 };
204
205 // Add path to watcher with enhanced error handling
206 self.debouncer
207 .watcher()
208 .watch(&path, recursive_mode)
209 .map_err(|e| {
210 // Check if this is a permission error by examining the error chain
211 // notify::Error wraps std::io::Error, so we check the source
212 let error_string = e.to_string().to_lowercase();
213 if error_string.contains("permission denied")
214 || error_string.contains("access is denied")
215 {
216 return FileWatcherError::PermissionDenied(path.clone());
217 }
218
219 // Generic watch error for other cases
220 FileWatcherError::WatchError {
221 path: path.clone(),
222 error: e.to_string(),
223 }
224 })?;
225
226 Ok(())
227 }
228
229 /// Remove a path from the watch list
230 ///
231 /// Stops watching the specified path for changes.
232 ///
233 /// # Arguments
234 /// * `path` - Path to unwatch
235 ///
236 /// # Errors
237 /// Returns an error if the path is not currently being watched
238 ///
239 /// # Example
240 /// ```no_run
241 /// use dampen_dev::watcher::{FileWatcher, FileWatcherConfig};
242 /// use std::path::PathBuf;
243 ///
244 /// let mut watcher = FileWatcher::new(FileWatcherConfig::default()).unwrap();
245 /// let path = PathBuf::from("src/ui");
246 /// watcher.watch(path.clone()).unwrap();
247 /// watcher.unwatch(path).expect("Failed to unwatch path");
248 /// ```
249 pub fn unwatch(&mut self, path: PathBuf) -> Result<(), FileWatcherError> {
250 self.debouncer
251 .watcher()
252 .unwatch(&path)
253 .map_err(|e| FileWatcherError::WatchError {
254 path: path.clone(),
255 error: e.to_string(),
256 })?;
257
258 Ok(())
259 }
260
261 /// Get the receiver for file change events
262 ///
263 /// Returns a reference to the channel receiver that will receive
264 /// paths of changed `.dampen` files. Events are debounced according
265 /// to the configuration.
266 ///
267 /// # Returns
268 /// A reference to the crossbeam channel receiver
269 ///
270 /// # Example
271 /// ```no_run
272 /// use dampen_dev::watcher::{FileWatcher, FileWatcherConfig};
273 ///
274 /// let watcher = FileWatcher::new(FileWatcherConfig::default()).unwrap();
275 /// let receiver = watcher.receiver();
276 ///
277 /// // In an event loop:
278 /// // for changed_file in receiver.try_iter() {
279 /// // println!("File changed: {:?}", changed_file);
280 /// // }
281 /// ```
282 pub fn receiver(&self) -> &Receiver<PathBuf> {
283 &self.receiver
284 }
285
286 /// Get the configuration used by this watcher
287 ///
288 /// # Returns
289 /// A reference to the FileWatcherConfig
290 pub fn config(&self) -> &FileWatcherConfig {
291 &self.config
292 }
293}
294
295/// Handle debounced file system events and filter for .dampen files
296///
297/// This function is called by the notify-debouncer when file events occur.
298/// It filters events to only include files matching the extension filter
299/// and sends the paths through the channel.
300///
301/// **File Deletion Handling**: If a file is deleted during watching, the event
302/// is silently ignored. This is graceful behavior - deleted files don't trigger
303/// hot-reload attempts.
304///
305/// **Simultaneous Multi-File Changes** (T124): The debouncing mechanism (100ms window)
306/// naturally batches rapid file changes together. When multiple files are modified
307/// simultaneously (e.g., save-all in IDE), all events within the debounce window
308/// are processed together in a single batch. Each file change triggers its own
309/// hot-reload attempt sequentially, with the most recent change winning.
310fn handle_debounced_events(
311 result: DebounceEventResult,
312 sender: &Sender<PathBuf>,
313 extension_filter: &str,
314) {
315 match result {
316 Ok(events) => {
317 for event in events {
318 // Extract paths from the event
319 for path in &event.paths {
320 // Filter by extension
321 if !path_matches_extension(path, extension_filter) {
322 continue;
323 }
324
325 // Check if file still exists (handles deletion gracefully)
326 if !path.exists() {
327 // File was deleted - this is normal, don't send event
328 // In development mode, file deletions are intentional (e.g., cleanup)
329 // and don't require hot-reload attempts
330 #[cfg(debug_assertions)]
331 eprintln!("File watcher: ignoring deleted file {:?}", path);
332 continue;
333 }
334
335 // Send the path through the channel
336 // If the receiver is dropped, we silently ignore the error
337 let _ = sender.send(path.clone());
338 }
339 }
340 }
341 Err(errors) => {
342 // Log errors but don't stop watching
343 // These could be permission errors, I/O errors, etc.
344 for error in errors {
345 eprintln!("File watcher error: {:?}", error);
346 }
347 }
348 }
349}
350
351/// Check if a path matches the extension filter
352///
353/// # Arguments
354/// * `path` - Path to check
355/// * `extension` - Extension to match (e.g., ".dampen")
356///
357/// # Returns
358/// True if the path's extension matches the filter
359fn path_matches_extension(path: &Path, extension: &str) -> bool {
360 path.extension()
361 .and_then(|ext| ext.to_str())
362 .map(|ext| format!(".{}", ext) == extension)
363 .unwrap_or(false)
364}
365
366/// Errors that can occur during file watching
367#[derive(Debug, thiserror::Error)]
368pub enum FileWatcherError {
369 /// Failed to initialize the file watcher
370 #[error("Failed to initialize file watcher: {0}")]
371 InitializationFailed(String),
372
373 /// Path does not exist
374 #[error("Path not found: {0}")]
375 PathNotFound(PathBuf),
376
377 /// Error while watching a path
378 #[error("Failed to watch path {path}: {error}")]
379 WatchError {
380 /// Path that failed to be watched
381 path: PathBuf,
382 /// Error description
383 error: String,
384 },
385
386 /// Permission denied
387 #[error("Permission denied for path: {0}")]
388 PermissionDenied(PathBuf),
389}