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