fob_cli/dev/
watcher.rs

1//! File system watcher with debouncing for development mode.
2//!
3//! Watches the entire project directory and filters changes to relevant files,
4//! ignoring node_modules, build artifacts, and other configured patterns.
5
6use crate::error::{CliError, Result};
7use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10use tokio::sync::mpsc;
11
12/// File change event type.
13#[derive(Debug, Clone)]
14pub enum FileChange {
15    /// File was modified
16    Modified(PathBuf),
17    /// File was created
18    Created(PathBuf),
19    /// File was removed
20    Removed(PathBuf),
21}
22
23impl FileChange {
24    /// Get the path affected by this change.
25    pub fn path(&self) -> &Path {
26        match self {
27            FileChange::Modified(p) | FileChange::Created(p) | FileChange::Removed(p) => p,
28        }
29    }
30}
31
32/// File watcher with debouncing and filtering.
33///
34/// Watches a directory recursively and sends change events through a channel.
35/// Debouncing prevents rapid successive events from causing multiple rebuilds.
36pub struct FileWatcher {
37    /// Underlying notify watcher
38    _watcher: RecommendedWatcher,
39    /// Root directory being watched
40    root: PathBuf,
41    /// Patterns to ignore (e.g., "node_modules", "*.log")
42    ignore_patterns: Vec<String>,
43}
44
45impl FileWatcher {
46    /// Create a new file watcher.
47    ///
48    /// # Arguments
49    ///
50    /// * `root` - Root directory to watch recursively
51    /// * `ignore_patterns` - Patterns to ignore (glob-style)
52    /// * `debounce_ms` - Debounce delay in milliseconds
53    ///
54    /// # Returns
55    ///
56    /// Tuple of (FileWatcher, receiver for change events)
57    ///
58    /// # Errors
59    ///
60    /// Returns error if watcher cannot be created or directory doesn't exist
61    pub fn new(
62        root: PathBuf,
63        ignore_patterns: Vec<String>,
64        debounce_ms: u64,
65    ) -> Result<(Self, mpsc::Receiver<FileChange>)> {
66        // Validate root directory exists
67        if !root.exists() {
68            return Err(CliError::FileNotFound(root));
69        }
70
71        let (tx, rx) = mpsc::channel(100);
72
73        // Create debouncer to batch rapid changes
74        let debounce_duration = Duration::from_millis(debounce_ms);
75        let mut last_event: Option<(PathBuf, Instant)> = None;
76        let ignore_patterns_clone = ignore_patterns.clone();
77        let root_clone = root.clone();
78
79        // Create watcher with event handler
80        let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
81            if let Ok(event) = res {
82                // Process each path in the event
83                for path in &event.paths {
84                    // Skip if path should be ignored
85                    if Self::should_ignore(path, &root_clone, &ignore_patterns_clone) {
86                        continue;
87                    }
88
89                    // Debounce: skip if same file changed within debounce window
90                    let now = Instant::now();
91                    if let Some((last_path, last_time)) = &last_event {
92                        if last_path == path && now.duration_since(*last_time) < debounce_duration {
93                            continue;
94                        }
95                    }
96
97                    last_event = Some((path.clone(), now));
98
99                    // Convert notify event to our FileChange type
100                    let change = match event.kind {
101                        notify::EventKind::Create(_) => FileChange::Created(path.clone()),
102                        notify::EventKind::Modify(_) => FileChange::Modified(path.clone()),
103                        notify::EventKind::Remove(_) => FileChange::Removed(path.clone()),
104                        _ => continue,
105                    };
106
107                    // Send event (non-blocking)
108                    let _ = tx.blocking_send(change);
109                }
110            }
111        })
112        .map_err(CliError::Watch)?;
113
114        // Start watching the root directory
115        watcher
116            .watch(&root, RecursiveMode::Recursive)
117            .map_err(CliError::Watch)?;
118
119        Ok((
120            Self {
121                _watcher: watcher,
122                root,
123                ignore_patterns,
124            },
125            rx,
126        ))
127    }
128
129    /// Check if a path should be ignored.
130    ///
131    /// # Security
132    ///
133    /// - Prevents watching system directories
134    /// - Validates paths are within project root
135    fn should_ignore(path: &Path, root: &Path, ignore_patterns: &[String]) -> bool {
136        // Security: Only watch files within root
137        if !path.starts_with(root) {
138            return true;
139        }
140
141        // Get relative path for pattern matching
142        let rel_path = match path.strip_prefix(root) {
143            Ok(p) => p,
144            Err(_) => return true,
145        };
146
147        let path_str = rel_path.to_string_lossy();
148
149        // Check each ignore pattern
150        for pattern in ignore_patterns {
151            // Simple pattern matching (could be enhanced with glob crate)
152            if pattern.starts_with('*') {
153                // Extension pattern like "*.log"
154                let ext = pattern.trim_start_matches('*');
155                if path_str.ends_with(ext) {
156                    return true;
157                }
158            } else if path_str.starts_with(pattern) || path_str.contains(&format!("/{}", pattern)) {
159                // Directory pattern like "node_modules"
160                return true;
161            }
162        }
163
164        // Ignore hidden files and directories
165        for component in rel_path.components() {
166            if let Some(name) = component.as_os_str().to_str() {
167                if name.starts_with('.') && name != "." && name != ".." {
168                    return true;
169                }
170            }
171        }
172
173        false
174    }
175
176    /// Get the root directory being watched.
177    pub fn root(&self) -> &Path {
178        &self.root
179    }
180
181    /// Get the ignore patterns configured for this watcher.
182    pub fn ignore_patterns(&self) -> &[String] {
183        &self.ignore_patterns
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_should_ignore_node_modules() {
193        let root = PathBuf::from("/project");
194        let patterns = vec!["node_modules".to_string()];
195
196        let path = PathBuf::from("/project/node_modules/package/index.js");
197        assert!(FileWatcher::should_ignore(&path, &root, &patterns));
198
199        let path = PathBuf::from("/project/src/index.js");
200        assert!(!FileWatcher::should_ignore(&path, &root, &patterns));
201    }
202
203    #[test]
204    fn test_should_ignore_extension() {
205        let root = PathBuf::from("/project");
206        let patterns = vec!["*.log".to_string()];
207
208        let path = PathBuf::from("/project/debug.log");
209        assert!(FileWatcher::should_ignore(&path, &root, &patterns));
210
211        let path = PathBuf::from("/project/src/index.js");
212        assert!(!FileWatcher::should_ignore(&path, &root, &patterns));
213    }
214
215    #[test]
216    fn test_should_ignore_hidden_files() {
217        let root = PathBuf::from("/project");
218        let patterns = vec![];
219
220        let path = PathBuf::from("/project/.git/config");
221        assert!(FileWatcher::should_ignore(&path, &root, &patterns));
222
223        let path = PathBuf::from("/project/.env");
224        assert!(FileWatcher::should_ignore(&path, &root, &patterns));
225
226        let path = PathBuf::from("/project/src/.hidden/file.js");
227        assert!(FileWatcher::should_ignore(&path, &root, &patterns));
228    }
229
230    #[test]
231    fn test_should_ignore_outside_root() {
232        let root = PathBuf::from("/project");
233        let patterns = vec![];
234
235        let path = PathBuf::from("/other/file.js");
236        assert!(FileWatcher::should_ignore(&path, &root, &patterns));
237    }
238
239    #[test]
240    fn test_file_change_path() {
241        let path = PathBuf::from("/project/src/index.js");
242
243        let change = FileChange::Modified(path.clone());
244        assert_eq!(change.path(), path.as_path());
245
246        let change = FileChange::Created(path.clone());
247        assert_eq!(change.path(), path.as_path());
248
249        let change = FileChange::Removed(path.clone());
250        assert_eq!(change.path(), path.as_path());
251    }
252}