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 doctor;
39pub mod linker_shim;
40pub mod manifest;
41pub mod new_app;
42pub mod new_module;
43pub mod platforms;
44pub mod probe;
45pub mod run;
46pub mod rustc_shim;
47pub mod tui;
48
49#[derive(Parser, Debug)]
50#[command(
51    name = "whisker",
52    about = "Whisker — cross-platform mobile UI framework",
53    version
54)]
55struct Cli {
56    /// Show every step's full underlying output (raw cargo /
57    /// xcodebuild / simctl streams + the internal debug logs the
58    /// curated UI hides by default). Plumbed into `whisker-build::ui`
59    /// via the `WHISKER_VERBOSE` env var so subprocesses
60    /// (`whisker-dev-server`, the shim binaries, etc.) inherit it.
61    #[arg(long, short = 'v', global = true)]
62    verbose: bool,
63
64    #[command(subcommand)]
65    command: Command,
66}
67
68#[derive(Subcommand, Debug)]
69enum Command {
70    /// Inspect the local toolchain — Rust targets, Android NDK/SDK/JDK,
71    /// Xcode.
72    Doctor(doctor::Args),
73    /// Build, install, and dev-loop a Whisker app — file watch + rebuild
74    /// + subsecond hot patches over WebSocket.
75    Run(run::Args),
76    /// Scaffold a new Whisker module crate — Cargo.toml (with the
77    /// `[package.metadata.whisker]` marker), Package.swift,
78    /// build.gradle.kts, and skeleton Rust / Swift / Kotlin sources.
79    /// See `docs/module-author-guide.md`.
80    NewModule(new_module::NewModuleArgs),
81    /// Scaffold a new Whisker app — single-crate workspace with
82    /// `Cargo.toml`, a `#[whisker::main]` `src/lib.rs`, the
83    /// `whisker.rs` `Config` probe, `.gitignore`, and `README.md`.
84    /// The result compiles standalone; run `whisker run android` or
85    /// `whisker run ios` from inside the new directory.
86    New(new_app::NewAppArgs),
87}
88
89pub fn run(args: impl IntoIterator<Item = String>) -> Result<()> {
90    // Use clap's own exit path so `--help` / `--version` print to stdout
91    // with exit code 0; bubbling the result through anyhow would prefix
92    // it with "Error: " and exit non-zero.
93    let cli = match Cli::try_parse_from(args) {
94        Ok(c) => c,
95        Err(e) => e.exit(),
96    };
97    // `--verbose` and `WHISKER_VERBOSE=1` are the same switch. Setting
98    // the env var means any subprocess we spawn (dev-server, shim
99    // binaries) sees the same mode without further plumbing.
100    if cli.verbose {
101        std::env::set_var("WHISKER_VERBOSE", "1");
102    }
103    match cli.command {
104        Command::Doctor(a) => doctor::run(a),
105        Command::Run(a) => run::run(a),
106        Command::NewModule(a) => new_module::run(a),
107        Command::New(a) => new_app::run(a),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn parse<I, S>(args: I) -> std::result::Result<Cli, clap::Error>
116    where
117        I: IntoIterator<Item = S>,
118        S: Into<std::ffi::OsString> + Clone,
119    {
120        Cli::try_parse_from(args)
121    }
122
123    #[test]
124    fn parses_doctor_with_no_flags() {
125        let cli = parse(["whisker", "doctor"]).unwrap();
126        match cli.command {
127            Command::Doctor(a) => {
128                assert!(!a.no_ios);
129                assert!(!a.no_android);
130            }
131            other => panic!("expected Doctor, got {other:?}"),
132        }
133    }
134
135    #[test]
136    fn parses_run_with_only_target() {
137        // `target` is now required (no default), so the bare
138        // `whisker run` form is gone — supply a positional target
139        // and assert the rest of the args adopt their defaults.
140        let cli = parse(["whisker", "run", "android"]).unwrap();
141        match cli.command {
142            Command::Run(a) => {
143                assert!(a.manifest_path.is_none());
144                assert_eq!(a.target, run::CliTarget::Android);
145                assert_eq!(a.bind.port(), 9876);
146                // Hot-patch is the dev default — opt out with --no-hot-patch.
147                assert!(!a.no_hot_patch);
148                assert!(a.workspace_root.is_none());
149            }
150            other => panic!("expected Run, got {other:?}"),
151        }
152    }
153
154    #[test]
155    fn parses_run_without_target_fails() {
156        // `whisker run` with no positional target is now an error
157        // (Host was the previous default and has been removed).
158        let res = parse(["whisker", "run"]);
159        assert!(res.is_err(), "expected clap error, got {res:?}");
160    }
161
162    #[test]
163    fn parses_run_with_explicit_target_and_flags() {
164        // `target` moved from `--target <value>` to a positional
165        // argument (`whisker run android`) — clap accepts it in any
166        // position relative to the named flags, so the test mixes
167        // them deliberately.
168        let cli = parse([
169            "whisker",
170            "run",
171            "--manifest-path",
172            "/tmp/my-app/Cargo.toml",
173            "android",
174            "--bind",
175            "0.0.0.0:1234",
176            "--no-hot-patch",
177        ])
178        .unwrap();
179        match cli.command {
180            Command::Run(a) => {
181                assert_eq!(
182                    a.manifest_path.as_deref(),
183                    Some(std::path::Path::new("/tmp/my-app/Cargo.toml")),
184                );
185                assert_eq!(a.target, run::CliTarget::Android);
186                assert_eq!(a.bind.to_string(), "0.0.0.0:1234");
187                assert!(a.no_hot_patch);
188            }
189            other => panic!("expected Run, got {other:?}"),
190        }
191    }
192
193    #[test]
194    fn parses_doctor_skip_flags() {
195        let cli = parse(["whisker", "doctor", "--no-ios", "--no-android"]).unwrap();
196        match cli.command {
197            Command::Doctor(a) => {
198                assert!(a.no_ios);
199                assert!(a.no_android);
200            }
201            other => panic!("expected Doctor, got {other:?}"),
202        }
203    }
204
205    #[test]
206    fn missing_subcommand_is_an_error() {
207        // Clap renders help when no subcommand is given (we haven't
208        // marked any as default), so the error kind here is the
209        // help-on-missing-arg variant rather than `MissingSubcommand`.
210        let e = parse(["whisker"]).unwrap_err();
211        assert_eq!(
212            e.kind(),
213            clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
214        );
215    }
216
217    #[test]
218    fn unknown_subcommand_is_an_error() {
219        let e = parse(["whisker", "frobnicate"]).unwrap_err();
220        assert_eq!(e.kind(), clap::error::ErrorKind::InvalidSubcommand);
221    }
222
223    #[test]
224    fn help_flag_short_circuits_to_displayhelp() {
225        let e = parse(["whisker", "--help"]).unwrap_err();
226        assert_eq!(e.kind(), clap::error::ErrorKind::DisplayHelp);
227    }
228
229    #[test]
230    fn version_flag_short_circuits_to_displayversion() {
231        let e = parse(["whisker", "--version"]).unwrap_err();
232        assert_eq!(e.kind(), clap::error::ErrorKind::DisplayVersion);
233    }
234}