Skip to main content

whisker_cli/
lib.rs

1//! Whisker CLI implementation.
2//!
3//! ## Subcommands
4//!
5//! - `doctor` — environment / toolchain health check (Rust targets,
6//!   Android NDK/SDK/JDK, Xcode).
7//! - `run` — `whisker run`: build → install → launch → file-watch +
8//!   hot-patch loop. Thin wrapper around
9//!   [`whisker_dev_server::DevServer`]; the cli's job is to resolve
10//!   the user crate's `whisker.rs` (via [`manifest`] + [`probe`])
11//!   and project the resulting `Config` into the dev-server's
12//!   flat [`whisker_dev_server::Config`].
13//! - `new` / `new-module` — scaffolding.
14//!
15//! No `build` subcommand: production builds happen through the same
16//! `xcodebuild` / `gradle assembleRelease` invocations CI uses. Past
17//! revisions shipped a `whisker build` convenience wrapper, but it
18//! existed mostly to manage the `~/.cache/whisker/lynx/` user cache,
19//! which is itself gone now (iOS uses SPM remote binary targets,
20//! Android pulls aars from Maven).
21//!
22//! ## Internal binaries
23//!
24//! In addition to the user-facing `whisker` binary, the package also
25//! produces two shim binaries used during the initial fat build to
26//! capture the rustc + linker invocations that Tier 1 hot-patch will
27//! replay later:
28//!
29//! - `whisker-rustc-shim` (`-Cstrip=…` / `-Csave-temps=y` style
30//!   wrapper around rustc) — captures argv to
31//!   `$WHISKER_RUSTC_CACHE_DIR/<crate>-<timestamp>.json`.
32//! - `whisker-linker-shim` (forwarded by rustc's `-C linker=…`) —
33//!   captures argv to `$WHISKER_LINKER_CACHE_DIR/<output>-…json`.
34
35use anyhow::Result;
36use clap::{Parser, Subcommand};
37
38pub mod build_dispatch;
39pub mod doctor;
40pub mod fmt;
41pub mod linker_shim;
42pub mod manifest;
43pub mod new_app;
44pub mod new_module;
45pub mod platforms;
46pub mod probe;
47pub mod run;
48pub mod rustc_shim;
49pub mod tui;
50
51#[derive(Parser, Debug)]
52#[command(
53    name = "whisker",
54    about = "Whisker — cross-platform mobile UI framework",
55    version
56)]
57struct Cli {
58    /// Show every step's full underlying output (raw cargo /
59    /// xcodebuild / simctl streams + the internal debug logs the
60    /// curated UI hides by default). Plumbed into `whisker-build::ui`
61    /// via the `WHISKER_VERBOSE` env var so subprocesses
62    /// (`whisker-dev-server`, the shim binaries, etc.) inherit it.
63    #[arg(long, short = 'v', global = true)]
64    verbose: bool,
65
66    #[command(subcommand)]
67    command: Command,
68}
69
70#[derive(Subcommand, Debug)]
71enum Command {
72    /// Inspect the local toolchain — Rust targets, Android NDK/SDK/JDK,
73    /// Xcode.
74    Doctor(doctor::Args),
75    /// Build, install, and dev-loop a Whisker app — file watch + rebuild
76    /// + subsecond hot patches over WebSocket.
77    Run(run::Args),
78    /// Scaffold a new Whisker module crate — Cargo.toml (with the
79    /// `[package.metadata.whisker]` marker), Package.swift,
80    /// build.gradle.kts, and skeleton Rust / Swift / Kotlin sources.
81    /// See `docs/module-author-guide.md`.
82    NewModule(new_module::NewModuleArgs),
83    /// Scaffold a new Whisker app — single-crate workspace with
84    /// `Cargo.toml`, a `#[whisker::main]` `src/lib.rs`, the
85    /// `whisker.rs` `Config` probe, `.gitignore`, and `README.md`.
86    /// The result compiles standalone; run `whisker run android` or
87    /// `whisker run ios` from inside the new directory.
88    New(new_app::NewAppArgs),
89
90    /// Format Rust source — a rustfmt drop-in that ALSO formats
91    /// Whisker's `render!` / `css!` macro bodies (which rustfmt leaves
92    /// untouched). Respects `rustfmt.toml` only; no whisker-specific
93    /// config. Use `--stdin` for the rust-analyzer integration
94    /// (`rustfmt.overrideCommand = ["whisker", "fmt", "--stdin"]`).
95    Fmt(fmt::FmtArgs),
96
97    /// (internal) Cross-compile the user crate into
98    /// `WhiskerDriver.framework`. Invoked by the generated Xcode
99    /// project's Run Script Phase, not by users.
100    #[command(name = "build-ios", hide = true)]
101    BuildIos(build_dispatch::IosArgs),
102
103    /// (internal) Cross-compile the user crate into `lib*.so`. Invoked
104    /// by the Whisker Gradle plugin's `cargoBuild*` task, not by users.
105    #[command(name = "build-android", hide = true)]
106    BuildAndroid(build_dispatch::AndroidArgs),
107
108    /// (internal) Emit a JSON manifest of the app's Whisker modules.
109    /// Invoked by the Gradle Settings plugin at init, not by users.
110    #[command(name = "modules", hide = true)]
111    Modules(build_dispatch::ModulesArgs),
112}
113
114pub fn run(args: impl IntoIterator<Item = String>) -> Result<()> {
115    // Use clap's own exit path so `--help` / `--version` print to stdout
116    // with exit code 0; bubbling the result through anyhow would prefix
117    // it with "Error: " and exit non-zero.
118    let cli = match Cli::try_parse_from(args) {
119        Ok(c) => c,
120        Err(e) => e.exit(),
121    };
122    // `--verbose` and `WHISKER_VERBOSE=1` are the same switch. Setting
123    // the env var means any subprocess we spawn (dev-server, shim
124    // binaries) sees the same mode without further plumbing.
125    if cli.verbose {
126        std::env::set_var("WHISKER_VERBOSE", "1");
127    }
128    match cli.command {
129        Command::Doctor(a) => doctor::run(a),
130        Command::Run(a) => run::run(a),
131        Command::NewModule(a) => new_module::run(a),
132        Command::New(a) => new_app::run(a),
133        Command::Fmt(a) => fmt::run(a),
134        Command::BuildIos(a) => build_dispatch::run_ios(a),
135        Command::BuildAndroid(a) => build_dispatch::run_android(a),
136        Command::Modules(a) => build_dispatch::run_modules(a),
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn parse<I, S>(args: I) -> std::result::Result<Cli, clap::Error>
145    where
146        I: IntoIterator<Item = S>,
147        S: Into<std::ffi::OsString> + Clone,
148    {
149        Cli::try_parse_from(args)
150    }
151
152    #[test]
153    fn parses_doctor_with_no_flags() {
154        let cli = parse(["whisker", "doctor"]).unwrap();
155        match cli.command {
156            Command::Doctor(a) => {
157                assert!(!a.no_ios);
158                assert!(!a.no_android);
159            }
160            other => panic!("expected Doctor, got {other:?}"),
161        }
162    }
163
164    #[test]
165    fn parses_run_with_only_target() {
166        // `target` is now required (no default), so the bare
167        // `whisker run` form is gone — supply a positional target
168        // and assert the rest of the args adopt their defaults.
169        let cli = parse(["whisker", "run", "android"]).unwrap();
170        match cli.command {
171            Command::Run(a) => {
172                assert!(a.manifest_path.is_none());
173                assert_eq!(a.target, run::CliTarget::Android);
174                assert_eq!(a.bind.port(), 9876);
175                // Hot-patch is the dev default — opt out with --no-hot-patch.
176                assert!(!a.no_hot_patch);
177                assert!(a.workspace_root.is_none());
178            }
179            other => panic!("expected Run, got {other:?}"),
180        }
181    }
182
183    #[test]
184    fn parses_run_without_target_fails() {
185        // `whisker run` with no positional target is now an error
186        // (Host was the previous default and has been removed).
187        let res = parse(["whisker", "run"]);
188        assert!(res.is_err(), "expected clap error, got {res:?}");
189    }
190
191    #[test]
192    fn parses_run_with_explicit_target_and_flags() {
193        // `target` moved from `--target <value>` to a positional
194        // argument (`whisker run android`) — clap accepts it in any
195        // position relative to the named flags, so the test mixes
196        // them deliberately.
197        let cli = parse([
198            "whisker",
199            "run",
200            "--manifest-path",
201            "/tmp/my-app/Cargo.toml",
202            "android",
203            "--bind",
204            "0.0.0.0:1234",
205            "--no-hot-patch",
206        ])
207        .unwrap();
208        match cli.command {
209            Command::Run(a) => {
210                assert_eq!(
211                    a.manifest_path.as_deref(),
212                    Some(std::path::Path::new("/tmp/my-app/Cargo.toml")),
213                );
214                assert_eq!(a.target, run::CliTarget::Android);
215                assert_eq!(a.bind.to_string(), "0.0.0.0:1234");
216                assert!(a.no_hot_patch);
217            }
218            other => panic!("expected Run, got {other:?}"),
219        }
220    }
221
222    #[test]
223    fn parses_fmt_stdin() {
224        let cli = parse(["whisker", "fmt", "--stdin"]).unwrap();
225        match cli.command {
226            Command::Fmt(a) => {
227                assert!(a.stdin);
228                assert!(!a.check);
229                assert!(a.files.is_empty());
230            }
231            other => panic!("expected Fmt, got {other:?}"),
232        }
233    }
234
235    #[test]
236    fn parses_fmt_files_and_check() {
237        let cli = parse(["whisker", "fmt", "--check", "a.rs", "b.rs"]).unwrap();
238        match cli.command {
239            Command::Fmt(a) => {
240                assert!(a.check);
241                assert!(!a.stdin);
242                assert_eq!(a.files.len(), 2);
243            }
244            other => panic!("expected Fmt, got {other:?}"),
245        }
246    }
247
248    #[test]
249    fn parses_doctor_skip_flags() {
250        let cli = parse(["whisker", "doctor", "--no-ios", "--no-android"]).unwrap();
251        match cli.command {
252            Command::Doctor(a) => {
253                assert!(a.no_ios);
254                assert!(a.no_android);
255            }
256            other => panic!("expected Doctor, got {other:?}"),
257        }
258    }
259
260    #[test]
261    fn missing_subcommand_is_an_error() {
262        // Clap renders help when no subcommand is given (we haven't
263        // marked any as default), so the error kind here is the
264        // help-on-missing-arg variant rather than `MissingSubcommand`.
265        let e = parse(["whisker"]).unwrap_err();
266        assert_eq!(
267            e.kind(),
268            clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
269        );
270    }
271
272    #[test]
273    fn unknown_subcommand_is_an_error() {
274        let e = parse(["whisker", "frobnicate"]).unwrap_err();
275        assert_eq!(e.kind(), clap::error::ErrorKind::InvalidSubcommand);
276    }
277
278    #[test]
279    fn help_flag_short_circuits_to_displayhelp() {
280        let e = parse(["whisker", "--help"]).unwrap_err();
281        assert_eq!(e.kind(), clap::error::ErrorKind::DisplayHelp);
282    }
283
284    // The generated native projects (gen/ios pbxproj Run Script, the
285    // Gradle plugin) call these hidden subcommands by exact name +
286    // flags. If a rename/flag-change slips through, the templates break
287    // silently at build time — so pin the CLI contract here.
288    #[test]
289    fn internal_build_subcommands_parse() {
290        match parse([
291            "whisker",
292            "build-ios",
293            "--workspace=/ws",
294            "--package=app",
295            "--configuration=Debug",
296            "--platform=iphonesimulator",
297            "--archs=arm64",
298            "--built-products-dir=/out",
299        ])
300        .unwrap()
301        .command
302        {
303            Command::BuildIos(_) => {}
304            other => panic!("expected BuildIos, got {other:?}"),
305        }
306
307        match parse([
308            "whisker",
309            "build-android",
310            "--workspace=/ws",
311            "--package=app",
312            "--profile=debug",
313            "--abi=arm64-v8a",
314            "--jni-libs-dir=/jni",
315        ])
316        .unwrap()
317        .command
318        {
319            Command::BuildAndroid(_) => {}
320            other => panic!("expected BuildAndroid, got {other:?}"),
321        }
322
323        match parse(["whisker", "modules", "--workspace=/ws", "--package=app"])
324            .unwrap()
325            .command
326        {
327            Command::Modules(_) => {}
328            other => panic!("expected Modules, got {other:?}"),
329        }
330    }
331
332    #[test]
333    fn version_flag_short_circuits_to_displayversion() {
334        let e = parse(["whisker", "--version"]).unwrap_err();
335        assert_eq!(e.kind(), clap::error::ErrorKind::DisplayVersion);
336    }
337}