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
//! Command-line grammar for the `frame` binary: five intention-verbs, the
//! quiet `build` alias, and the ops-facing `host` subcommand.
//!
//! Every verb is named for what the user wants, never for internal
//! machinery, and no verb grows flags that duplicate values `frame.toml`
//! already states.

use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};

/// Parsed command line for the Frame operator tool.
#[derive(Debug, Parser)]
#[command(name = "frame", version, about = "Frame application tooling")]
pub struct Cli {
    /// Operation to perform.
    #[command(subcommand)]
    pub command: Command,
}

/// Supported Frame operations.
#[derive(Debug, Subcommand)]
pub enum Command {
    /// Create a new Frame application (prerequisite tools are checked first).
    New {
        /// Application and Gleam project name.
        name: String,
    },
    /// Build what changed, boot the application, and print its URL.
    Run,
    /// Boot the application and stay attached: component edits rebuild
    /// and hot-swap against the running node; failures are loud while the
    /// node serves the last known-good code.
    Dev {
        /// The edit-quiet deadline in milliseconds: one resettable window
        /// after an observed source event that defines an edit burst.
        #[arg(long, default_value_t = 100)]
        quiet_ms: u64,
    },
    /// Prove the application works: component, host, and real-browser tests
    /// as one verdict.
    Test(TestScope),
    /// Fast static verification: page types, component types, host types,
    /// and frame.toml — no build artifacts.
    Check,
    /// Walk through every prerequisite tool, with install links for anything
    /// missing.
    Doctor,
    /// Build the application without booting it.
    Build,
    /// Boot a frame host from an explicit config file (operations use; an
    /// application directory wants `frame run` instead).
    Host {
        /// Path to the `frame.toml` describing the whole stack.
        #[arg(long)]
        config: PathBuf,
    },
}

/// Scope narrowing for `frame test`. With no flag, every scope runs — the
/// real-browser proof included; flags narrow to a subset, and naming
/// several runs their union.
#[derive(Args, Clone, Copy, Debug, Default)]
pub struct TestScope {
    /// Run the Gleam component tests.
    #[arg(long)]
    pub component: bool,
    /// Run the host's Rust test suite (includes the full-stack boot proof).
    #[arg(long)]
    pub host: bool,
    /// Run the real-browser proof against the served page.
    #[arg(long)]
    pub browser: bool,
}

impl TestScope {
    /// True when no narrowing flag was given: everything runs.
    #[must_use]
    pub fn is_everything(self) -> bool {
        !(self.component || self.host || self.browser)
    }

    /// True when the component scope should run.
    #[must_use]
    pub fn component_selected(self) -> bool {
        self.component || self.is_everything()
    }

    /// True when the host scope should run.
    #[must_use]
    pub fn host_selected(self) -> bool {
        self.host || self.is_everything()
    }

    /// True when the real-browser scope should run.
    #[must_use]
    pub fn browser_selected(self) -> bool {
        self.browser || self.is_everything()
    }
}

#[cfg(test)]
mod tests {
    use super::{Cli, Command, TestScope};
    use clap::Parser;

    #[test]
    fn clap_takes_bare_name_and_rejects_removed_and_unknown_flags() {
        assert!(Cli::try_parse_from(["frame", "new", "valid_app"]).is_ok());
        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--frame-root", "."]).is_err());
        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--force"]).is_err());
    }

    #[test]
    fn test_scope_defaults_to_everything_and_flags_narrow_to_a_union() -> Result<(), String> {
        let default = TestScope::default();
        assert!(default.is_everything());
        assert!(default.component_selected() && default.host_selected());
        assert!(default.browser_selected());

        let parsed = Cli::try_parse_from(["frame", "test", "--component", "--browser"])
            .map_err(|error| error.to_string())?;
        let Command::Test(scope) = parsed.command else {
            return Err("frame test --component --browser parsed to the wrong verb".to_owned());
        };
        assert!(!scope.is_everything());
        assert!(scope.component_selected());
        assert!(scope.browser_selected());
        assert!(!scope.host_selected());
        Ok(())
    }

    #[test]
    fn every_verb_parses_and_host_requires_its_config() {
        for verb in ["run", "check", "doctor", "build"] {
            assert!(Cli::try_parse_from(["frame", verb]).is_ok(), "{verb}");
        }
        assert!(Cli::try_parse_from(["frame", "host", "--config", "frame.toml"]).is_ok());
        assert!(
            Cli::try_parse_from(["frame", "host"]).is_err(),
            "frame host without --config must be a parse error"
        );
        assert!(
            Cli::try_parse_from(["frame", "e2e"]).is_err(),
            "e2e is dead as a command name"
        );
    }
}