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::{channel, RecvTimeoutError};
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 = RecommendedWatcher::new(tx, Config::default())
73        .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!("Watching for changes (debounce: {}s)...", config.debounce_ms / 1000);
82    }
83
84    // Track pending file changes
85    let mut pending_files: HashSet<PathBuf> = HashSet::new();
86    // Deleted file paths that need to be removed from the index.
87    // Tracked separately because the file no longer exists on disk and
88    // should_watch_file() cannot reliably inspect it.
89    let mut pending_deletions: HashSet<PathBuf> = HashSet::new();
90    let mut last_event_time: Option<Instant> = None;
91    let debounce_duration = Duration::from_millis(config.debounce_ms);
92
93    // Event loop
94    loop {
95        // Try to receive events with 100ms timeout (allows checking debounce timer)
96        match rx.recv_timeout(Duration::from_millis(100)) {
97            Ok(Ok(event)) => {
98                // Process the file system event
99                if let Some((changed_path, is_removal)) = process_event_typed(&event) {
100                    if is_removal {
101                        // File is gone — we can no longer call should_watch_file() on it,
102                        // but we must still reindex so the deleted entry is removed.
103                        // Accept any path whose extension suggests a code file OR has no
104                        // extension at all (e.g. a deleted directory triggers a broad Remove).
105                        let ext = changed_path.extension()
106                            .and_then(|e| e.to_str())
107                            .unwrap_or("");
108                        let is_code = ext.is_empty() || crate::models::Language::from_extension(ext).is_supported();
109                        if is_code {
110                            log::debug!("Detected removal: {:?}", changed_path);
111                            pending_deletions.insert(changed_path);
112                            last_event_time = Some(Instant::now());
113                        }
114                    } else if should_watch_file(&changed_path) {
115                        log::debug!("Detected change: {:?}", changed_path);
116                        pending_files.insert(changed_path);
117                        last_event_time = Some(Instant::now());
118                    }
119                }
120            }
121            Ok(Err(e)) => {
122                log::warn!("Watch error: {}", e);
123            }
124            Err(RecvTimeoutError::Timeout) => {
125                // Check if debounce period has elapsed
126                let has_pending = !pending_files.is_empty() || !pending_deletions.is_empty();
127                if let Some(last_time) = last_event_time {
128                    if has_pending && last_time.elapsed() >= debounce_duration {
129                        // Trigger reindex
130                        let total_changes = pending_files.len() + pending_deletions.len();
131                        if !config.quiet {
132                            if pending_deletions.is_empty() {
133                                println!(
134                                    "\nDetected {} changed file(s), reindexing...",
135                                    pending_files.len()
136                                );
137                            } else {
138                                println!(
139                                    "\nDetected {} change(s) ({} deleted), reindexing...",
140                                    total_changes,
141                                    pending_deletions.len()
142                                );
143                            }
144                        }
145
146                        let start = Instant::now();
147                        match indexer.index(path, false) {
148                            Ok(stats) => {
149                                let elapsed = start.elapsed();
150                                if !config.quiet {
151                                    println!(
152                                        "✓ Reindexed {} files in {:.1}ms\n",
153                                        stats.total_files,
154                                        elapsed.as_secs_f64() * 1000.0
155                                    );
156                                }
157                                log::info!(
158                                    "Reindexed {} files in {:?}",
159                                    stats.total_files,
160                                    elapsed
161                                );
162                            }
163                            Err(e) => {
164                                output::error(&format!("✗ Reindex failed: {}", e));
165                                log::error!("Reindex failed: {}", e);
166                            }
167                        }
168
169                        // Clear pending changes
170                        pending_files.clear();
171                        pending_deletions.clear();
172                        last_event_time = None;
173                    }
174                }
175            }
176            Err(RecvTimeoutError::Disconnected) => {
177                log::info!("Watcher channel disconnected, stopping...");
178                break;
179            }
180        }
181    }
182
183    if !config.quiet {
184        println!("Watcher stopped.");
185    }
186
187    Ok(())
188}
189
190/// Process a file system event and extract the changed path together with a
191/// flag indicating whether this is a deletion (Remove) event.
192///
193/// Returns `Some((path, is_removal))`, or `None` for events that should be
194/// ignored (metadata-only changes, etc.).
195fn process_event_typed(event: &Event) -> Option<(PathBuf, bool)> {
196    match event.kind {
197        EventKind::Remove(_) => event.paths.first().cloned().map(|p| (p, true)),
198        EventKind::Create(_) | EventKind::Modify(_) => {
199            event.paths.first().cloned().map(|p| (p, false))
200        }
201        _ => None,
202    }
203}
204
205/// Process a file system event and extract the changed path
206///
207/// Returns None if the event should be ignored (e.g., metadata changes, directory events)
208fn process_event(event: &Event) -> Option<PathBuf> {
209    process_event_typed(event).map(|(p, _)| p)
210}
211
212/// Check if a file should trigger a reindex
213///
214/// Returns true if the file has a supported language extension
215fn should_watch_file(path: &Path) -> bool {
216    // Skip hidden files and directories
217    if let Some(file_name) = path.file_name() {
218        if file_name.to_string_lossy().starts_with('.') {
219            return false;
220        }
221    }
222
223    // Skip directories
224    if path.is_dir() {
225        return false;
226    }
227
228    // Check if file extension is supported
229    if let Some(ext) = path.extension() {
230        let ext_str = ext.to_string_lossy();
231        let lang = Language::from_extension(&ext_str);
232        return lang.is_supported();
233    }
234
235    false
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use std::fs;
242    use tempfile::TempDir;
243
244    #[test]
245    fn test_should_watch_rust_file() {
246        let temp = TempDir::new().unwrap();
247        let rust_file = temp.path().join("test.rs");
248        fs::write(&rust_file, "fn main() {}").unwrap();
249
250        assert!(should_watch_file(&rust_file));
251    }
252
253    #[test]
254    fn test_should_not_watch_unsupported_file() {
255        let temp = TempDir::new().unwrap();
256        let txt_file = temp.path().join("test.txt");
257        fs::write(&txt_file, "plain text").unwrap();
258
259        assert!(!should_watch_file(&txt_file));
260    }
261
262    #[test]
263    fn test_should_not_watch_hidden_file() {
264        let temp = TempDir::new().unwrap();
265        let hidden_file = temp.path().join(".hidden.rs");
266        fs::write(&hidden_file, "fn main() {}").unwrap();
267
268        assert!(!should_watch_file(&hidden_file));
269    }
270
271    #[test]
272    fn test_should_not_watch_directory() {
273        let temp = TempDir::new().unwrap();
274        let dir = temp.path().join("src");
275        fs::create_dir(&dir).unwrap();
276
277        assert!(!should_watch_file(&dir));
278    }
279
280    #[test]
281    fn test_watch_config_default() {
282        let config = WatchConfig::default();
283        assert_eq!(config.debounce_ms, 15000);
284        assert!(!config.quiet);
285    }
286
287    #[test]
288    fn test_process_event_create() {
289        let event = Event {
290            kind: EventKind::Create(notify::event::CreateKind::File),
291            paths: vec![PathBuf::from("/test/file.rs")],
292            attrs: Default::default(),
293        };
294
295        let path = process_event(&event);
296        assert!(path.is_some());
297        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
298    }
299
300    #[test]
301    fn test_process_event_modify() {
302        let event = Event {
303            kind: EventKind::Modify(notify::event::ModifyKind::Data(
304                notify::event::DataChange::Any,
305            )),
306            paths: vec![PathBuf::from("/test/file.rs")],
307            attrs: Default::default(),
308        };
309
310        let path = process_event(&event);
311        assert!(path.is_some());
312        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
313    }
314
315    #[test]
316    fn test_process_event_access_ignored() {
317        let event = Event {
318            kind: EventKind::Access(notify::event::AccessKind::Read),
319            paths: vec![PathBuf::from("/test/file.rs")],
320            attrs: Default::default(),
321        };
322
323        let path = process_event(&event);
324        assert!(path.is_none());
325    }
326}