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;
15
16/// Configuration for file watching
17#[derive(Debug, Clone)]
18pub struct WatchConfig {
19    /// Debounce duration in milliseconds
20    /// Waits this long after the last change before triggering reindex
21    pub debounce_ms: u64,
22    /// Suppress output (only log errors)
23    pub quiet: bool,
24}
25
26impl Default for WatchConfig {
27    fn default() -> Self {
28        Self {
29            debounce_ms: 15000, // 15 seconds
30            quiet: false,
31        }
32    }
33}
34
35/// Watch a directory for file changes and auto-reindex
36///
37/// This function blocks until interrupted (Ctrl+C).
38///
39/// # Algorithm
40///
41/// 1. Set up file system watcher using notify crate
42/// 2. Collect file change events into a HashSet (deduplicate)
43/// 3. Wait for debounce period after last change
44/// 4. Trigger incremental reindex (only changed files)
45/// 5. Repeat
46///
47/// # Debouncing
48///
49/// The debounce timer resets on every file change event. This batches
50/// rapid changes (e.g., multi-file refactors, format-on-save) into a
51/// single reindex operation.
52///
53/// Example timeline:
54/// ```text
55/// t=0s:  File A changed  [timer starts]
56/// t=2s:  File B changed  [timer resets]
57/// t=5s:  File C changed  [timer resets]
58/// t=20s: Timer expires    [reindex A, B, C]
59/// ```
60pub fn watch(path: &Path, indexer: Indexer, config: WatchConfig) -> Result<()> {
61    log::info!(
62        "Starting file watcher for {:?} with {}ms debounce",
63        path,
64        config.debounce_ms
65    );
66
67    // Setup channel for receiving file system events
68    let (tx, rx) = channel();
69
70    // Create watcher with default config
71    let mut watcher = RecommendedWatcher::new(tx, Config::default())
72        .context("Failed to create file watcher")?;
73
74    // Start watching the directory recursively
75    watcher
76        .watch(path, RecursiveMode::Recursive)
77        .context("Failed to start watching directory")?;
78
79    if !config.quiet {
80        println!("Watching for changes (debounce: {}s)...", config.debounce_ms / 1000);
81    }
82
83    // Track pending file changes
84    let mut pending_files: HashSet<PathBuf> = HashSet::new();
85    let mut last_event_time: Option<Instant> = None;
86    let debounce_duration = Duration::from_millis(config.debounce_ms);
87
88    // Event loop
89    loop {
90        // Try to receive events with 100ms timeout (allows checking debounce timer)
91        match rx.recv_timeout(Duration::from_millis(100)) {
92            Ok(Ok(event)) => {
93                // Process the file system event
94                if let Some(changed_path) = process_event(&event) {
95                    // Filter to only supported file types
96                    if should_watch_file(&changed_path) {
97                        log::debug!("Detected change: {:?}", changed_path);
98                        pending_files.insert(changed_path);
99                        last_event_time = Some(Instant::now());
100                    }
101                }
102            }
103            Ok(Err(e)) => {
104                log::warn!("Watch error: {}", e);
105            }
106            Err(RecvTimeoutError::Timeout) => {
107                // Check if debounce period has elapsed
108                if let Some(last_time) = last_event_time {
109                    if !pending_files.is_empty() && last_time.elapsed() >= debounce_duration {
110                        // Trigger reindex
111                        if !config.quiet {
112                            println!(
113                                "\nDetected {} changed file(s), reindexing...",
114                                pending_files.len()
115                            );
116                        }
117
118                        let start = Instant::now();
119                        match indexer.index(path, false) {
120                            Ok(stats) => {
121                                let elapsed = start.elapsed();
122                                if !config.quiet {
123                                    println!(
124                                        "✓ Reindexed {} files in {:.1}ms\n",
125                                        stats.total_files,
126                                        elapsed.as_secs_f64() * 1000.0
127                                    );
128                                }
129                                log::info!(
130                                    "Reindexed {} files in {:?}",
131                                    stats.total_files,
132                                    elapsed
133                                );
134                            }
135                            Err(e) => {
136                                eprintln!("✗ Reindex failed: {}\n", e);
137                                log::error!("Reindex failed: {}", e);
138                            }
139                        }
140
141                        // Clear pending changes
142                        pending_files.clear();
143                        last_event_time = None;
144                    }
145                }
146            }
147            Err(RecvTimeoutError::Disconnected) => {
148                log::info!("Watcher channel disconnected, stopping...");
149                break;
150            }
151        }
152    }
153
154    if !config.quiet {
155        println!("Watcher stopped.");
156    }
157
158    Ok(())
159}
160
161/// Process a file system event and extract the changed path
162///
163/// Returns None if the event should be ignored (e.g., metadata changes, directory events)
164fn process_event(event: &Event) -> Option<PathBuf> {
165    // Only care about Create, Modify, and Remove events
166    match event.kind {
167        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
168            // Take the first path (usually only one)
169            event.paths.first().cloned()
170        }
171        _ => None,
172    }
173}
174
175/// Check if a file should trigger a reindex
176///
177/// Returns true if the file has a supported language extension
178fn should_watch_file(path: &Path) -> bool {
179    // Skip hidden files and directories
180    if let Some(file_name) = path.file_name() {
181        if file_name.to_string_lossy().starts_with('.') {
182            return false;
183        }
184    }
185
186    // Skip directories
187    if path.is_dir() {
188        return false;
189    }
190
191    // Check if file extension is supported
192    if let Some(ext) = path.extension() {
193        let ext_str = ext.to_string_lossy();
194        let lang = Language::from_extension(&ext_str);
195        return lang.is_supported();
196    }
197
198    false
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::fs;
205    use tempfile::TempDir;
206
207    #[test]
208    fn test_should_watch_rust_file() {
209        let temp = TempDir::new().unwrap();
210        let rust_file = temp.path().join("test.rs");
211        fs::write(&rust_file, "fn main() {}").unwrap();
212
213        assert!(should_watch_file(&rust_file));
214    }
215
216    #[test]
217    fn test_should_not_watch_unsupported_file() {
218        let temp = TempDir::new().unwrap();
219        let txt_file = temp.path().join("test.txt");
220        fs::write(&txt_file, "plain text").unwrap();
221
222        assert!(!should_watch_file(&txt_file));
223    }
224
225    #[test]
226    fn test_should_not_watch_hidden_file() {
227        let temp = TempDir::new().unwrap();
228        let hidden_file = temp.path().join(".hidden.rs");
229        fs::write(&hidden_file, "fn main() {}").unwrap();
230
231        assert!(!should_watch_file(&hidden_file));
232    }
233
234    #[test]
235    fn test_should_not_watch_directory() {
236        let temp = TempDir::new().unwrap();
237        let dir = temp.path().join("src");
238        fs::create_dir(&dir).unwrap();
239
240        assert!(!should_watch_file(&dir));
241    }
242
243    #[test]
244    fn test_watch_config_default() {
245        let config = WatchConfig::default();
246        assert_eq!(config.debounce_ms, 15000);
247        assert!(!config.quiet);
248    }
249
250    #[test]
251    fn test_process_event_create() {
252        let event = Event {
253            kind: EventKind::Create(notify::event::CreateKind::File),
254            paths: vec![PathBuf::from("/test/file.rs")],
255            attrs: Default::default(),
256        };
257
258        let path = process_event(&event);
259        assert!(path.is_some());
260        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
261    }
262
263    #[test]
264    fn test_process_event_modify() {
265        let event = Event {
266            kind: EventKind::Modify(notify::event::ModifyKind::Data(
267                notify::event::DataChange::Any,
268            )),
269            paths: vec![PathBuf::from("/test/file.rs")],
270            attrs: Default::default(),
271        };
272
273        let path = process_event(&event);
274        assert!(path.is_some());
275        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
276    }
277
278    #[test]
279    fn test_process_event_access_ignored() {
280        let event = Event {
281            kind: EventKind::Access(notify::event::AccessKind::Read),
282            paths: vec![PathBuf::from("/test/file.rs")],
283            attrs: Default::default(),
284        };
285
286        let path = process_event(&event);
287        assert!(path.is_none());
288    }
289}