frame-cli 0.4.0

CLI for Frame — six intention-verbs over one application: frame new scaffolds it, frame run serves it, frame dev hot-reloads it against the running node, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! The tear-sanctioned watcher backend, with the polling-backend exclusion
//! asserted at startup (F-7b constraint 4, condition 3).
//!
//! notify ships a `PollWatcher` fallback, and silently resolving to it on
//! any platform would smuggle the banned shape through a sanctioned door.
//! `RecommendedWatcher` resolves at compile time; this module asserts the
//! resolved backend is a NATIVE event one (`FSEvents` on macOS, inotify on
//! Linux) and refuses typed before any watch is established otherwise.
//! Kqueue is refused too: the ruling names `FSEvents`/inotify, and widening
//! it is a tear question, not a worker choice.

use notify::{RecommendedWatcher, Watcher, WatcherKind};

/// Typed startup refusal for a non-native watch backend.
#[derive(Debug, thiserror::Error)]
#[error(
    "the platform resolved watch backend {resolved:?}, which is not one of \
     the sanctioned native event backends (FSEvents, inotify); frame dev \
     refuses to start rather than fall back to a polling watcher"
)]
pub struct NonNativeBackend {
    /// The backend `notify` resolved for this platform.
    pub resolved: WatcherKind,
}

/// Asserts the compile-time-resolved watcher backend is a native event
/// backend, returning it for the startup report. Called before any watch
/// is established; the typed refusal aborts `frame dev` loudly.
///
/// # Errors
///
/// [`NonNativeBackend`] when the platform resolved anything outside the
/// sanctioned `FSEvents`/inotify set — `PollWatcher` above all.
pub fn assert_native_backend() -> Result<WatcherKind, NonNativeBackend> {
    classify(<RecommendedWatcher as Watcher>::kind())
}

fn classify(resolved: WatcherKind) -> Result<WatcherKind, NonNativeBackend> {
    match resolved {
        WatcherKind::Fsevent | WatcherKind::Inotify => Ok(resolved),
        other => Err(NonNativeBackend { resolved: other }),
    }
}

/// The watched set of one generated project (F-7b R2): the component
/// manifest and the component sources, nothing else.
///
/// The watcher registers on the `component/` directory recursively — a
/// single-file watch on `gleam.toml` would break under editors' atomic
/// rename-replace saves — and THIS classification is what keeps the set
/// honest: `component/build/**` (where `gleam build` writes) and every
/// other non-input path classify [`Classification::Irrelevant`], which is
/// also what prevents the dev loop from rebuilding on its own build
/// outputs. The generated template files (host code, `build.rs`,
/// manifests) are structurally outside `component/` and therefore outside
/// the watch — the tear's regeneration-seam exclusion.
#[derive(Debug, Clone)]
pub struct WatchedSet {
    /// `<root>/component` — what the watcher registers, recursively.
    pub watch_root: std::path::PathBuf,
    /// `<root>/component/gleam.toml`.
    manifest: std::path::PathBuf,
    /// `<root>/component/src`.
    sources: std::path::PathBuf,
}

/// What one watcher event means to the dev loop.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Classification {
    /// Outside the build inputs, or a non-mutating / metadata-only event.
    Irrelevant,
    /// A build input changed: opens or extends the edit burst.
    Dirtying,
    /// The event touched a watched root itself (the source tree or the
    /// manifest) with a remove/rename. The loop answers with ONE
    /// event-driven existence check: still present means an atomic
    /// editor replace (dirtying); gone means the typed halted state that
    /// names the path and the recovery.
    RootAffected(std::path::PathBuf),
    /// The backend reported loss (rescan flag): the loop takes one full
    /// content snapshot, rebuilds it, and re-establishes the
    /// subscription — never a periodic rescan.
    Desynchronized,
}

impl WatchedSet {
    /// The watched set for the generated project at `root`.
    #[must_use]
    pub fn of_project(root: &std::path::Path) -> Self {
        let component = root.join("component");
        Self {
            manifest: component.join("gleam.toml"),
            sources: component.join("src"),
            watch_root: component,
        }
    }

