Skip to main content

frame_cli/
cli.rs

1//! Command-line grammar for the `frame` binary: five intention-verbs, the
2//! quiet `build` alias, and the ops-facing `host` subcommand.
3//!
4//! Every verb is named for what the user wants, never for internal
5//! machinery, and no verb grows flags that duplicate values `frame.toml`
6//! already states.
7
8use std::path::PathBuf;
9
10use clap::{Args, Parser, Subcommand};
11
12/// Parsed command line for the Frame operator tool.
13#[derive(Debug, Parser)]
14#[command(name = "frame", version, about = "Frame application tooling")]
15pub struct Cli {
16    /// Operation to perform.
17    #[command(subcommand)]
18    pub command: Command,
19}
20
21/// Supported Frame operations.
22#[derive(Debug, Subcommand)]
23pub enum Command {
24    /// Create a new Frame application (prerequisite tools are checked first).
25    New {
26        /// Application and Gleam project name.
27        name: String,
28    },
29    /// Build what changed, boot the application, and print its URL.
30    Run,
31    /// Prove the application works: component, host, and real-browser tests
32    /// as one verdict.
33    Test(TestScope),
34    /// Fast static verification: page types, component types, host types,
35    /// and frame.toml — no build artifacts.
36    Check,
37    /// Walk through every prerequisite tool, with install links for anything
38    /// missing.
39    Doctor,
40    /// Build the application without booting it.
41    Build,
42    /// Boot a frame host from an explicit config file (operations use; an
43    /// application directory wants `frame run` instead).
44    Host {
45        /// Path to the `frame.toml` describing the whole stack.
46        #[arg(long)]
47        config: PathBuf,
48    },
49}
50
51/// Scope narrowing for `frame test`. With no flag, every scope runs — the
52/// real-browser proof included; flags narrow to a subset, and naming
53/// several runs their union.
54#[derive(Args, Clone, Copy, Debug, Default)]
55pub struct TestScope {
56    /// Run the Gleam component tests.
57    #[arg(long)]
58    pub component: bool,
59    /// Run the host's Rust test suite (includes the full-stack boot proof).
60    #[arg(long)]
61    pub host: bool,
62    /// Run the real-browser proof against the served page.
63    #[arg(long)]
64    pub browser: bool,
65}
66
67impl TestScope {
68    /// True when no narrowing flag was given: everything runs.
69    #[must_use]
70    pub fn is_everything(self) -> bool {
71        !(self.component || self.host || self.browser)
72    }
73
74    /// True when the component scope should run.
75    #[must_use]
76    pub fn component_selected(self) -> bool {
77        self.component || self.is_everything()
78    }
79
80    /// True when the host scope should run.
81    #[must_use]
82    pub fn host_selected(self) -> bool {
83        self.host || self.is_everything()
84    }
85
86    /// True when the real-browser scope should run.
87    #[must_use]
88    pub fn browser_selected(self) -> bool {
89        self.browser || self.is_everything()
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::{Cli, Command, TestScope};
96    use clap::Parser;
97
98    #[test]
99    fn clap_takes_bare_name_and_rejects_removed_and_unknown_flags() {
100        assert!(Cli::try_parse_from(["frame", "new", "valid_app"]).is_ok());
101        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--frame-root", "."]).is_err());
102        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--force"]).is_err());
103    }
104
105    #[test]
106    fn test_scope_defaults_to_everything_and_flags_narrow_to_a_union() -> Result<(), String> {
107        let default = TestScope::default();
108        assert!(default.is_everything());
109        assert!(default.component_selected() && default.host_selected());
110        assert!(default.browser_selected());
111
112        let parsed = Cli::try_parse_from(["frame", "test", "--component", "--browser"])
113            .map_err(|error| error.to_string())?;
114        let Command::Test(scope) = parsed.command else {
115            return Err("frame test --component --browser parsed to the wrong verb".to_owned());
116        };
117        assert!(!scope.is_everything());
118        assert!(scope.component_selected());
119        assert!(scope.browser_selected());
120        assert!(!scope.host_selected());
121        Ok(())
122    }
123
124    #[test]
125    fn every_verb_parses_and_host_requires_its_config() {
126        for verb in ["run", "check", "doctor", "build"] {
127            assert!(Cli::try_parse_from(["frame", verb]).is_ok(), "{verb}");
128        }
129        assert!(Cli::try_parse_from(["frame", "host", "--config", "frame.toml"]).is_ok());
130        assert!(
131            Cli::try_parse_from(["frame", "host"]).is_err(),
132            "frame host without --config must be a parse error"
133        );
134        assert!(
135            Cli::try_parse_from(["frame", "e2e"]).is_err(),
136            "e2e is dead as a command name"
137        );
138    }
139}