1use notify_debouncer_full::notify::{RecommendedWatcher, RecursiveMode};
4use notify_debouncer_full::{DebounceEventResult, Debouncer, RecommendedCache, new_debouncer};
5use ragfs_core::FileEvent;
6use std::path::Path;
7use std::sync::mpsc;
8use std::time::Duration;
9use tokio::sync::mpsc as tokio_mpsc;
10use tracing::{debug, error, warn};
11
12pub struct FileWatcher {
14 debouncer: Debouncer<RecommendedWatcher, RecommendedCache>,
15}
16
17impl FileWatcher {
18 pub fn new(
20 event_tx: tokio_mpsc::Sender<FileEvent>,
21 debounce_duration: Duration,
22 ) -> Result<Self, notify::Error> {
23 let (tx, rx) = mpsc::channel();
24
25 let event_tx_clone = event_tx.clone();
27 std::thread::spawn(move || {
28 while let Ok(result) = rx.recv() {
29 if let Err(e) = handle_debounced_events(result, &event_tx_clone) {
30 error!("Error handling file events: {e}");
31 }
32 }
33 });
34
35 let debouncer = new_debouncer(debounce_duration, None, move |result| {
36 let _ = tx.send(result);
37 })?;
38
39 Ok(Self { debouncer })
40 }
41
42 pub fn watch(&mut self, path: &Path) -> Result<(), notify_debouncer_full::notify::Error> {
44 debug!("Starting to watch: {:?}", path);
45 self.debouncer.watch(path, RecursiveMode::Recursive)?;
46 Ok(())
47 }
48
49 pub fn unwatch(&mut self, path: &Path) -> Result<(), notify_debouncer_full::notify::Error> {
51 debug!("Stopping watch: {:?}", path);
52 self.debouncer.unwatch(path)?;
53 Ok(())
54 }
55}
56
57fn handle_debounced_events(
58 result: DebounceEventResult,
59 event_tx: &tokio_mpsc::Sender<FileEvent>,
60) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
61 match result {
62 Ok(events) => {
63 for event in events {
64 if let Some(file_event) = convert_event(&event) {
65 if event_tx.blocking_send(file_event).is_err() {
67 warn!("Event channel closed");
68 break;
69 }
70 }
71 }
72 }
73 Err(errors) => {
74 for error in errors {
75 error!("Watch error: {error}");
76 }
77 }
78 }
79 Ok(())
80}
81
82fn convert_event(event: ¬ify_debouncer_full::DebouncedEvent) -> Option<FileEvent> {
83 use notify_debouncer_full::notify::EventKind;
84
85 let path = event.paths.first()?.clone();
86
87 if path
89 .file_name()
90 .is_some_and(|name| name.to_string_lossy().starts_with('.'))
91 {
92 return None;
93 }
94
95 match &event.kind {
96 EventKind::Create(_) => Some(FileEvent::Created(path)),
97 EventKind::Modify(_) => Some(FileEvent::Modified(path)),
98 EventKind::Remove(_) => Some(FileEvent::Deleted(path)),
99 EventKind::Other => {
100 if event.paths.len() >= 2 {
102 Some(FileEvent::Renamed {
103 from: event.paths[0].clone(),
104 to: event.paths[1].clone(),
105 })
106 } else {
107 None
108 }
109 }
110 _ => None,
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use notify_debouncer_full::DebouncedEvent;
118 use notify_debouncer_full::notify::EventKind;
119 use notify_debouncer_full::notify::event::{CreateKind, ModifyKind, RemoveKind};
120 use std::path::PathBuf;
121 use std::time::Instant;
122
123 fn make_event(kind: EventKind, paths: Vec<PathBuf>) -> DebouncedEvent {
124 DebouncedEvent {
125 event: notify_debouncer_full::notify::Event {
126 kind,
127 paths,
128 attrs: Default::default(),
129 },
130 time: Instant::now(),
131 }
132 }
133
134 #[test]
135 fn test_convert_event_create() {
136 let path = PathBuf::from("/tmp/test.txt");
137 let event = make_event(EventKind::Create(CreateKind::File), vec![path.clone()]);
138
139 let result = convert_event(&event);
140 assert!(matches!(result, Some(FileEvent::Created(p)) if p == path));
141 }
142
143 #[test]
144 fn test_convert_event_modify() {
145 use notify_debouncer_full::notify::event::DataChange;
146 let path = PathBuf::from("/tmp/test.txt");
147 let event = make_event(
148 EventKind::Modify(ModifyKind::Data(DataChange::Any)),
149 vec![path.clone()],
150 );
151
152 let result = convert_event(&event);
153 assert!(matches!(result, Some(FileEvent::Modified(p)) if p == path));
154 }
155
156 #[test]
157 fn test_convert_event_delete() {
158 let path = PathBuf::from("/tmp/test.txt");
159 let event = make_event(EventKind::Remove(RemoveKind::File), vec![path.clone()]);
160
161 let result = convert_event(&event);
162 assert!(matches!(result, Some(FileEvent::Deleted(p)) if p == path));
163 }
164
165 #[test]
166 fn test_hidden_files_skipped() {
167 let path = PathBuf::from("/tmp/.hidden");
168 let event = make_event(EventKind::Create(CreateKind::File), vec![path]);
169
170 let result = convert_event(&event);
171 assert!(result.is_none());
172 }
173}