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    /// Boot the application and stay attached: component edits rebuild
32    /// and hot-swap against the running node; failures are loud while the
33    /// node serves the last known-good code.
34    Dev {
35        /// The edit-quiet deadline in milliseconds: one resettable window
36        /// after an observed source event that defines an edit burst.
37        #[arg(long, default_value_t = 100)]
38        quiet_ms: u64,
39    },
40    /// Prove the application works: component, host, and real-browser tests
41    /// as one verdict.
42    Test(TestScope),
43    /// Fast static verification: page types, component types, host types,
44    /// and frame.toml — no build artifacts.
45    Check,
46    /// Walk through every prerequisite tool, with install links for anything
47    /// missing.
48    Doctor,
49    /// Build the application without booting it.
50    Build,
51    /// Boot a frame host from an explicit config file (operations use; an
52    /// application directory wants `frame run` instead).
53    Host {
54        /// Path to the `frame.toml` describing the whole stack.
55        #[arg(long)]
56        config: PathBuf,
57    },
58}
59
60/// Scope narrowing for `frame test`. With no flag, every scope runs — the
61/// real-browser proof included; flags narrow to a subset, and naming
62/// several runs their union.
63#[derive(Args, Clone, Copy, Debug, Default)]
64pub struct TestScope {
65    /// Run the Gleam component tests.
66    #[arg(long)]
67    pub component: bool,
68    /// Run the host's Rust test suite (includes the full-stack boot proof).
69    #[arg(long)]
70    pub host: bool,
71    /// Run the real-browser proof against the served page.
72    #[arg(long)]
73    pub browser: bool,
74}
75
76impl TestScope {
77    /// True when no narrowing flag was given: everything runs.
78    #[must_use]
79    pub fn is_everything(self) -> bool {
80        !(self.component || self.host || self.browser)
81    }
82
83    /// True when the component scope should run.
84    #[must_use]
85    pub fn component_selected(self) -> bool {
86        self.component || self.is_everything()
87    }
88
89    /// True when the host scope should run.
90    #[must_use]
91    pub fn host_selected(self) -> bool {
92        self.host || self.is_everything()
93    }
94
95    /// True when the real-browser scope should run.
96    #[must_use]
97    pub fn browser_selected(self) -> bool {
98        self.browser || self.is_everything()
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::{Cli, Command, TestScope};
105    use clap::Parser;
106
107    #[test]
108    fn clap_takes_bare_name_and_rejects_removed_and_unknown_flags() {
109        assert!(Cli::try_parse_from(["frame", "new", "valid_app"]).is_ok());
110        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--frame-root", "."]).is_err());
111        assert!(Cli::try_parse_from(["frame", "new", "valid_app", "--force"]).is_err());
112    }
113
114    #[test]
115    fn test_scope_defaults_to_everything_and_flags_narrow_to_a_union() -> Result<(), String> {
116        let default = TestScope::default();
117        assert!(default.is_everything());
118        assert!(default.component_selected() && default.host_selected());
119        assert!(default.browser_selected());
120
121        let parsed = Cli::try_parse_from(["frame", "test", "--component", "--browser"])
122            .map_err(|error| error.to_string())?;
123        let Command::Test(scope) = parsed.command else {
124            return Err("frame test --component --browser parsed to the wrong verb".to_owned());
125        };
126        assert!(!scope.is_everything());
127        assert!(scope.component_selected());
128        assert!(scope.browser_selected());
129        assert!(!scope.host_selected());
130        Ok(())
131    }
132
133    #[test]
134    fn every_verb_parses_and_host_requires_its_config() {
135        for verb in ["run", "check", "doctor", "build"] {
136            assert!(Cli::try_parse_from(["frame", verb]).is_ok(), "{verb}");
137        }
138        assert!(Cli::try_parse_from(["frame", "host", "--config", "frame.toml"]).is_ok());
139        assert!(
140            Cli::try_parse_from(["frame", "host"]).is_err(),
141            "frame host without --config must be a parse error"
142        );
143        assert!(
144            Cli::try_parse_from(["frame", "e2e"]).is_err(),
145            "e2e is dead as a command name"
146        );
147    }
148}