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