ragfs_index/
watcher.rs

1//! File system watcher for detecting changes.
2
3use 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
12/// File system watcher with debouncing.
13pub struct FileWatcher {
14    debouncer: Debouncer<RecommendedWatcher, RecommendedCache>,
15}
16
17impl FileWatcher {
18    /// Create a new file watcher.
19    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        // Spawn thread to convert events
26        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    /// Start watching a path.
43    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    /// Stop watching a path.
50    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                    // Use blocking send since we're in a std thread
66                    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: &notify_debouncer_full::DebouncedEvent) -> Option<FileEvent> {
83    use notify_debouncer_full::notify::EventKind;
84
85    let path = event.paths.first()?.clone();
86
87    // Skip hidden files and directories
88    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            // Handle rename as "other" event with two paths
101            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}