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 last_event_time: Option<Instant> = None;
87 let debounce_duration = Duration::from_millis(config.debounce_ms);
88
89 loop {
91 match rx.recv_timeout(Duration::from_millis(100)) {
93 Ok(Ok(event)) => {
94 if let Some(changed_path) = process_event(&event) {
96 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 if let Some(last_time) = last_event_time {
110 if !pending_files.is_empty() && last_time.elapsed() >= debounce_duration {
111 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 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
162fn process_event(event: &Event) -> Option<PathBuf> {
166 match event.kind {
168 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
169 event.paths.first().cloned()
171 }
172 _ => None,
173 }
174}
175
176fn should_watch_file(path: &Path) -> bool {
180 if let Some(file_name) = path.file_name() {
182 if file_name.to_string_lossy().starts_with('.') {
183 return false;
184 }
185 }
186
187 if path.is_dir() {
189 return false;
190 }
191
192 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}