Skip to main content

whisker_cli/
new_app.rs

1//! `whisker new <name>` — scaffold a new Whisker app crate.
2//!
3//! Creates a directory matching the supplied crate name with the
4//! minimum-viable Whisker app skeleton: a single-crate workspace
5//! `Cargo.toml`, a tiny `src/lib.rs` with `#[whisker::main]`, the
6//! `whisker.rs` `Config` probe, a `.gitignore`, a `rust-analyzer.toml`
7//! (format-on-save via `whisker fmt`), and a `README.md`.
8//! The result compiles standalone — the user runs `whisker run
9//! --target host` (or `--target ios` / `android` if their machine
10//! passes `whisker doctor`) and sees an interactive counter.
11//!
12//! ## Why a single-crate workspace?
13//!
14//! `whisker run` walks up from the crate's `Cargo.toml` looking for a
15//! `[workspace]` table — it uses the workspace root for Lynx cache
16//! paths, the rustc-shim cache dir, etc. A standalone app crate needs
17//! to advertise itself as both `[package]` and `[workspace]` so the
18//! single directory satisfies both lookups; otherwise `whisker run`
19//! errors out with "no [workspace] Cargo.toml at or above …".
20//!
21//! ## Naming
22//!
23//! - **Crate name** (the `<name>` arg): kebab-case, must be a valid
24//!   cargo package name. Example: `my-app`, `awesome-thing`.
25//! - **Display name** (derived from crate name): title-cased,
26//!   spaces between words. Example: `My App`, `Awesome Thing`.
27//!   Override with `--display-name`.
28//! - **Bundle ID** (derived from crate name): `rs.example.<ns>`
29//!   where `<ns>` is `_`-joined snake_case. Override with
30//!   `--bundle-id`.
31
32use anyhow::{anyhow, bail, Context, Result};
33use clap::Args;
34use std::path::{Path, PathBuf};
35
36/// `whisker new` CLI arguments.
37#[derive(Args, Debug)]
38pub struct NewAppArgs {
39    /// The cargo crate name. kebab-case (`my-app`, `awesome-thing`).
40    /// Must be a valid cargo package name — letters / digits / `-` /
41    /// `_`, must start with a letter.
42    pub name: String,
43
44    /// Optional parent directory. Defaults to the current working
45    /// directory. The new crate lands at `<parent>/<name>/`.
46    #[arg(long)]
47    pub path: Option<PathBuf>,
48
49    /// Override the iOS bundle id / Android applicationId.
50    /// Defaults to `rs.example.<snake_case_name>`.
51    #[arg(long)]
52    pub bundle_id: Option<String>,
53
54    /// Override the human-readable app display name. Defaults to the
55    /// crate name with `-` swapped for spaces and each word
56    /// title-cased (`my-app` → `My App`).
57    #[arg(long)]
58    pub display_name: Option<String>,
59}
60
61pub fn run(args: NewAppArgs) -> Result<()> {
62    validate_crate_name(&args.name)?;
63    let parent = args.path.unwrap_or_else(|| PathBuf::from("."));
64    let target_dir = parent.join(&args.name);
65    if target_dir.exists() {
66        bail!(
67            "{}: directory already exists. Pick a different name or remove it.",
68            target_dir.display(),
69        );
70    }
71
72    let ns = args.name.replace('-', "_");
73    let display_name = args
74        .display_name
75        .clone()
76        .unwrap_or_else(|| derive_display_name(&args.name));
77    let bundle_id = args
78        .bundle_id
79        .clone()
80        .unwrap_or_else(|| format!("rs.example.{ns}"));
81
82    let v = Vars {
83        crate_name: &args.name,
84        display_name: &display_name,
85        bundle_id: &bundle_id,
86    };
87
88    std::fs::create_dir_all(target_dir.join("src"))
89        .with_context(|| format!("create {}/src", target_dir.display()))?;
90
91    write(&target_dir, "Cargo.toml", &cargo_toml(&v))?;
92    write(&target_dir, "src/lib.rs", &lib_rs(&v))?;
93    write(&target_dir, "whisker.rs", &whisker_rs(&v))?;
94    write(&target_dir, ".gitignore", GITIGNORE)?;
95    write(&target_dir, "rust-analyzer.toml", RUST_ANALYZER_TOML)?;
96    write(&target_dir, "README.md", &readme(&v))?;
97
98    eprintln!(
99        "Created Whisker app at {}\n\
100         \n\
101         Next steps:\n  \
102         1. cd {}\n  \
103         2. whisker run ios      # requires Xcode + iOS simulator\n  \
104         3. whisker run android  # requires Android SDK + emulator\n  \
105         \n\
106         Run `whisker doctor` first to verify your toolchain.",
107        target_dir.display(),
108        target_dir.display(),
109    );
110    Ok(())
111}
112
113// ============================================================================
114// Template variables + rendering
115// ============================================================================
116
117struct Vars<'a> {
118    /// Cargo crate name, e.g. `my-app`.
119    crate_name: &'a str,
120    /// Human-readable display name shown in the app launcher.
121    display_name: &'a str,
122    /// Reverse-DNS bundle id / applicationId.
123    bundle_id: &'a str,
124}
125
126fn write(root: &Path, rel: &str, content: &str) -> Result<()> {
127    let path = root.join(rel);
128    if let Some(parent) = path.parent() {
129        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
130    }
131    std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
132    Ok(())
133}
134
135fn cargo_toml(v: &Vars) -> String {
136    format!(
137        r#"# `{name}` — a Whisker app. See README.md for usage.
138#
139# Single-crate workspace: the same `Cargo.toml` carries `[package]`
140# for cargo's package resolution and `[workspace]` so `whisker run`
141# can find a workspace root. Add sibling crates by listing them in
142# `workspace.members`.
143
144[workspace]
145members = ["."]
146resolver = "2"
147
148[package]
149name = "{name}"
150version = "0.1.0"
151edition = "2021"
152
153[lib]
154crate-type = ["rlib"]
155
156[dependencies]
157whisker = "{whisker_version}"
158"#,
159        name = v.crate_name,
160        whisker_version = whisker_dep_version(),
161    )
162}
163
164/// The `whisker` version a freshly-scaffolded app should depend on: the
165/// CLI's own `major.minor`. release-plz bumps every workspace crate in
166/// lockstep, so the CLI version always matches the published `whisker`
167/// crate — this keeps `whisker new` from pinning a stale version (e.g. a
168/// `0.1` scaffold while crates.io is already on `0.2`).
169fn whisker_dep_version() -> String {
170    let mut parts = env!("CARGO_PKG_VERSION").split('.');
171    let major = parts.next().unwrap_or("0");
172    let minor = parts.next().unwrap_or("1");
173    format!("{major}.{minor}")
174}
175
176fn lib_rs(v: &Vars) -> String {
177    let display = v.display_name;
178    format!(
179        r##"//! {display} — a Whisker app.
180
181use whisker::prelude::*;
182use whisker::runtime::view::Element;
183
184#[whisker::main]
185fn app() -> Element {{
186    // `RwSignal::new` creates a reactive value. The closure below
187    // re-runs whenever the signal changes, repainting the text in place.
188    let count = RwSignal::new(0);
189
190    render! {{
191        page(style: "flex-direction: column; padding: 24px; gap: 16px; background-color: #0f0f10;") {{
192            text(
193                value: "{display}",
194                style: "color: white; font-size: 24px; font-weight: 600;",
195            )
196            text(
197                value: computed(move || format!("Taps: {{}}", count.get())),
198                style: "color: #d4d4d8; font-size: 18px;",
199            )
200            view(
201                style: "background-color: #4f46e5; padding: 14px 24px; border-radius: 10px; align-self: flex-start;",
202                on_tap: move |_| count.set(count.get() + 1),
203            ) {{
204                text(
205                    value: "Tap me",
206                    style: "color: white; font-size: 16px; font-weight: 500;",
207                )
208            }}
209        }}
210    }}
211}}
212"##
213    )
214}
215
216fn whisker_rs(v: &Vars) -> String {
217    format!(
218        r#"// `whisker.rs` — Whisker app configuration.
219//
220// `whisker run` compiles this file as a tiny probe binary that
221// serializes the resulting `Config` to JSON; the CLI reads that
222// JSON and projects it into the dev-server's flat `Config`.
223
224pub fn configure(app: &mut whisker_config::Config) {{
225    app.name("{display}")
226        .bundle_id("{bundle_id}")
227        .version("0.1.0")
228        .build_number(1);
229
230    app.android(|a| {{
231        a.package("{bundle_id}")
232            .application_id("{bundle_id}")
233            .launcher_activity(".MainActivity")
234            .min_sdk(24)
235            .target_sdk(34);
236    }});
237
238    app.ios(|i| {{
239        i.bundle_id("{bundle_id}")
240            .scheme("{display}")
241            .deployment_target("13.0");
242    }});
243}}
244"#,
245        display = v.display_name,
246        bundle_id = v.bundle_id,
247    )
248}
249
250const GITIGNORE: &str = "\
251# Cargo build artifacts.
252target/
253
254# rustfmt backup files (older toolchains left these behind on a failed
255# format pass; harmless to keep ignored).
256**/*.rs.bk
257
258# Whisker-generated host projects — refreshed on every `whisker run`.
259# Includes gradle's `.gradle/` + `build/` caches and xcodebuild's
260# `xcuserdata/` / `*.xcuserstate` under here, so no need to list those
261# separately.
262gen/
263
264# Environment / secrets. Copy the pattern (e.g. `.env.example`) when
265# you need to share a template across the team without committing the
266# real values.
267.env
268.env.local
269.env.*.local
270
271# IDE / editor noise.
272.idea/
273.vscode/
274*.iml
275.vs/
276
277# OS noise.
278.DS_Store
279Thumbs.db
280
281# NOTE: `Cargo.lock` is deliberately NOT ignored. A Whisker user crate
282# is shaped like an application (compiled into the device-side dylib),
283# so the lock file is what guarantees every CI / teammate / production
284# build resolves to the same dependency tree. Commit it.
285";
286
287/// Editor-agnostic rust-analyzer project config. Any rust-analyzer
288/// host (VS Code, Zed, neovim, Helix, …) reads `rust-analyzer.toml`, so
289/// format-on-save routes through `whisker fmt` for everyone — a rustfmt
290/// drop-in that also formats the `render!` / `css!` macro bodies rustfmt
291/// leaves untouched (and still honors your `rustfmt.toml`).
292const RUST_ANALYZER_TOML: &str = "\
293# rust-analyzer project configuration (committed: shared by the whole
294# team, editor-agnostic). Formats on save with `whisker fmt`, a rustfmt
295# drop-in that ALSO formats `render!` / `css!` macro bodies — which plain
296# rustfmt skips — while still honoring your `rustfmt.toml`.
297#
298# Requires the `whisker` CLI on PATH (`cargo install whisker-cli`).
299[rustfmt]
300overrideCommand = [\"whisker\", \"fmt\", \"--stdin\"]
301";
302
303fn readme(v: &Vars) -> String {
304    format!(
305        r##"# {display}
306
307A [Whisker](https://github.com/whiskerrs/whisker) app.
308
309## Develop
310
311```sh
312# On an iOS Simulator (macOS only).
313whisker run ios
314
315# On an Android device or emulator.
316whisker run android
317```
318
319Run `whisker doctor` first to verify your toolchain is set up for each
320target.
321
322## Edit
323
324The UI lives in [`src/lib.rs`](src/lib.rs). Save any change and
325`whisker run` hot-patches the running app in under a second — no
326restart, no state loss.
327
328App-level metadata (bundle id, app name, Android / iOS deployment
329settings) lives in [`whisker.rs`](whisker.rs). Edits there require
330a full `whisker run` restart since they shape the generated native
331project.
332
333## Build for release
334
335Whisker doesn't wrap release builds — drive xcodebuild / gradle the
336same way CI does:
337
338```sh
339# Android release APK
340( cd gen/android && ./gradlew :app:assembleRelease )
341
342# iOS Simulator .app (Release configuration)
343xcodebuild -project gen/ios/<Scheme>.xcodeproj \
344  -scheme <Scheme> -configuration Release \
345  -destination 'generic/platform=iOS Simulator' build
346```
347
348The `gen/` tree is refreshed automatically on every `whisker run`;
349delete it whenever you want a clean re-generate.
350"##,
351        display = v.display_name,
352    )
353}
354
355// ============================================================================
356// Name validation + derivations
357// ============================================================================
358
359/// Reject crate names cargo wouldn't accept. Whisker doesn't add any
360/// constraints on top — the goal is to fail fast with a helpful
361/// message rather than letting `cargo build` print the same complaint
362/// after the scaffold landed on disk.
363fn validate_crate_name(name: &str) -> Result<()> {
364    if name.is_empty() {
365        return Err(anyhow!("crate name is empty"));
366    }
367    let first = name.chars().next().unwrap();
368    if !first.is_ascii_alphabetic() {
369        return Err(anyhow!(
370            "crate name must start with an ASCII letter (got `{first}`)"
371        ));
372    }
373    for c in name.chars() {
374        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
375            return Err(anyhow!(
376                "crate name contains illegal character `{c}` — allowed: ASCII letters, digits, `-`, `_`"
377            ));
378        }
379    }
380    Ok(())
381}
382
383/// Title-case the crate name for the display surface. `my-app` →
384/// `My App`. Underscore behaves like a dash.
385fn derive_display_name(crate_name: &str) -> String {
386    crate_name
387        .split(['-', '_'])
388        .filter(|s| !s.is_empty())
389        .map(|word| {
390            let mut chars = word.chars();
391            match chars.next() {
392                Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
393                None => String::new(),
394            }
395        })
396        .collect::<Vec<_>>()
397        .join(" ")
398}
399
400// ============================================================================
401// Tests
402// ============================================================================
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::sync::atomic::{AtomicU64, Ordering};
408
409    /// Per-process monotonic counter for tempdir suffixes. Keeps
410    /// concurrent test runs from racing on the same `<name>` dir.
411    fn test_seq() -> u64 {
412        static SEQ: AtomicU64 = AtomicU64::new(0);
413        SEQ.fetch_add(1, Ordering::Relaxed)
414    }
415
416    #[test]
417    fn validate_accepts_simple_kebab_name() {
418        validate_crate_name("my-app").unwrap();
419        validate_crate_name("a").unwrap();
420        validate_crate_name("foo_bar").unwrap();
421        validate_crate_name("v2").unwrap();
422    }
423
424    #[test]
425    fn validate_rejects_empty_and_non_letter_lead() {
426        assert!(validate_crate_name("").is_err());
427        assert!(validate_crate_name("1app").is_err());
428        assert!(validate_crate_name("-app").is_err());
429        assert!(validate_crate_name("_app").is_err());
430    }
431
432    #[test]
433    fn validate_rejects_illegal_chars() {
434        assert!(validate_crate_name("my app").is_err()); // space
435        assert!(validate_crate_name("my.app").is_err()); // dot
436        assert!(validate_crate_name("my/app").is_err()); // slash
437        assert!(validate_crate_name("café").is_err()); // non-ASCII
438    }
439
440    #[test]
441    fn display_name_title_cases_kebab_segments() {
442        assert_eq!(derive_display_name("my-app"), "My App");
443        assert_eq!(
444            derive_display_name("awesome-thing-pro"),
445            "Awesome Thing Pro"
446        );
447        assert_eq!(derive_display_name("hello_world"), "Hello World");
448        assert_eq!(derive_display_name("single"), "Single");
449    }
450
451    #[test]
452    fn display_name_skips_empty_segments() {
453        // Double-dash or trailing dash shouldn't produce a doubled
454        // space — `split` with a predicate filters to non-empty.
455        assert_eq!(derive_display_name("a--b"), "A B");
456        assert_eq!(derive_display_name("a-"), "A");
457    }
458
459    #[test]
460    fn scaffold_creates_expected_files() {
461        let tmp = std::env::temp_dir().join(format!(
462            "whisker-new-test-{}-{}",
463            std::process::id(),
464            // No `Instant::now` in cfg(test) constraints; a thread-id
465            // nibble is enough entropy for sequential test runs.
466            test_seq()
467        ));
468        std::fs::create_dir_all(&tmp).unwrap();
469        let args = NewAppArgs {
470            name: "demo-app".into(),
471            path: Some(tmp.clone()),
472            bundle_id: None,
473            display_name: None,
474        };
475        run(args).unwrap();
476
477        let root = tmp.join("demo-app");
478        assert!(root.join("Cargo.toml").is_file());
479        assert!(root.join("src/lib.rs").is_file());
480        assert!(root.join("whisker.rs").is_file());
481        assert!(root.join(".gitignore").is_file());
482        assert!(root.join("README.md").is_file());
483        assert!(root.join("rust-analyzer.toml").is_file());
484
485        // The scaffolded rust-analyzer config routes format-on-save
486        // through `whisker fmt` so render!/css! get formatted.
487        let ra = std::fs::read_to_string(root.join("rust-analyzer.toml")).unwrap();
488        assert!(ra.contains("[rustfmt]"));
489        assert!(ra.contains(r#"overrideCommand = ["whisker", "fmt", "--stdin"]"#));
490
491        let cargo = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
492        assert!(cargo.contains("name = \"demo-app\""));
493        assert!(cargo.contains("[workspace]"));
494        // Tracks the CLI's own major.minor (release-plz bumps in lockstep),
495        // so this stays correct across version bumps.
496        assert!(cargo.contains(&format!("whisker = \"{}\"", super::whisker_dep_version())));
497
498        let whisker_rs = std::fs::read_to_string(root.join("whisker.rs")).unwrap();
499        // Default display name + bundle id are derived.
500        assert!(whisker_rs.contains("Demo App"));
501        assert!(whisker_rs.contains("rs.example.demo_app"));
502
503        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
504        // Sanity-check the load-bearing entries — losing either of
505        // these would surface as committed `target/` artifacts or a
506        // committed `gen/` tree on the user's first push.
507        assert!(
508            gitignore.contains("target/"),
509            "missing target/ in .gitignore",
510        );
511        assert!(gitignore.contains("gen/"), "missing gen/ in .gitignore");
512        // `Cargo.lock` must NOT be ignored — a Whisker app's lock
513        // file pins the dep tree the CI / production build resolves.
514        assert!(
515            !gitignore.lines().any(|l| l.trim() == "Cargo.lock"),
516            ".gitignore must NOT exclude Cargo.lock",
517        );
518
519        std::fs::remove_dir_all(&tmp).ok();
520    }
521
522    #[test]
523    fn scaffold_respects_overrides() {
524        let tmp = std::env::temp_dir().join(format!(
525            "whisker-new-overrides-{}-{}",
526            std::process::id(),
527            test_seq()
528        ));
529        std::fs::create_dir_all(&tmp).unwrap();
530        let args = NewAppArgs {
531            name: "custom".into(),
532            path: Some(tmp.clone()),
533            bundle_id: Some("com.example.custom".into()),
534            display_name: Some("Custom Display".into()),
535        };
536        run(args).unwrap();
537
538        let whisker_rs = std::fs::read_to_string(tmp.join("custom/whisker.rs")).unwrap();
539        assert!(whisker_rs.contains("Custom Display"));
540        assert!(whisker_rs.contains("com.example.custom"));
541
542        std::fs::remove_dir_all(&tmp).ok();
543    }
544
545    #[test]
546    fn scaffold_refuses_to_clobber_existing_dir() {
547        let tmp = std::env::temp_dir().join(format!(
548            "whisker-new-clobber-{}-{}",
549            std::process::id(),
550            test_seq()
551        ));
552        std::fs::create_dir_all(tmp.join("existing")).unwrap();
553        let args = NewAppArgs {
554            name: "existing".into(),
555            path: Some(tmp.clone()),
556            bundle_id: None,
557            display_name: None,
558        };
559        let err = run(args).unwrap_err();
560        assert!(err.to_string().contains("already exists"));
561
562        std::fs::remove_dir_all(&tmp).ok();
563    }
564}