Skip to main content

frame_cli/dev/
watch.rs

1//! The tear-sanctioned watcher backend, with the polling-backend exclusion
2//! asserted at startup (F-7b constraint 4, condition 3).
3//!
4//! notify ships a `PollWatcher` fallback, and silently resolving to it on
5//! any platform would smuggle the banned shape through a sanctioned door.
6//! `RecommendedWatcher` resolves at compile time; this module asserts the
7//! resolved backend is a NATIVE event one (`FSEvents` on macOS, inotify on
8//! Linux) and refuses typed before any watch is established otherwise.
9//! Kqueue is refused too: the ruling names `FSEvents`/inotify, and widening
10//! it is a tear question, not a worker choice.
11
12use notify::{RecommendedWatcher, Watcher, WatcherKind};
13
14/// Typed startup refusal for a non-native watch backend.
15#[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    /// The backend `notify` resolved for this platform.
23    pub resolved: WatcherKind,
24}
25
26/// Asserts the compile-time-resolved watcher backend is a native event
27/// backend, returning it for the startup report. Called before any watch
28/// is established; the typed refusal aborts `frame dev` loudly.
29///
30/// # Errors
31///
32/// [`NonNativeBackend`] when the platform resolved anything outside the
33/// sanctioned `FSEvents`/inotify set — `PollWatcher` above all.
34pub 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/// The watched set of one generated project (F-7b R2): the component
46/// manifest and the component sources, nothing else.
47///
48/// The watcher registers on the `component/` directory recursively — a
49/// single-file watch on `gleam.toml` would break under editors' atomic
50/// rename-replace saves — and THIS classification is what keeps the set
51/// honest: `component/build/**` (where `gleam build` writes) and every
52/// other non-input path classify [`Classification::Irrelevant`], which is
53/// also what prevents the dev loop from rebuilding on its own build
54/// outputs. The generated template files (host code, `build.rs`,
55/// manifests) are structurally outside `component/` and therefore outside
56/// the watch — the tear's regeneration-seam exclusion.
57#[derive(Debug, Clone)]
58pub struct WatchedSet {
59    /// `<root>/component` — what the watcher registers, recursively.
60    pub watch_root: std::path::PathBuf,
61    /// `<root>/component/gleam.toml`.
62    manifest: std::path::PathBuf,
63    /// `<root>/component/src`.
64    sources: std::path::PathBuf,
65}
66
67/// What one watcher event means to the dev loop.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Classification {
70    /// Outside the build inputs, or a non-mutating / metadata-only event.
71    Irrelevant,
72    /// A build input changed: opens or extends the edit burst.
73    Dirtying,
74    /// The event touched a watched root itself (the source tree or the
75    /// manifest) with a remove/rename. The loop answers with ONE
76    /// event-driven existence check: still present means an atomic
77    /// editor replace (dirtying); gone means the typed halted state that
78    /// names the path and the recovery.
79    RootAffected(std::path::PathBuf),
80    /// The backend reported loss (rescan flag): the loop takes one full
81    /// content snapshot, rebuilds it, and re-establishes the
82    /// subscription — never a periodic rescan.
83    Desynchronized,
84}
85
86impl WatchedSet {
87    /// The watched set for the generated project at `root`.
88    #[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    /// Classifies one backend event. Pure: the single filesystem question
99    /// a [`Classification::RootAffected`] answer requires is asked by the
100    /// loop, in response to the event, exactly once.
101    #[must_use]
102    pub fn classify(&self, event: &notify::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            // Non-mutating access and metadata-only churn never dirty.
111            EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_)) => {
112                return Classification::Irrelevant;
113            }
114            // Create, content modify, remove, rename — and the imprecise
115            // catch-alls, conservatively: losing a real edit is worse
116            // than one spurious rebuild of identical content.
117            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    /// The roots whose disappearance is the typed halted state: the
138    /// source tree, the manifest, and the watch root itself.
139    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    /// The sanctioned platforms resolve a native backend — the exclusion
156    /// holds where frame dev actually runs.
157    #[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    /// `PollWatcher` is the named banned shape: the refusal is typed and
167    /// its message names both the resolved backend and the sanctioned set.
168    #[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    /// Kqueue delivers events but is OUTSIDE the ruled set; widening the
179    /// sanction is a tear question, so the exclusion refuses it too.
180    #[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    /// Create, content modify, remove, and rename under the watched set
201    /// all dirty the build (R2's enumerated dirtying set).
202    #[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    /// Metadata-only noise and non-mutating access never dirty, even on
237    /// build inputs.
238    #[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    /// `gleam build` writes under `component/build/**` — INSIDE the
258    /// registered watch — and must classify irrelevant, or the loop would
259    /// rebuild on its own outputs forever. Template files live outside
260    /// `component/` entirely (the regeneration seam).
261    #[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    /// Remove/rename touching a watched root itself is the loop's cue for
291    /// its one event-driven existence check (atomic editor replace vs the
292    /// typed halted state).
293    #[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    /// The backend's rescan flag is the loss signal: classification says
317    /// desynchronized regardless of kind or path.
318    #[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}