    /// Classifies one backend event. Pure: the single filesystem question
    /// a [`Classification::RootAffected`] answer requires is asked by the
    /// loop, in response to the event, exactly once.
    #[must_use]
    pub fn classify(&self, event: &notify::Event) -> Classification {
        use notify::EventKind;
        use notify::event::ModifyKind;

        if event.need_rescan() {
            return Classification::Desynchronized;
        }
        match event.kind {
            // Non-mutating access and metadata-only churn never dirty.
            EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_)) => {
                return Classification::Irrelevant;
            }
            // Create, content modify, remove, rename — and the imprecise
            // catch-alls, conservatively: losing a real edit is worse
            // than one spurious rebuild of identical content.
            EventKind::Any
            | EventKind::Other
            | EventKind::Create(_)
            | EventKind::Modify(_)
            | EventKind::Remove(_) => {}
        }
        if matches!(
            event.kind,
            EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(_))
        ) && let Some(root) = event.paths.iter().find(|path| self.is_watched_root(path))
        {
            return Classification::RootAffected(root.clone());
        }
        if event.paths.iter().any(|path| self.is_build_input(path)) {
            Classification::Dirtying
        } else {
            Classification::Irrelevant
        }
    }

    /// The roots whose disappearance is the typed halted state: the
    /// source tree, the manifest, and the watch root itself.
    fn is_watched_root(&self, path: &std::path::Path) -> bool {
        path == self.sources || path == self.manifest || path == self.watch_root
    }

    fn is_build_input(&self, path: &std::path::Path) -> bool {
        path == self.manifest || path.starts_with(&self.sources)
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used)]

    use super::{Classification, NonNativeBackend, WatchedSet, assert_native_backend, classify};
    use notify::WatcherKind;

    /// The sanctioned platforms resolve a native backend — the exclusion
    /// holds where frame dev actually runs.
    #[test]
    fn this_platform_resolves_a_native_event_backend() {
        let resolved = assert_native_backend().expect("native backend");
        assert!(
            matches!(resolved, WatcherKind::Fsevent | WatcherKind::Inotify),
            "resolved {resolved:?}"
        );
    }

    /// `PollWatcher` is the named banned shape: the refusal is typed and
    /// its message names both the resolved backend and the sanctioned set.
    #[test]
    fn poll_watcher_is_refused_by_name() {
        let refusal = classify(WatcherKind::PollWatcher).expect_err("refuses");
        let NonNativeBackend { resolved } = &refusal;
        assert_eq!(*resolved, WatcherKind::PollWatcher);
        let message = refusal.to_string();
        assert!(message.contains("PollWatcher"), "{message}");
        assert!(message.contains("polling watcher"), "{message}");
    }

    /// Kqueue delivers events but is OUTSIDE the ruled set; widening the
    /// sanction is a tear question, so the exclusion refuses it too.
    #[test]
    fn kqueue_is_outside_the_ruled_set() {
        classify(WatcherKind::Kqueue).expect_err("refuses kqueue");
        classify(WatcherKind::NullWatcher).expect_err("refuses the test fake");
    }

    use notify::EventKind;
    use notify::event::{
        AccessKind, CreateKind, DataChange, Event, MetadataKind, ModifyKind, RemoveKind, RenameMode,
    };
    use std::path::Path;

    fn set() -> WatchedSet {
        WatchedSet::of_project(Path::new("/proj"))
    }

    fn event(kind: EventKind, path: &str) -> Event {
        Event::new(kind).add_path(path.into())
    }

    /// Create, content modify, remove, and rename under the watched set
    /// all dirty the build (R2's enumerated dirtying set).
    #[test]
    fn build_inputs_dirty_on_every_mutating_kind() {
        let set = set();
        for kind in [
            EventKind::Create(CreateKind::File),
            EventKind::Modify(ModifyKind::Data(DataChange::Content)),
            EventKind::Modify(ModifyKind::Any),
            EventKind::Remove(RemoveKind::File),
            EventKind::Any,
        ] {
            assert_eq!(
                set.classify(&event(kind, "/proj/component/src/app.gleam")),
                Classification::Dirtying,
                "{kind:?}"
            );
        }
        assert_eq!(
            set.classify(&event(
                EventKind::Modify(ModifyKind::Data(DataChange::Content)),
                "/proj/component/gleam.toml"
            )),
            Classification::Dirtying,
            "the manifest is a build input"
        );
        assert_eq!(
            set.classify(&event(
                EventKind::Create(CreateKind::Folder),
                "/proj/component/src/nested/dir"
            )),
            Classification::Dirtying,
            "directories under src dirty too"
        );
    }

    /// Metadata-only noise and non-mutating access never dirty, even on
    /// build inputs.
    #[test]
    fn metadata_and_access_noise_is_irrelevant() {
        let set = set();
        assert_eq!(
            set.classify(&event(
                EventKind::Modify(ModifyKind::Metadata(MetadataKind::Permissions)),
                "/proj/component/src/app.gleam"
            )),
            Classification::Irrelevant
        );
        assert_eq!(
            set.classify(&event(
                EventKind::Access(AccessKind::Any),
                "/proj/component/src/app.gleam"
            )),
            Classification::Irrelevant
        );
    }

    /// `gleam build` writes under `component/build/**` — INSIDE the
    /// registered watch — and must classify irrelevant, or the loop would
    /// rebuild on its own outputs forever. Template files live outside
    /// `component/` entirely (the regeneration seam).
    #[test]
    fn build_outputs_and_template_files_are_outside_the_set() {
        let set = set();
        assert_eq!(
            set.classify(&event(
                EventKind::Create(CreateKind::File),
                "/proj/component/build/dev/erlang/app/ebin/app.beam"
            )),
            Classification::Irrelevant,
            "own build outputs never dirty"
        );
        for template in [
            "/proj/host/src/lib.rs",
            "/proj/host/build.rs",
            "/proj/host/Cargo.toml",
            "/proj/frame.toml",
            "/proj/page/main.ts",
        ] {
            assert_eq!(
                set.classify(&event(
                    EventKind::Modify(ModifyKind::Data(DataChange::Content)),
                    template
                )),
                Classification::Irrelevant,
                "{template} is a regeneration seam, not a watch event"
            );
        }
    }

    /// Remove/rename touching a watched root itself is the loop's cue for
    /// its one event-driven existence check (atomic editor replace vs the
    /// typed halted state).
    #[test]
    fn root_removal_and_rename_surface_as_root_affected() {
        let set = set();
        for (kind, path) in [
            (EventKind::Remove(RemoveKind::Folder), "/proj/component/src"),
            (
                EventKind::Modify(ModifyKind::Name(RenameMode::From)),
                "/proj/component/src",
            ),
            (
                EventKind::Remove(RemoveKind::File),
                "/proj/component/gleam.toml",
            ),
            (EventKind::Remove(RemoveKind::Folder), "/proj/component"),
        ] {
            assert_eq!(
                set.classify(&event(kind, path)),
                Classification::RootAffected(Path::new(path).to_path_buf()),
                "{kind:?} {path}"
            );
        }
    }

    /// The backend's rescan flag is the loss signal: classification says
    /// desynchronized regardless of kind or path.
    #[test]
    fn rescan_flag_classifies_desynchronized() {
        let set = set();
        let lossy = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
        assert_eq!(set.classify(&lossy), Classification::Desynchronized);
    }
}