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;
15
16#[derive(Debug, Clone)]
18pub struct WatchConfig {
19 pub debounce_ms: u64,
22 pub quiet: bool,
24}
25
26impl Default for WatchConfig {
27 fn default() -> Self {
28 Self {
29 debounce_ms: 15000, quiet: false,
31 }
32 }
33}
34
35pub 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 let (tx, rx) = channel();
69
70 let mut watcher = RecommendedWatcher::new(tx, Config::default())
72 .context("Failed to create file watcher")?;
73
74 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 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 loop {
90 match rx.recv_timeout(Duration::from_millis(100)) {
92 Ok(Ok(event)) => {
93 if let Some(changed_path) = process_event(&event) {
95 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 if let Some(last_time) = last_event_time {
109 if !pending_files.is_empty() && last_time.elapsed() >= debounce_duration {
110 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 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
161fn process_event(event: &Event) -> Option<PathBuf> {
165 match event.kind {
167 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
168 event.paths.first().cloned()
170 }
171 _ => None,
172 }
173}
174
175fn should_watch_file(path: &Path) -> bool {
179 if let Some(file_name) = path.file_name() {
181 if file_name.to_string_lossy().starts_with('.') {
182 return false;
183 }
184 }
185
186 if path.is_dir() {
188 return false;
189 }
190
191 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}