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::{channel, RecvTimeoutError};
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 = RecommendedWatcher::new(tx, Config::default())
73 .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!("Watching for changes (debounce: {}s)...", config.debounce_ms / 1000);
82 }
83
84 let mut pending_files: HashSet<PathBuf> = HashSet::new();
86 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 loop {
95 match rx.recv_timeout(Duration::from_millis(100)) {
97 Ok(Ok(event)) => {
98 if let Some((changed_path, is_removal)) = process_event_typed(&event) {
100 if is_removal {
101 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 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 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 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
190fn 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
205fn process_event(event: &Event) -> Option<PathBuf> {
209 process_event_typed(event).map(|(p, _)| p)
210}
211
212fn should_watch_file(path: &Path) -> bool {
216 if let Some(file_name) = path.file_name() {
218 if file_name.to_string_lossy().starts_with('.') {
219 return false;
220 }
221 }
222
223 if path.is_dir() {
225 return false;
226 }
227
228 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}