1use notify::{RecommendedWatcher, Watcher, WatcherKind};
13
14#[derive(Debug, thiserror::Error)]
16#[error(
17 "the platform resolved watch backend {resolved:?}, which is not one of \
18 the sanctioned native event backends (FSEvents, inotify); frame dev \
19 refuses to start rather than fall back to a polling watcher"
20)]
21pub struct NonNativeBackend {
22 pub resolved: WatcherKind,
24}
25
26pub fn assert_native_backend() -> Result<WatcherKind, NonNativeBackend> {
35 classify(<RecommendedWatcher as Watcher>::kind())
36}
37
38fn classify(resolved: WatcherKind) -> Result<WatcherKind, NonNativeBackend> {
39 match resolved {
40 WatcherKind::Fsevent | WatcherKind::Inotify => Ok(resolved),
41 other => Err(NonNativeBackend { resolved: other }),
42 }
43}
44
45#[derive(Debug, Clone)]
58pub struct WatchedSet {
59 pub watch_root: std::path::PathBuf,
61 manifest: std::path::PathBuf,
63 sources: std::path::PathBuf,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Classification {
70 Irrelevant,
72 Dirtying,
74 RootAffected(std::path::PathBuf),
80 Desynchronized,
84}
85
86impl WatchedSet {
87 #[must_use]
89 pub fn of_project(root: &std::path::Path) -> Self {
90 let component = root.join("component");
91 Self {
92 manifest: component.join("gleam.toml"),
93 sources: component.join("src"),
94 watch_root: component,
95 }
96 }
97
98 #[must_use]
102 pub fn classify(&self, event: ¬ify::Event) -> Classification {
103 use notify::EventKind;
104 use notify::event::ModifyKind;
105
106 if event.need_rescan() {
107 return Classification::Desynchronized;
108 }
109 match event.kind {
110 EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_)) => {
112 return Classification::Irrelevant;
113 }
114 EventKind::Any
118 | EventKind::Other
119 | EventKind::Create(_)
120 | EventKind::Modify(_)
121 | EventKind::Remove(_) => {}
122 }
123 if matches!(
124 event.kind,
125 EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(_))
126 ) && let Some(root) = event.paths.iter().find(|path| self.is_watched_root(path))
127 {
128 return Classification::RootAffected(root.clone());
129 }
130 if event.paths.iter().any(|path| self.is_build_input(path)) {
131 Classification::Dirtying
132 } else {
133 Classification::Irrelevant
134 }
135 }
136
137 fn is_watched_root(&self, path: &std::path::Path) -> bool {
140 path == self.sources || path == self.manifest || path == self.watch_root
141 }
142
143 fn is_build_input(&self, path: &std::path::Path) -> bool {
144 path == self.manifest || path.starts_with(&self.sources)
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 #![allow(clippy::expect_used)]
151
152 use super::{Classification, NonNativeBackend, WatchedSet, assert_native_backend, classify};
153 use notify::WatcherKind;
154
155 #[test]
158 fn this_platform_resolves_a_native_event_backend() {
159 let resolved = assert_native_backend().expect("native backend");
160 assert!(
161 matches!(resolved, WatcherKind::Fsevent | WatcherKind::Inotify),
162 "resolved {resolved:?}"
163 );
164 }
165
166 #[test]
169 fn poll_watcher_is_refused_by_name() {
170 let refusal = classify(WatcherKind::PollWatcher).expect_err("refuses");
171 let NonNativeBackend { resolved } = &refusal;
172 assert_eq!(*resolved, WatcherKind::PollWatcher);
173 let message = refusal.to_string();
174 assert!(message.contains("PollWatcher"), "{message}");
175 assert!(message.contains("polling watcher"), "{message}");
176 }
177
178 #[test]
181 fn kqueue_is_outside_the_ruled_set() {
182 classify(WatcherKind::Kqueue).expect_err("refuses kqueue");
183 classify(WatcherKind::NullWatcher).expect_err("refuses the test fake");
184 }
185
186 use notify::EventKind;
187 use notify::event::{
188 AccessKind, CreateKind, DataChange, Event, MetadataKind, ModifyKind, RemoveKind, RenameMode,
189 };
190 use std::path::Path;
191
192 fn set() -> WatchedSet {
193 WatchedSet::of_project(Path::new("/proj"))
194 }
195
196 fn event(kind: EventKind, path: &str) -> Event {
197 Event::new(kind).add_path(path.into())
198 }
199
200 #[test]
203 fn build_inputs_dirty_on_every_mutating_kind() {
204 let set = set();
205 for kind in [
206 EventKind::Create(CreateKind::File),
207 EventKind::Modify(ModifyKind::Data(DataChange::Content)),
208 EventKind::Modify(ModifyKind::Any),
209 EventKind::Remove(RemoveKind::File),
210 EventKind::Any,
211 ] {
212 assert_eq!(
213 set.classify(&event(kind, "/proj/component/src/app.gleam")),
214 Classification::Dirtying,
215 "{kind:?}"
216 );
217 }
218 assert_eq!(
219 set.classify(&event(
220 EventKind::Modify(ModifyKind::Data(DataChange::Content)),
221 "/proj/component/gleam.toml"
222 )),
223 Classification::Dirtying,
224 "the manifest is a build input"
225 );
226 assert_eq!(
227 set.classify(&event(
228 EventKind::Create(CreateKind::Folder),
229 "/proj/component/src/nested/dir"
230 )),
231 Classification::Dirtying,
232 "directories under src dirty too"
233 );
234 }
235
236 #[test]
239 fn metadata_and_access_noise_is_irrelevant() {
240 let set = set();
241 assert_eq!(
242 set.classify(&event(
243 EventKind::Modify(ModifyKind::Metadata(MetadataKind::Permissions)),
244 "/proj/component/src/app.gleam"
245 )),
246 Classification::Irrelevant
247 );
248 assert_eq!(
249 set.classify(&event(
250 EventKind::Access(AccessKind::Any),
251 "/proj/component/src/app.gleam"
252 )),
253 Classification::Irrelevant
254 );
255 }
256
257 #[test]
262 fn build_outputs_and_template_files_are_outside_the_set() {
263 let set = set();
264 assert_eq!(
265 set.classify(&event(
266 EventKind::Create(CreateKind::File),
267 "/proj/component/build/dev/erlang/app/ebin/app.beam"
268 )),
269 Classification::Irrelevant,
270 "own build outputs never dirty"
271 );
272 for template in [
273 "/proj/host/src/lib.rs",
274 "/proj/host/build.rs",
275 "/proj/host/Cargo.toml",
276 "/proj/frame.toml",
277 "/proj/page/main.ts",
278 ] {
279 assert_eq!(
280 set.classify(&event(
281 EventKind::Modify(ModifyKind::Data(DataChange::Content)),
282 template
283 )),
284 Classification::Irrelevant,
285 "{template} is a regeneration seam, not a watch event"
286 );
287 }
288 }
289
290 #[test]
294 fn root_removal_and_rename_surface_as_root_affected() {
295 let set = set();
296 for (kind, path) in [
297 (EventKind::Remove(RemoveKind::Folder), "/proj/component/src"),
298 (
299 EventKind::Modify(ModifyKind::Name(RenameMode::From)),
300 "/proj/component/src",
301 ),
302 (
303 EventKind::Remove(RemoveKind::File),
304 "/proj/component/gleam.toml",
305 ),
306 (EventKind::Remove(RemoveKind::Folder), "/proj/component"),
307 ] {
308 assert_eq!(
309 set.classify(&event(kind, path)),
310 Classification::RootAffected(Path::new(path).to_path_buf()),
311 "{kind:?} {path}"
312 );
313 }
314 }
315
316 #[test]
319 fn rescan_flag_classifies_desynchronized() {
320 let set = set();
321 let lossy = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
322 assert_eq!(set.classify(&lossy), Classification::Desynchronized);
323 }
324}