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    let mut last_event_time: Option<Instant> = None;
87    let debounce_duration = Duration::from_millis(config.debounce_ms);
88
89    // Event loop
90    loop {
91        // Try to receive events with 100ms timeout (allows checking debounce timer)
92        match rx.recv_timeout(Duration::from_millis(100)) {
93            Ok(Ok(event)) => {
94                // Process the file system event
95                if let Some(changed_path) = process_event(&event) {
96                    // Filter to only supported file types
97                    if should_watch_file(&changed_path) {
98                        log::debug!("Detected change: {:?}", changed_path);
99                        pending_files.insert(changed_path);
100                        last_event_time = Some(Instant::now());
101                    }
102                }
103            }
104            Ok(Err(e)) => {
105                log::warn!("Watch error: {}", e);
106            }
107            Err(RecvTimeoutError::Timeout) => {
108                // Check if debounce period has elapsed
109                if let Some(last_time) = last_event_time {
110                    if !pending_files.is_empty() && last_time.elapsed() >= debounce_duration {
111                        // Trigger reindex
112                        if !config.quiet {
113                            println!(
114                                "\nDetected {} changed file(s), reindexing...",
115                                pending_files.len()
116                            );
117                        }
118
119                        let start = Instant::now();
120                        match indexer.index(path, false) {
121                            Ok(stats) => {
122                                let elapsed = start.elapsed();
123                                if !config.quiet {
124                                    println!(
125                                        "✓ Reindexed {} files in {:.1}ms\n",
126                                        stats.total_files,
127                                        elapsed.as_secs_f64() * 1000.0
128                                    );
129                                }
130                                log::info!(
131                                    "Reindexed {} files in {:?}",
132                                    stats.total_files,
133                                    elapsed
134                                );
135                            }
136                            Err(e) => {
137                                output::error(&format!("✗ Reindex failed: {}", e));
138                                log::error!("Reindex failed: {}", e);
139                            }
140                        }
141
142                        // Clear pending changes
143                        pending_files.clear();
144                        last_event_time = None;
145                    }
146                }
147            }
148            Err(RecvTimeoutError::Disconnected) => {
149                log::info!("Watcher channel disconnected, stopping...");
150                break;
151            }
152        }
153    }
154
155    if !config.quiet {
156        println!("Watcher stopped.");
157    }
158
159    Ok(())
160}
161
162/// Process a file system event and extract the changed path
163///
164/// Returns None if the event should be ignored (e.g., metadata changes, directory events)
165fn process_event(event: &Event) -> Option<PathBuf> {
166    // Only care about Create, Modify, and Remove events
167    match event.kind {
168        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
169            // Take the first path (usually only one)
170            event.paths.first().cloned()
171        }
172        _ => None,
173    }
174}
175
176/// Check if a file should trigger a reindex
177///
178/// Returns true if the file has a supported language extension
179fn should_watch_file(path: &Path) -> bool {
180    // Skip hidden files and directories
181    if let Some(file_name) = path.file_name() {
182        if file_name.to_string_lossy().starts_with('.') {
183            return false;
184        }
185    }
186
187    // Skip directories
188    if path.is_dir() {
189        return false;
190    }
191
192    // Check if file extension is supported
193    if let Some(ext) = path.extension() {
194        let ext_str = ext.to_string_lossy();
195        let lang = Language::from_extension(&ext_str);
196        return lang.is_supported();
197    }
198
199    false
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::fs;
206    use tempfile::TempDir;
207
208    #[test]
209    fn test_should_watch_rust_file() {
210        let temp = TempDir::new().unwrap();
211        let rust_file = temp.path().join("test.rs");
212        fs::write(&rust_file, "fn main() {}").unwrap();
213
214        assert!(should_watch_file(&rust_file));
215    }
216
217    #[test]
218    fn test_should_not_watch_unsupported_file() {
219        let temp = TempDir::new().unwrap();
220        let txt_file = temp.path().join("test.txt");
221        fs::write(&txt_file, "plain text").unwrap();
222
223        assert!(!should_watch_file(&txt_file));
224    }
225
226    #[test]
227    fn test_should_not_watch_hidden_file() {
228        let temp = TempDir::new().unwrap();
229        let hidden_file = temp.path().join(".hidden.rs");
230        fs::write(&hidden_file, "fn main() {}").unwrap();
231
232        assert!(!should_watch_file(&hidden_file));
233    }
234
235    #[test]
236    fn test_should_not_watch_directory() {
237        let temp = TempDir::new().unwrap();
238        let dir = temp.path().join("src");
239        fs::create_dir(&dir).unwrap();
240
241        assert!(!should_watch_file(&dir));
242    }
243
244    #[test]
245    fn test_watch_config_default() {
246        let config = WatchConfig::default();
247        assert_eq!(config.debounce_ms, 15000);
248        assert!(!config.quiet);
249    }
250
251    #[test]
252    fn test_process_event_create() {
253        let event = Event {
254            kind: EventKind::Create(notify::event::CreateKind::File),
255            paths: vec![PathBuf::from("/test/file.rs")],
256            attrs: Default::default(),
257        };
258
259        let path = process_event(&event);
260        assert!(path.is_some());
261        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
262    }
263
264    #[test]
265    fn test_process_event_modify() {
266        let event = Event {
267            kind: EventKind::Modify(notify::event::ModifyKind::Data(
268                notify::event::DataChange::Any,
269            )),
270            paths: vec![PathBuf::from("/test/file.rs")],
271            attrs: Default::default(),
272        };
273
274        let path = process_event(&event);
275        assert!(path.is_some());
276        assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
277    }
278
279    #[test]
280    fn test_process_event_access_ignored() {
281        let event = Event {
282            kind: EventKind::Access(notify::event::AccessKind::Read),
283            paths: vec![PathBuf::from("/test/file.rs")],
284            attrs: Default::default(),
285        };
286
287        let path = process_event(&event);
288        assert!(path.is_none());
289    }
290}