1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::mpsc::{channel, Receiver};
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
20use tracing::{debug, error, info, warn};
21
22use xore_core::{Result, XoreError};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum FileEvent {
27 Created(PathBuf),
29 Modified(PathBuf),
31 Deleted(PathBuf),
33 Renamed { from: PathBuf, to: PathBuf },
35}
36
37impl FileEvent {
38 pub fn paths(&self) -> Vec<&PathBuf> {
40 match self {
41 FileEvent::Created(p) | FileEvent::Modified(p) | FileEvent::Deleted(p) => vec![p],
42 FileEvent::Renamed { from, to } => vec![from, to],
43 }
44 }
45
46 pub fn kind_str(&self) -> &'static str {
48 match self {
49 FileEvent::Created(_) => "created",
50 FileEvent::Modified(_) => "modified",
51 FileEvent::Deleted(_) => "deleted",
52 FileEvent::Renamed { .. } => "renamed",
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct WatcherConfig {
60 pub debounce_duration: Duration,
62 pub batch_size: usize,
64 pub exclude_patterns: Vec<String>,
66 pub include_hidden: bool,
68}
69
70impl Default for WatcherConfig {
71 fn default() -> Self {
72 Self {
73 debounce_duration: Duration::from_millis(500),
74 batch_size: 50,
75 exclude_patterns: vec![
76 ".git".to_string(),
77 "node_modules".to_string(),
78 "target".to_string(),
79 ".xore".to_string(),
80 "*.tmp".to_string(),
81 "*.swp".to_string(),
82 ],
83 include_hidden: false,
84 }
85 }
86}
87
88struct Debouncer {
90 pending: Arc<Mutex<HashMap<PathBuf, (FileEvent, Instant)>>>,
92 duration: Duration,
94}
95
96impl Debouncer {
97 fn new(duration: Duration) -> Self {
98 Self { pending: Arc::new(Mutex::new(HashMap::new())), duration }
99 }
100
101 fn add(&self, event: FileEvent) {
103 let mut pending = self.pending.lock().unwrap();
104 for path in event.paths() {
105 pending.insert(path.clone(), (event.clone(), Instant::now()));
106 }
107 }
108
109 fn drain(&self) -> Vec<FileEvent> {
111 let mut pending = self.pending.lock().unwrap();
112 let now = Instant::now();
113 let mut ready = Vec::new();
114
115 pending.retain(|_path, (event, timestamp)| {
117 if now.duration_since(*timestamp) >= self.duration {
118 ready.push(event.clone());
119 false } else {
121 true }
123 });
124
125 ready
126 }
127}
128
129pub struct EventFilter {
131 excludes: Vec<String>,
132 include_hidden: bool,
133}
134
135impl EventFilter {
136 pub fn new(config: &WatcherConfig) -> Self {
137 Self { excludes: config.exclude_patterns.clone(), include_hidden: config.include_hidden }
138 }
139
140 pub fn should_index(&self, path: &Path) -> bool {
142 if !self.include_hidden {
144 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
145 if name.starts_with('.') {
146 return false;
147 }
148 }
149 }
150
151 let path_str = path.to_string_lossy();
153 for pattern in &self.excludes {
154 if pattern.contains('*') {
155 if Self::wildcard_match(&path_str, pattern) {
157 return false;
158 }
159 } else if path_str.contains(pattern) {
160 return false;
161 }
162 }
163
164 if path.is_dir() {
166 return false;
167 }
168
169 true
170 }
171
172 fn wildcard_match(text: &str, pattern: &str) -> bool {
174 if pattern.contains('*') {
175 let parts: Vec<&str> = pattern.split('*').collect();
176 if parts.len() == 2 {
177 text.starts_with(parts[0]) && text.ends_with(parts[1])
178 } else {
179 false
180 }
181 } else {
182 text == pattern
183 }
184 }
185}
186
187pub struct FileWatcher {
189 _watcher: RecommendedWatcher,
190 event_rx: Receiver<std::result::Result<Event, notify::Error>>,
191 debouncer: Debouncer,
192 filter: EventFilter,
193 config: WatcherConfig,
194}
195
196impl FileWatcher {
197 pub fn new(config: WatcherConfig) -> Result<Self> {
199 let (tx, rx) = channel();
200
201 let watcher = RecommendedWatcher::new(
202 move |res| {
203 if let Err(e) = tx.send(res) {
204 error!("Failed to send watch event: {}", e);
205 }
206 },
207 Config::default(),
208 )
209 .map_err(|e| XoreError::Other(format!("Failed to create watcher: {}", e)))?;
210
211 let debouncer = Debouncer::new(config.debounce_duration);
212 let filter = EventFilter::new(&config);
213
214 Ok(Self { _watcher: watcher, event_rx: rx, debouncer, filter, config })
215 }
216
217 pub fn watch_path(&mut self, path: &Path) -> Result<()> {
219 self._watcher
220 .watch(path, RecursiveMode::Recursive)
221 .map_err(|e| XoreError::Other(format!("Failed to watch path: {}", e)))?;
222
223 info!("Started watching: {}", path.display());
224 Ok(())
225 }
226
227 pub fn unwatch_path(&mut self, path: &Path) -> Result<()> {
229 self._watcher
230 .unwatch(path)
231 .map_err(|e| XoreError::Other(format!("Failed to unwatch path: {}", e)))?;
232
233 info!("Stopped watching: {}", path.display());
234 Ok(())
235 }
236
237 pub fn recv_events(&mut self) -> Result<Vec<FileEvent>> {
239 while let Ok(res) = self.event_rx.try_recv() {
241 match res {
242 Ok(event) => {
243 if let Some(file_event) = self.process_event(event) {
244 self.debouncer.add(file_event);
245 }
246 }
247 Err(e) => {
248 warn!("Watch error: {}", e);
249 }
250 }
251 }
252
253 let events = self.debouncer.drain();
255
256 let filtered: Vec<FileEvent> = events
258 .into_iter()
259 .filter(|e| {
260 for path in e.paths() {
261 if !self.filter.should_index(path) {
262 debug!("Filtered out: {}", path.display());
263 return false;
264 }
265 }
266 true
267 })
268 .collect();
269
270 Ok(filtered)
271 }
272
273 fn process_event(&self, event: Event) -> Option<FileEvent> {
275 match event.kind {
276 EventKind::Create(_) => {
277 if let Some(path) = event.paths.first() {
278 debug!("File created: {}", path.display());
279 return Some(FileEvent::Created(path.clone()));
280 }
281 }
282 EventKind::Modify(_) => {
283 if let Some(path) = event.paths.first() {
284 debug!("File modified: {}", path.display());
285 return Some(FileEvent::Modified(path.clone()));
286 }
287 }
288 EventKind::Remove(_) => {
289 if let Some(path) = event.paths.first() {
290 debug!("File deleted: {}", path.display());
291 return Some(FileEvent::Deleted(path.clone()));
292 }
293 }
294 EventKind::Access(_) => {
295 return None;
297 }
298 _ => {
299 debug!("Unhandled event kind: {:?}", event.kind);
300 }
301 }
302 None
303 }
304
305 pub fn config(&self) -> &WatcherConfig {
307 &self.config
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use std::fs;
315 use std::thread;
316 use tempfile::TempDir;
317
318 #[test]
319 fn test_file_event_paths() {
320 let event = FileEvent::Created(PathBuf::from("/test.txt"));
321 assert_eq!(event.paths().len(), 1);
322
323 let event =
324 FileEvent::Renamed { from: PathBuf::from("/old.txt"), to: PathBuf::from("/new.txt") };
325 assert_eq!(event.paths().len(), 2);
326 }
327
328 #[test]
329 fn test_event_filter_hidden_files() {
330 let config = WatcherConfig { include_hidden: false, ..Default::default() };
331 let filter = EventFilter::new(&config);
332
333 assert!(!filter.should_index(Path::new(".hidden")));
334 assert!(filter.should_index(Path::new("visible.txt")));
335 }
336
337 #[test]
338 fn test_event_filter_exclude_patterns() {
339 let config = WatcherConfig {
340 exclude_patterns: vec!["node_modules".to_string(), "*.tmp".to_string()],
341 ..Default::default()
342 };
343 let filter = EventFilter::new(&config);
344
345 assert!(!filter.should_index(Path::new("node_modules/test.js")));
346 assert!(!filter.should_index(Path::new("test.tmp")));
347 assert!(filter.should_index(Path::new("src/main.rs")));
348 }
349
350 #[test]
351 fn test_event_filter_directories() {
352 let config = WatcherConfig::default();
353 let filter = EventFilter::new(&config);
354
355 let temp_dir = TempDir::new().unwrap();
356 assert!(!filter.should_index(temp_dir.path()));
357 }
358
359 #[test]
360 fn test_wildcard_match() {
361 assert!(EventFilter::wildcard_match("test.tmp", "*.tmp"));
362 assert!(EventFilter::wildcard_match("backup.bak", "*.bak"));
363 assert!(!EventFilter::wildcard_match("test.txt", "*.tmp"));
364 }
365
366 #[test]
367 fn test_debouncer() {
368 let debouncer = Debouncer::new(Duration::from_millis(100));
369
370 debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
372
373 let events = debouncer.drain();
375 assert_eq!(events.len(), 0);
376
377 thread::sleep(Duration::from_millis(150));
379
380 let events = debouncer.drain();
382 assert_eq!(events.len(), 1);
383 }
384
385 #[test]
386 fn test_debouncer_multiple_events_same_file() {
387 let debouncer = Debouncer::new(Duration::from_millis(100));
388
389 debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
391 thread::sleep(Duration::from_millis(20));
392 debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
393 thread::sleep(Duration::from_millis(20));
394 debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
395
396 thread::sleep(Duration::from_millis(150));
398
399 let events = debouncer.drain();
401 assert_eq!(events.len(), 1);
402 }
403
404 #[test]
405 fn test_watcher_creation() {
406 let config = WatcherConfig::default();
407 let watcher = FileWatcher::new(config);
408 assert!(watcher.is_ok());
409 }
410
411 #[test]
412 fn test_watcher_watch_path() {
413 let temp_dir = TempDir::new().unwrap();
414 let config = WatcherConfig::default();
415 let mut watcher = FileWatcher::new(config).unwrap();
416
417 let result = watcher.watch_path(temp_dir.path());
418 assert!(result.is_ok());
419 }
420
421 fn wait_for_events(watcher: &mut FileWatcher, max_attempts: usize) -> Vec<FileEvent> {
423 for _ in 0..max_attempts {
424 thread::sleep(Duration::from_millis(100));
425 if let Ok(events) = watcher.recv_events() {
426 if !events.is_empty() {
427 return events;
428 }
429 }
430 }
431 vec![]
432 }
433
434 #[test]
435 fn test_watcher_file_create() {
436 let temp_dir = TempDir::new().unwrap();
437 let config =
438 WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
439 let mut watcher = FileWatcher::new(config).unwrap();
440
441 watcher.watch_path(temp_dir.path()).unwrap();
442
443 let test_file = temp_dir.path().join("test.txt");
445 fs::write(&test_file, "hello").unwrap();
446
447 let events = wait_for_events(&mut watcher, 10);
449 if !events.is_empty() {
452 println!("Received {} events", events.len());
453 }
454 }
455
456 #[test]
457 fn test_watcher_file_modify() {
458 let temp_dir = TempDir::new().unwrap();
459 let test_file = temp_dir.path().join("test.txt");
460 fs::write(&test_file, "hello").unwrap();
461
462 let config =
463 WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
464 let mut watcher = FileWatcher::new(config).unwrap();
465 watcher.watch_path(temp_dir.path()).unwrap();
466
467 thread::sleep(Duration::from_millis(100));
469
470 fs::write(&test_file, "world").unwrap();
472
473 let events = wait_for_events(&mut watcher, 10);
475 if !events.is_empty() {
476 println!("Received {} events", events.len());
477 }
478 }
479
480 #[test]
481 fn test_watcher_file_delete() {
482 let temp_dir = TempDir::new().unwrap();
483 let test_file = temp_dir.path().join("test.txt");
484 fs::write(&test_file, "hello").unwrap();
485
486 let config =
487 WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
488 let mut watcher = FileWatcher::new(config).unwrap();
489 watcher.watch_path(temp_dir.path()).unwrap();
490
491 thread::sleep(Duration::from_millis(100));
493
494 fs::remove_file(&test_file).unwrap();
496
497 let events = wait_for_events(&mut watcher, 10);
499 if !events.is_empty() {
500 println!("Received {} events", events.len());
501 }
502 }
503
504 #[test]
505 fn test_watcher_multiple_files() {
506 let temp_dir = TempDir::new().unwrap();
507 let config =
508 WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
509 let mut watcher = FileWatcher::new(config).unwrap();
510 watcher.watch_path(temp_dir.path()).unwrap();
511
512 for i in 0..5 {
514 let file = temp_dir.path().join(format!("test{}.txt", i));
515 fs::write(file, format!("content {}", i)).unwrap();
516 thread::sleep(Duration::from_millis(10));
517 }
518
519 let events = wait_for_events(&mut watcher, 10);
521 if !events.is_empty() {
522 println!("Received {} events", events.len());
523 }
524 }
525
526 #[test]
527 fn test_watcher_exclude_patterns() {
528 let temp_dir = TempDir::new().unwrap();
529 let config = WatcherConfig {
530 debounce_duration: Duration::from_millis(100),
531 exclude_patterns: vec!["*.tmp".to_string()],
532 ..Default::default()
533 };
534 let mut watcher = FileWatcher::new(config).unwrap();
535 watcher.watch_path(temp_dir.path()).unwrap();
536
537 let tmp_file = temp_dir.path().join("test.tmp");
539 fs::write(&tmp_file, "temp").unwrap();
540
541 let txt_file = temp_dir.path().join("test.txt");
543 fs::write(&txt_file, "real").unwrap();
544
545 thread::sleep(Duration::from_millis(200));
547
548 let events = watcher.recv_events().unwrap();
549 for event in &events {
551 for path in event.paths() {
552 assert!(!path.to_string_lossy().ends_with(".tmp"));
553 }
554 }
555 }
556}