Skip to main content

libsurfer/
file_watcher.rs

1#[cfg(not(target_arch = "wasm32"))]
2use camino::Utf8Path;
3#[cfg(all(not(windows), not(target_arch = "wasm32")))]
4use notify::Error;
5#[cfg(all(not(windows), not(target_arch = "wasm32")))]
6use notify::{Config, Event, EventKind, RecursiveMode, Watcher, event::ModifyKind};
7#[cfg(all(not(windows), not(target_arch = "wasm32")))]
8use std::time::Duration;
9#[cfg(all(not(windows), not(target_arch = "wasm32")))]
10use tracing::{error, info};
11
12/// Watches a provided file for changes.
13/// Currently, this only works for Unix-like systems (tested on linux and macOS).
14pub struct FileWatcher {
15    #[cfg(all(not(windows), not(target_arch = "wasm32")))]
16    _inner: notify::RecommendedWatcher,
17}
18
19/// Checks whether two paths, pointing at a file, refer to the same file.
20/// This might be slower than some platform-dependent alternatives,
21/// but should be guaranteed to work on all platforms
22#[allow(dead_code)] // Only used in tests on Windows
23fn is_same_file(p1: impl AsRef<std::path::Path>, p2: impl AsRef<std::path::Path>) -> bool {
24    match (
25        std::fs::canonicalize(p1.as_ref()),
26        std::fs::canonicalize(p2.as_ref()),
27    ) {
28        (Ok(p1_canon), Ok(p2_canon)) => p1_canon == p2_canon,
29        _ => false,
30    }
31}
32
33#[cfg(all(not(windows), not(target_arch = "wasm32")))]
34impl FileWatcher {
35    /// Create a watcher for a path pointing to some file.
36    /// Whenever that file changes, the provided `on_change` will be called.
37    /// The returned `FileWatcher` will stop watching files when dropped.
38    pub fn new<F>(path: &Utf8Path, on_change: F) -> Result<FileWatcher, Error>
39    where
40        F: Fn() + Send + Sync + 'static,
41    {
42        let std_path = path.as_std_path().to_owned();
43        let binding = std_path.clone();
44        let parent = match binding.parent() {
45            Some(p) if p.as_os_str().is_empty() => std::path::Path::new("."),
46            Some(p) => p,
47            None => return Err(Error::new(notify::ErrorKind::PathNotFound).add_path(std_path)),
48        };
49        let mut watcher = notify::RecommendedWatcher::new(
50            move |res| match res {
51                Ok(Event {
52                    kind: EventKind::Modify(ModifyKind::Data(_)),
53                    paths,
54                    ..
55                }) => {
56                    if paths.iter().any(|path| is_same_file(path, &std_path)) {
57                        info!("Observed file {} was changed on disk", std_path.display());
58                        on_change();
59                    }
60                }
61                Ok(_) => {}
62                Err(e) => error!("Error while watching fil\n{}", e),
63            },
64            Config::default().with_poll_interval(Duration::from_secs(1)),
65        )?;
66
67        watcher.watch(parent, RecursiveMode::NonRecursive)?;
68        info!("Watching file {} for changes", binding.display());
69
70        Ok(FileWatcher { _inner: watcher })
71    }
72}
73
74// Currently, the windows tests fail with `exit code: 0xc000001d, STATUS_ILLEGAL_INSTRUCTION`.
75// It is not quite clear whether this issue originates with the `tempfile` crate or `notify`
76// (see https://github.com/notify-rs/notify/issues/624). Therefore, the FileWatcher is a noop
77// implementation for windows. Since, at the time of initial implementation,
78// this issue couldn't be resolved, the file watcher only has partial support for unix-like
79// systems
80#[cfg(windows)]
81impl FileWatcher {
82    pub fn new<F>(_path: &Utf8Path, _on_change: F) -> eyre::Result<FileWatcher>
83    where
84        F: Fn() + Send + Sync + 'static,
85    {
86        // blank implementation
87        Ok(FileWatcher {})
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use crate::file_watcher::{FileWatcher, is_same_file};
94    use camino::Utf8Path;
95    use std::fs;
96    use std::fs::File;
97    #[cfg(not(windows))]
98    use std::fs::OpenOptions;
99    #[cfg(not(windows))]
100    use std::io::Write;
101    use std::path::{Path, PathBuf};
102    use std::sync::{Arc, Condvar, Mutex};
103    use std::time::Duration;
104
105    struct TempDir {
106        inner: tempfile::TempDir,
107    }
108
109    impl TempDir {
110        pub fn new() -> TempDir {
111            TempDir {
112                inner: tempfile::TempDir::new().unwrap(),
113            }
114        }
115
116        pub fn create(&self, file: &str) -> PathBuf {
117            let file_path = self.path().join(file);
118            File::create(&file_path).unwrap();
119            file_path
120        }
121
122        pub fn mkdir(&self, name: &str) -> PathBuf {
123            let file_path = self.path().join(name);
124            fs::create_dir(&file_path).unwrap();
125            file_path
126        }
127
128        pub fn path(&self) -> &Path {
129            self.inner.path()
130        }
131    }
132
133    /// Guard ensuring that a callback is executed.
134    pub struct CallbackGuard {
135        called: Mutex<bool>,
136        lock: Condvar,
137    }
138
139    impl CallbackGuard {
140        pub fn new() -> Arc<Self> {
141            Arc::new(CallbackGuard {
142                called: Mutex::new(false),
143                lock: Condvar::new(),
144            })
145        }
146
147        pub fn signal(&self) {
148            let mut guard = self.called.lock().unwrap();
149            *guard = true;
150            self.lock.notify_all();
151        }
152
153        /// Block until signal has been called or a timeout occurred.
154        /// Panics on the timeout.
155        #[cfg(not(windows))]
156        pub fn assert_called(&self) {
157            let mut started = self.called.lock().unwrap();
158            let result = self
159                .lock
160                .wait_timeout(started, Duration::from_secs(10))
161                .unwrap();
162            started = result.0;
163            if *started {
164                // We received the notification and the value has been updated, we can leave.
165                return;
166            }
167            panic!("Timeout while waiting for callback")
168        }
169
170        /// Block until signal has been called or a timeout occurred.
171        /// Panics when the signal has been called.
172        pub fn assert_not_called(&self) {
173            let mut started = self.called.lock().unwrap();
174            let result = self
175                .lock
176                .wait_timeout(started, Duration::from_secs(10))
177                .unwrap();
178            started = result.0;
179            if *started {
180                panic!("Callback was called");
181            }
182        }
183    }
184
185    #[test]
186    #[cfg(not(windows))]
187    pub fn notifies_on_change() -> Result<(), Box<dyn std::error::Error>> {
188        let tmp_dir = TempDir::new();
189        let path = tmp_dir.create("test");
190
191        let barrier = CallbackGuard::new();
192        let barrier_clone = barrier.clone();
193        let _watcher = FileWatcher::new(Utf8Path::from_path(path.as_ref()).unwrap(), move || {
194            barrier_clone.signal();
195        });
196        {
197            // We open, write and close a file. The observer should have been called.
198            let mut file = OpenOptions::new().write(true).open(&path)?;
199            writeln!(file, "Changes")?;
200        }
201        barrier.assert_called();
202        Ok(())
203    }
204
205    #[test]
206    pub fn does_not_notify_on_create_and_delete() -> Result<(), Box<dyn std::error::Error>> {
207        let tmp_dir = TempDir::new();
208        let path = tmp_dir.path().join("test");
209
210        let barrier = CallbackGuard::new();
211        let barrier_clone = barrier.clone();
212
213        let _watcher = FileWatcher::new(Utf8Path::from_path(path.as_ref()).unwrap(), move || {
214            barrier_clone.signal();
215        });
216        {
217            // open a file
218            File::create(&path)?;
219        }
220        {
221            // delete the file
222            fs::remove_file(path)?;
223        }
224        barrier.assert_not_called();
225        Ok(())
226    }
227
228    #[test]
229    #[cfg(not(windows))]
230    pub fn resolves_files_that_are_named_differently() -> Result<(), Box<dyn std::error::Error>> {
231        let tmp_dir = TempDir::new();
232        let mut path = tmp_dir.mkdir("test");
233        path.push("test_file");
234        File::create(&path).unwrap();
235
236        let barrier = CallbackGuard::new();
237        let barrier_clone = barrier.clone();
238
239        let _watcher = FileWatcher::new(
240            &Utf8Path::from_path(tmp_dir.path())
241                .unwrap()
242                .join("test/test_file"),
243            move || {
244                barrier_clone.signal();
245            },
246        );
247        {
248            // We open, write and close a file. The observer should have been called.
249            let mut file = OpenOptions::new().write(true).open(&path)?;
250            writeln!(file, "Changes")?;
251        }
252        barrier.assert_called();
253        Ok(())
254    }
255
256    #[test]
257    pub fn check_file_for_difference() {
258        let tmp_dir = TempDir::new();
259        let file1 = tmp_dir.create("file1");
260        let dir = tmp_dir.mkdir("dir");
261        let mut file2 = dir.clone();
262        file2.push("file2");
263        File::create(&file2).unwrap();
264        assert!(is_same_file(&file1, &file1));
265        assert!(!is_same_file(&file1, &file2));
266        let mut complicated_file2 = dir.clone();
267        complicated_file2.push("../dir/file2");
268        assert!(is_same_file(&complicated_file2, &file2));
269    }
270}