Skip to main content

reflex/
watcher.rs

1//! File system watcher for automatic reindexing
2//!
3//! The watcher monitors the workspace for file changes and automatically
4//! triggers incremental reindexing with configurable debouncing.
5
6use anyhow::{Context, Result};
7use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::sync::mpsc::{RecvTimeoutError, channel};
11use std::time::{Duration, Instant};
12
13use crate::indexer::Indexer;
14use crate::models::Language;
15use crate::output;
16
17/// Configuration for file watching
18#[derive(Debug, Clone)]
19pub struct WatchConfig {
20    /// Debounce duration in milliseconds
21    /// Waits this long after the last change before triggering reindex
22    pub debounce_ms: u64,
23    /// Suppress output (only log errors)
24    pub quiet: bool,
25}
26
27impl Default for WatchConfig {
28    fn default() -> Self {
29        Self {
30            debounce_ms: 15000, // 15 seconds
31            quiet: false,
32        }
33    }
34}
35
36/// Watch a directory for file changes and auto-reindex
37///
38/// This function blocks until interrupted (Ctrl+C).
39///
40/// # Algorithm
41///
42/// 1. Set up file system watcher using notify crate
43/// 2. Collect file change events into a HashSet (deduplicate)
44/// 3. Wait for debounce period after last change
45/// 4. Trigger incremental reindex (only changed files)
46/// 5. Repeat
47///
48/// # Debouncing
49///
50/// The debounce timer resets on every file change event. This batches
51/// rapid changes (e.g., multi-file refactors, format-on-save) into a
52/// single reindex operation.
53///
54/// Example timeline:
55/// ```text
56/// t=0s:  File A changed  [timer starts]
57/// t=2s:  File B changed  [timer resets]
58/// t=5s:  File C changed  [timer resets]
59/// t=20s: Timer expires    [reindex A, B, C]
60/// ```
61pub fn watch(path: &Path, indexer: Indexer, config: WatchConfig) -> Result<()> {
62    log::info!(
63        "Starting file watcher for {:?} with {}ms debounce",
64        path,
65        config.debounce_ms
66    );
67
68    // Setup channel for receiving file system events
69    let (tx, rx) = channel();
70
71    // Create watcher with default config
72    let mut watcher =
73        RecommendedWatcher::new(tx, Config::default()).context("Failed to create file watcher")?;
74
75    // Start watching the directory recursively
76    watcher
77        .watch(path, RecursiveMode::Recursive)
78        .context("Failed to start watching directory")?;
79
80    if !config.quiet {
81        println!(
82            "Watching for changes (debounce: {}s)...",
83            config.debounce_ms / 1000
84        );
85    }
86
87    // Track pending file changes
88    let mut pending_files: HashSet<PathBuf> = HashSet::new();
89    // Deleted file paths that need to be removed from the index.
90    // Tracked separately because the file no longer exists on disk and
91    // should_watch_file() cannot reliably inspect it.
92    let mut pending_deletions: HashSet<PathBuf> = HashSet::new();
93    let mut last_event_time: Option<Instant> = None;
94    let debounce_duration = Duration::from_millis(config.debounce_ms);
95
96    // Event loop
97    loop {
98        // Try to receive events with 100ms timeout (allows checking debounce timer)
99        match rx.recv_timeout(Duration::from_millis(100)) {
100            Ok(Ok(event)) => {
101                // Process the file system event
102                if let Some((changed_path, is_removal)) = process_event_typed(&event) {
103                    if is_removal {
104                        // File is gone — we can no longer call should_watch_file() on it,
105                        // but we must still reindex so the deleted entry is removed.
106                        // Accept any path whose extension suggests a code file OR has no
107                        // extension at all (e.g. a deleted directory triggers a broad Remove).
108                        let ext = changed_path
109                            .extension()
110                            .and_then(|e| e.to_str())
111                            .unwrap_or("");
112                        let is_indexed = ext.is_empty()
113                            || crate::models::Language::from_extension(ext).is_indexable();
114                        if is_indexed {
115                            log::debug!("Detected removal: {:?}", changed_path);
116                            pending_deletions.insert(changed_path);
117                            last_event_time = Some(Instant::now());
118                        }
119                    } else if should_watch_file(&changed_path) {
120                        log::debug!("Detected change: {:?}", changed_path);
121                        pending_files.insert(changed_path);
122                        last_event_time = Some(Instant::now());
123                    }
124                }
125            }
126            Ok(Err(e)) => {
127                log::warn!("Watch error: {}", e);
128            }
129            Err(RecvTimeoutError::Timeout) => {
130                // Check if debounce period has elapsed
131                let has_pending = !pending_files.is_empty() || !pending_deletions.is_empty();
132                if let Some(last_time) = last_event_time
133                    && has_pending
134                    && last_time.elapsed() >= debounce_duration
135                {
136                    // Trigger reindex
137                    let total_changes = pending_files.len() + pending_deletions.len();
138                    if !config.quiet {
139                        if pending_deletions.is_empty() {
140                            println!(
141                                "\nDetected {} changed file(s), reindexing...",
142                                pending_files.len()
143                            );
144                        } else {
145                            println!(
146                                "\nDetected {} change(s) ({} deleted), reindexing...",
147                                total_changes,
148                                pending_deletions.len()
149                            );
150                        }
151                    }
152
153                    let start = Instant::now();
154                    match indexer.index(path, false) {
155                        Ok(stats) => {
156                            let elapsed = start.elapsed();
157                            if !config.quiet {
158                                println!(
159                                    "✓ Reindexed {} files in {:.1}ms\n",
160                                    stats.total_files,
161                                    elapsed.as_secs_f64() * 1000.0
162                                );
163                            }
164                            log::info!("Reindexed {} files in {:?}", stats.total_files, elapsed);
165                        }
166                        Err(e) => {
167                            output::error(&format!("✗ Reindex failed: {}", e));
168                            log::error!("Reindex failed: {}", e);
169                        }
170                    }
171
172                    // Clear pending changes
173                    pending_files.clear();
174                    pending_deletions.clear();
175                    last_event_time = None;
176                }
177            }
178            Err(RecvTimeoutError::Disconnected) => {
179                log::info!("Watcher channel disconnected, stopping...");
180                break;
181            }
182        }
183    }
184
185    if !config.quiet {
186        println!("Watcher stopped.");
187    }
188
189    Ok(())
190}
191
192/// Process a file system event and extract the changed path together with a
193/// flag indicating whether this is a deletion (Remove) event.
194///
195/// Returns `Some((path, is_removal))`, or `None` for events that should be
196/// ignored (metadata-only changes, etc.).
197fn process_event_typed(event: &Event) -> Option<(PathBuf, bool)> {
198    match event.kind {
199        EventKind::Remove(_) => event.paths.first().cloned().map(|p| (p, true)),
200        EventKind::Create(_) | EventKind::Modify(_) => {
201            event.paths.first().cloned().map(|p| (p, false))
202        }
203        _ => None,
204    }
205}
206
207/// Process a file system event and extract the changed path
208///
209/// Returns None if the event should be ignored (e.g., metadata changes, directory events)
210#[allow(dead_code)]
211fn process_event(event: &Event) -> Option<PathBuf> {
212    process_event_typed(event).map(|(p, _)| p)
213}
214
215/// Check if a file should trigger a reindex
216///
217/// Returns true if the file has a supported language extension
218fn should_watch_file(path: &Path) -> bool {
219    // Skip hidden files and directories
220    if let Some(file_name) = path.file_name()
221        && file_name.to_string_lossy().starts_with('.')
222    {
223        return false;
224    }
225
226    // Skip directories
227    if path.is_dir() {
228        return false;
229    }
230
231    // Check if file extension is supported
232    if let Some(ext) = path.extension() {
233        let ext_str = ext.to_string_lossy();
234        let lang = Language::from_extension(&ext_str);
235        // is_indexable, not is_supported: the text tier is watched too, so editing a
236        // README or a config file triggers a reindex like any other indexed file.
237        if lang.is_text() {
238            let name = path
239                .file_name()
240                .map(|n| n.to_string_lossy())
241                .unwrap_or_default();
242            return crate::models::is_text_tier_file(&name);
243        }
244        return lang.is_supported();
245    }
246
247    false
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::fs;
254    use tempfile::TempDir;
255
256    #[test]
257    fn test_should_watch_rust_file() {
258        let temp = TempDir::new().unwrap();
259        let rust_file = temp.path().join("test.rs");
260        fs::write(&rust_file, "fn main() {}").unwrap();
261
262        assert!(should_watch_file(&rust_file));
263    }
264
265    #[test]
266    fn test_should_not_watch_unsupported_file() {
267        let temp = TempDir::new().unwrap();
268        // .txt is watched since 1.7.2 (plain-text tier); use an unclaimed extension.
269        let unknown = temp.path().join("test.xyz");
270        fs::write(&unknown, "mystery format").unwrap();
271        assert!(!should_watch_file(&unknown));
272
273        // A lock file matches a text extension but must not trigger a reindex.
274        let lock = temp.path().join("package-lock.json");
275        fs::write(&lock, "{}").unwrap();
276        assert!(!should_watch_file(&lock));
277    }
278
279    #[test]
280    fn test_should_watch_text_tier_file() {
281        let temp = TempDir::new().unwrap();
282        // Editing a README must trigger a reindex like any other indexed file.
283        for name in ["README.md", "config.yaml", "notes.txt"] {
284            let path = temp.path().join(name);
285            fs::write(&path, "content").unwrap();
286            assert!(should_watch_file(&path), "{name} should be watched");
287        }
288    }
289
290    #[test]
291    fn test_should_not_watch_hidden_file() {
292        let temp = TempDir::new().unwrap();
293        let hidden_file = temp.path().join(".hidden.rs");
294        fs::write(&hidden_file, "fn main() {}").unwrap();
295
296        assert!(!should_watch_file(&hidden_file));
297    }
298
299    #[test]
300    fn test_should_not_watch_directory() {
301        let temp = TempDir::new().unwrap();
302        let dir = temp.path().join("src");
303        fs::create_dir(&dir).unwrap();
304
305        assert!(!should_watch_file(&dir));
306    }
307
308    #[test]
309    fn test_watch_config_default() {
310        let config = WatchConfig::default();
311        assert_eq!(config.debounce_ms, 15000);
312        assert!(!config.quiet);
313    }
314
315    #[test]
316    fn test_process_event_create() {
317        let event = Event {
318            kind: EventKind::Create(notify::event::CreateKind::File),
319            paths: vec![PathBuf::from("/test/file.rs")],
320            attrs: Default::default(),
321        };
322
323        let path = process_event(&event);
324        assert!(path.is_some());
325        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
326    }
327
328    #[test]
329    fn test_process_event_modify() {
330        let event = Event {
331            kind: EventKind::Modify(notify::event::ModifyKind::Data(
332                notify::event::DataChange::Any,
333            )),
334            paths: vec![PathBuf::from("/test/file.rs")],
335            attrs: Default::default(),
336        };
337
338        let path = process_event(&event);
339        assert!(path.is_some());
340        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
341    }
342
343    #[test]
344    fn test_process_event_access_ignored() {
345        let event = Event {
346            kind: EventKind::Access(notify::event::AccessKind::Read),
347            paths: vec![PathBuf::from("/test/file.rs")],
348            attrs: Default::default(),
349        };
350
351        let path = process_event(&event);
352        assert!(path.is_none());
353    }
354}