venus_server/
watcher.rs

1//! File watcher for detecting notebook changes.
2//!
3//! Watches `.rs` notebook files and notifies the session when changes occur.
4
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use notify_debouncer_mini::{DebounceEventResult, new_debouncer, notify::RecursiveMode};
10use tokio::sync::mpsc;
11
12use crate::error::{ServerError, ServerResult};
13
14/// File change event.
15#[derive(Debug, Clone)]
16pub enum FileEvent {
17    /// File was modified.
18    Modified(PathBuf),
19    /// File was created.
20    Created(PathBuf),
21    /// File was removed.
22    Removed(PathBuf),
23}
24
25/// File watcher handle.
26pub struct FileWatcher {
27    /// Debouncer handle (kept alive to maintain watcher).
28    _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
29    /// Receiver for file events.
30    rx: mpsc::UnboundedReceiver<FileEvent>,
31}
32
33impl FileWatcher {
34    /// Create a new file watcher for the given path.
35    pub fn new(path: impl AsRef<Path>) -> ServerResult<Self> {
36        let path = path.as_ref().to_path_buf();
37        let watch_path = if path.is_file() {
38            path.parent().unwrap_or(Path::new(".")).to_path_buf()
39        } else {
40            path.clone()
41        };
42
43        let (tx, rx) = mpsc::unbounded_channel();
44        let target_file = if path.is_file() {
45            Some(Arc::new(path))
46        } else {
47            None
48        };
49
50        let mut debouncer = new_debouncer(
51            Duration::from_millis(200),
52            move |result: DebounceEventResult| {
53                if let Ok(events) = result {
54                    for event in events {
55                        let event_path = &event.path;
56
57                        // Filter to only .rs files
58                        if event_path.extension().is_none_or(|ext| ext != "rs") {
59                            continue;
60                        }
61
62                        // If watching a specific file, only report events for that file
63                        if let Some(ref target) = target_file
64                            && event_path != target.as_ref()
65                        {
66                            continue;
67                        }
68
69                        let file_event = if event_path.exists() {
70                            FileEvent::Modified(event_path.clone())
71                        } else {
72                            FileEvent::Removed(event_path.clone())
73                        };
74
75                        let _ = tx.send(file_event);
76                    }
77                }
78            },
79        )
80        .map_err(|e| ServerError::Watch(e.to_string()))?;
81
82        debouncer
83            .watcher()
84            .watch(&watch_path, RecursiveMode::NonRecursive)
85            .map_err(|e| ServerError::Watch(e.to_string()))?;
86
87        Ok(Self {
88            _debouncer: debouncer,
89            rx,
90        })
91    }
92
93    /// Receive the next file event.
94    pub async fn recv(&mut self) -> Option<FileEvent> {
95        self.rx.recv().await
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use std::fs;
103    use std::time::Duration;
104    use tempfile::TempDir;
105    use tokio::time::sleep;
106
107    #[tokio::test]
108    async fn test_watcher_creation() {
109        let temp = TempDir::new().unwrap();
110        let notebook = temp.path().join("test.rs");
111        fs::write(&notebook, "// test").unwrap();
112
113        let watcher = FileWatcher::new(&notebook);
114        assert!(watcher.is_ok());
115    }
116
117    #[tokio::test]
118    async fn test_watcher_detects_modification() {
119        let temp = TempDir::new().unwrap();
120        let notebook = temp.path().join("test.rs");
121        fs::write(&notebook, "// initial content").unwrap();
122
123        let mut watcher = FileWatcher::new(&notebook).unwrap();
124
125        // Give the watcher time to initialize
126        sleep(Duration::from_millis(100)).await;
127
128        // Modify the file
129        fs::write(&notebook, "// modified content").unwrap();
130
131        // Wait for debounce + processing
132        let timeout = tokio::time::timeout(Duration::from_secs(2), watcher.recv()).await;
133
134        assert!(timeout.is_ok(), "Watcher did not detect modification");
135        let event = timeout.unwrap();
136
137        match event {
138            Some(FileEvent::Modified(path)) => {
139                assert_eq!(path, notebook);
140            }
141            Some(other) => panic!("Expected Modified event, got {:?}", other),
142            None => panic!("Received None from watcher"),
143        }
144    }
145
146    #[tokio::test]
147    async fn test_watcher_ignores_non_rust_files() {
148        let temp = TempDir::new().unwrap();
149        let notebook = temp.path().join("test.rs");
150        fs::write(&notebook, "// test").unwrap();
151
152        let mut watcher = FileWatcher::new(&notebook).unwrap();
153
154        // Give the watcher time to initialize
155        sleep(Duration::from_millis(100)).await;
156
157        // Create a non-Rust file (should be ignored)
158        let other_file = temp.path().join("test.txt");
159        fs::write(&other_file, "text content").unwrap();
160
161        // Wait a bit to ensure no event is generated
162        let timeout =
163            tokio::time::timeout(Duration::from_millis(500), watcher.recv()).await;
164
165        // Should timeout because .txt files are filtered out
166        assert!(timeout.is_err(), "Watcher should ignore non-.rs files");
167    }
168
169    #[tokio::test]
170    async fn test_watcher_directory_mode() {
171        let temp = TempDir::new().unwrap();
172        let notebook = temp.path().join("test.rs");
173        fs::write(&notebook, "// test").unwrap();
174
175        // Watch the directory instead of specific file
176        let mut watcher = FileWatcher::new(temp.path()).unwrap();
177
178        // Give the watcher time to initialize
179        sleep(Duration::from_millis(100)).await;
180
181        // Modify the file
182        fs::write(&notebook, "// modified").unwrap();
183
184        // Should still detect changes
185        let timeout = tokio::time::timeout(Duration::from_secs(2), watcher.recv()).await;
186
187        assert!(
188            timeout.is_ok(),
189            "Directory watcher did not detect file modification"
190        );
191    }
192
193    #[tokio::test]
194    async fn test_file_event_types() {
195        // Test FileEvent variants
196        let event = FileEvent::Modified(PathBuf::from("/test.rs"));
197        assert!(matches!(event, FileEvent::Modified(_)));
198
199        let event = FileEvent::Created(PathBuf::from("/test.rs"));
200        assert!(matches!(event, FileEvent::Created(_)));
201
202        let event = FileEvent::Removed(PathBuf::from("/test.rs"));
203        assert!(matches!(event, FileEvent::Removed(_)));
204    }
205}