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}