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 linker_shim;
41pub mod manifest;
42pub mod new_app;
43pub mod new_module;
44pub mod platforms;
45pub mod probe;
46pub mod run;
47pub mod rustc_shim;
48pub mod tui;
49
50#[derive(Parser, Debug)]
51#[command(
52 name = "whisker",
53 about = "Whisker — cross-platform mobile UI framework",
54 version
55)]
56struct Cli {
57 /// Show every step's full underlying output (raw cargo /
58 /// xcodebuild / simctl streams + the internal debug logs the
59 /// curated UI hides by default). Plumbed into `whisker-build::ui`
60 /// via the `WHISKER_VERBOSE` env var so subprocesses
61 /// (`whisker-dev-server`, the shim binaries, etc.) inherit it.
62 #[arg(long, short = 'v', global = true)]
63 verbose: bool,
64
65 #[command(subcommand)]
66 command: Command,
67}
68
69#[derive(Subcommand, Debug)]
70enum Command {
71 /// Inspect the local toolchain — Rust targets, Android NDK/SDK/JDK,
72 /// Xcode.
73 Doctor(doctor::Args),
74 /// Build, install, and dev-loop a Whisker app — file watch + rebuild
75 /// + subsecond hot patches over WebSocket.
76 Run(run::Args),
77 /// Scaffold a new Whisker module crate — Cargo.toml (with the
78 /// `[package.metadata.whisker]` marker), Package.swift,
79 /// build.gradle.kts, and skeleton Rust / Swift / Kotlin sources.
80 /// See `docs/module-author-guide.md`.
81 NewModule(new_module::NewModuleArgs),
82 /// Scaffold a new Whisker app — single-crate workspace with
83 /// `Cargo.toml`, a `#[whisker::main]` `src/lib.rs`, the
84 /// `whisker.rs` `Config` probe, `.gitignore`, and `README.md`.
85 /// The result compiles standalone; run `whisker run android` or
86 /// `whisker run ios` from inside the new directory.
87 New(new_app::NewAppArgs),
88
89 /// (internal) Cross-compile the user crate into
90 /// `WhiskerDriver.framework`. Invoked by the generated Xcode
91 /// project's Run Script Phase, not by users.
92 #[command(name = "build-ios", hide = true)]
93 BuildIos(build_dispatch::IosArgs),
94
95 /// (internal) Cross-compile the user crate into `lib*.so`. Invoked
96 /// by the Whisker Gradle plugin's `cargoBuild*` task, not by users.
97 #[command(name = "build-android", hide = true)]
98 BuildAndroid(build_dispatch::AndroidArgs),
99
100 /// (internal) Emit a JSON manifest of the app's Whisker modules.
101 /// Invoked by the Gradle Settings plugin at init, not by users.
102 #[command(name = "modules", hide = true)]
103 Modules(build_dispatch::ModulesArgs),
104}
105
106pub fn run(args: impl IntoIterator<Item = String>) -> Result<()> {
107 // Use clap's own exit path so `--help` / `--version` print to stdout
108 // with exit code 0; bubbling the result through anyhow would prefix
109 // it with "Error: " and exit non-zero.
110 let cli = match Cli::try_parse_from(args) {
111 Ok(c) => c,
112 Err(e) => e.exit(),
113 };
114 // `--verbose` and `WHISKER_VERBOSE=1` are the same switch. Setting
115 // the env var means any subprocess we spawn (dev-server, shim
116 // binaries) sees the same mode without further plumbing.
117 if cli.verbose {
118 std::env::set_var("WHISKER_VERBOSE", "1");
119 }
120 match cli.command {
121 Command::Doctor(a) => doctor::run(a),
122 Command::Run(a) => run::run(a),
123 Command::NewModule(a) => new_module::run(a),
124 Command::New(a) => new_app::run(a),
125 Command::BuildIos(a) => build_dispatch::run_ios(a),
126 Command::BuildAndroid(a) => build_dispatch::run_android(a),
127 Command::Modules(a) => build_dispatch::run_modules(a),
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn parse<I, S>(args: I) -> std::result::Result<Cli, clap::Error>
136 where
137 I: IntoIterator<Item = S>,
138 S: Into<std::ffi::OsString> + Clone,
139 {
140 Cli::try_parse_from(args)
141 }
142
143 #[test]
144 fn parses_doctor_with_no_flags() {
145 let cli = parse(["whisker", "doctor"]).unwrap();
146 match cli.command {
147 Command::Doctor(a) => {
148 assert!(!a.no_ios);
149 assert!(!a.no_android);
150 }
151 other => panic!("expected Doctor, got {other:?}"),
152 }
153 }
154
155 #[test]
156 fn parses_run_with_only_target() {
157 // `target` is now required (no default), so the bare
158 // `whisker run` form is gone — supply a positional target
159 // and assert the rest of the args adopt their defaults.
160 let cli = parse(["whisker", "run", "android"]).unwrap();
161 match cli.command {
162 Command::Run(a) => {
163 assert!(a.manifest_path.is_none());
164 assert_eq!(a.target, run::CliTarget::Android);
165 assert_eq!(a.bind.port(), 9876);
166 // Hot-patch is the dev default — opt out with --no-hot-patch.
167 assert!(!a.no_hot_patch);
168 assert!(a.workspace_root.is_none());
169 }
170 other => panic!("expected Run, got {other:?}"),
171 }
172 }
173
174 #[test]
175 fn parses_run_without_target_fails() {
176 // `whisker run` with no positional target is now an error
177 // (Host was the previous default and has been removed).
178 let res = parse(["whisker", "run"]);
179 assert!(res.is_err(), "expected clap error, got {res:?}");
180 }
181
182 #[test]
183 fn parses_run_with_explicit_target_and_flags() {
184 // `target` moved from `--target <value>` to a positional
185 // argument (`whisker run android`) — clap accepts it in any
186 // position relative to the named flags, so the test mixes
187 // them deliberately.
188 let cli = parse([
189 "whisker",
190 "run",
191 "--manifest-path",
192 "/tmp/my-app/Cargo.toml",
193 "android",
194 "--bind",
195 "0.0.0.0:1234",
196 "--no-hot-patch",
197 ])
198 .unwrap();
199 match cli.command {
200 Command::Run(a) => {
201 assert_eq!(
202 a.manifest_path.as_deref(),
203 Some(std::path::Path::new("/tmp/my-app/Cargo.toml")),
204 );
205 assert_eq!(a.target, run::CliTarget::Android);
206 assert_eq!(a.bind.to_string(), "0.0.0.0:1234");
207 assert!(a.no_hot_patch);
208 }
209 other => panic!("expected Run, got {other:?}"),
210 }
211 }
212
213 #[test]
214 fn parses_doctor_skip_flags() {
215 let cli = parse(["whisker", "doctor", "--no-ios", "--no-android"]).unwrap();
216 match cli.command {
217 Command::Doctor(a) => {
218 assert!(a.no_ios);
219 assert!(a.no_android);
220 }
221 other => panic!("expected Doctor, got {other:?}"),
222 }
223 }
224
225 #[test]
226 fn missing_subcommand_is_an_error() {
227 // Clap renders help when no subcommand is given (we haven't
228 // marked any as default), so the error kind here is the
229 // help-on-missing-arg variant rather than `MissingSubcommand`.
230 let e = parse(["whisker"]).unwrap_err();
231 assert_eq!(
232 e.kind(),
233 clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
234 );
235 }
236
237 #[test]
238 fn unknown_subcommand_is_an_error() {
239 let e = parse(["whisker", "frobnicate"]).unwrap_err();
240 assert_eq!(e.kind(), clap::error::ErrorKind::InvalidSubcommand);
241 }
242
243 #[test]
244 fn help_flag_short_circuits_to_displayhelp() {
245 let e = parse(["whisker", "--help"]).unwrap_err();
246 assert_eq!(e.kind(), clap::error::ErrorKind::DisplayHelp);
247 }
248
249 // The generated native projects (gen/ios pbxproj Run Script, the
250 // Gradle plugin) call these hidden subcommands by exact name +
251 // flags. If a rename/flag-change slips through, the templates break
252 // silently at build time — so pin the CLI contract here.
253 #[test]
254 fn internal_build_subcommands_parse() {
255 match parse([
256 "whisker",
257 "build-ios",
258 "--workspace=/ws",
259 "--package=app",
260 "--configuration=Debug",
261 "--platform=iphonesimulator",
262 "--archs=arm64",
263 "--built-products-dir=/out",
264 ])
265 .unwrap()
266 .command
267 {
268 Command::BuildIos(_) => {}
269 other => panic!("expected BuildIos, got {other:?}"),
270 }
271
272 match parse([
273 "whisker",
274 "build-android",
275 "--workspace=/ws",
276 "--package=app",
277 "--profile=debug",
278 "--abi=arm64-v8a",
279 "--jni-libs-dir=/jni",
280 ])
281 .unwrap()
282 .command
283 {
284 Command::BuildAndroid(_) => {}
285 other => panic!("expected BuildAndroid, got {other:?}"),
286 }
287
288 match parse(["whisker", "modules", "--workspace=/ws", "--package=app"])
289 .unwrap()
290 .command
291 {
292 Command::Modules(_) => {}
293 other => panic!("expected Modules, got {other:?}"),
294 }
295 }
296
297 #[test]
298 fn version_flag_short_circuits_to_displayversion() {
299 let e = parse(["whisker", "--version"]).unwrap_err();
300 assert_eq!(e.kind(), clap::error::ErrorKind::DisplayVersion);
301 }
302}