libsurfer/
file_watcher.rs

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