Skip to main content

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}