1use 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#[derive(Debug, Clone)]
19pub struct WatchConfig {
20 pub debounce_ms: u64,
23 pub quiet: bool,
25}
26
27impl Default for WatchConfig {
28 fn default() -> Self {
29 Self {
30 debounce_ms: 15000, quiet: false,
32 }
33 }
34}
35
36pub 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 let (tx, rx) = channel();
70
71 let mut watcher =
73 RecommendedWatcher::new(tx, Config::default()).context("Failed to create file watcher")?;
74
75 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 let mut pending_files: HashSet<PathBuf> = HashSet::new();
89 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 loop {
98 match rx.recv_timeout(Duration::from_millis(100)) {
100 Ok(Ok(event)) => {
101 if let Some((changed_path, is_removal)) = process_event_typed(&event) {
103 if is_removal {
104 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 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 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 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
192fn 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#[allow(dead_code)]
211fn process_event(event: &Event) -> Option<PathBuf> {
212 process_event_typed(event).map(|(p, _)| p)
213}
214
215fn should_watch_file(path: &Path) -> bool {
219 if let Some(file_name) = path.file_name()
221 && file_name.to_string_lossy().starts_with('.')
222 {
223 return false;
224 }
225
226 if path.is_dir() {
228 return false;
229 }
230
231 if let Some(ext) = path.extension() {
233 let ext_str = ext.to_string_lossy();
234 let lang = Language::from_extension(&ext_str);
235 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 let unknown = temp.path().join("test.xyz");
270 fs::write(&unknown, "mystery format").unwrap();
271 assert!(!should_watch_file(&unknown));
272
273 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 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}