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 = "0.1"
156"#,
157        name = v.crate_name,
158    )
159}
160
161fn lib_rs(v: &Vars) -> String {
162    let display = v.display_name;
163    format!(
164        r##"//! {display} — a Whisker app.
165
166use whisker::prelude::*;
167use whisker::runtime::view::Element;
168
169#[whisker::main]
170fn app() -> Element {{
171    // `RwSignal::new` creates a reactive value. The closure below
172    // re-runs whenever the signal changes, repainting the text in place.
173    let count = RwSignal::new(0);
174
175    render! {{
176        page(style: "flex-direction: column; padding: 24px; gap: 16px; background-color: #0f0f10;") {{
177            text(
178                value: "{display}",
179                style: "color: white; font-size: 24px; font-weight: 600;",
180            )
181            text(
182                value: computed(move || format!("Taps: {{}}", count.get())),
183                style: "color: #d4d4d8; font-size: 18px;",
184            )
185            view(
186                style: "background-color: #4f46e5; padding: 14px 24px; border-radius: 10px; align-self: flex-start;",
187                on_tap: move |_| count.set(count.get() + 1),
188            ) {{
189                text(
190                    value: "Tap me",
191                    style: "color: white; font-size: 16px; font-weight: 500;",
192                )
193            }}
194        }}
195    }}
196}}
197"##
198    )
199}
200
201fn whisker_rs(v: &Vars) -> String {
202    format!(
203        r#"// `whisker.rs` — Whisker app configuration.
204//
205// `whisker run` compiles this file as a tiny probe binary that
206// serializes the resulting `Config` to JSON; the CLI reads that
207// JSON and projects it into the dev-server's flat `Config`.
208
209pub fn configure(app: &mut whisker_config::Config) {{
210    app.name("{display}")
211        .bundle_id("{bundle_id}")
212        .version("0.1.0")
213        .build_number(1);
214
215    app.android(|a| {{
216        a.package("{bundle_id}")
217            .application_id("{bundle_id}")
218            .launcher_activity(".MainActivity")
219            .min_sdk(24)
220            .target_sdk(34);
221    }});
222
223    app.ios(|i| {{
224        i.bundle_id("{bundle_id}")
225            .scheme("{display}")
226            .deployment_target("13.0");
227    }});
228}}
229"#,
230        display = v.display_name,
231        bundle_id = v.bundle_id,
232    )
233}
234
235const GITIGNORE: &str = "\
236# Cargo build artifacts.
237target/
238
239# rustfmt backup files (older toolchains left these behind on a failed
240# format pass; harmless to keep ignored).
241**/*.rs.bk
242
243# Whisker-generated host projects — refreshed on every `whisker run`.
244# Includes gradle's `.gradle/` + `build/` caches and xcodebuild's
245# `xcuserdata/` / `*.xcuserstate` under here, so no need to list those
246# separately.
247gen/
248
249# Environment / secrets. Copy the pattern (e.g. `.env.example`) when
250# you need to share a template across the team without committing the
251# real values.
252.env
253.env.local
254.env.*.local
255
256# IDE / editor noise.
257.idea/
258.vscode/
259*.iml
260.vs/
261
262# OS noise.
263.DS_Store
264Thumbs.db
265
266# NOTE: `Cargo.lock` is deliberately NOT ignored. A Whisker user crate
267# is shaped like an application (compiled into the device-side dylib),
268# so the lock file is what guarantees every CI / teammate / production
269# build resolves to the same dependency tree. Commit it.
270";
271
272fn readme(v: &Vars) -> String {
273    format!(
274        r##"# {display}
275
276A [Whisker](https://github.com/whiskerrs/whisker) app.
277
278## Develop
279
280```sh
281# On an iOS Simulator (macOS only).
282whisker run ios
283
284# On an Android device or emulator.
285whisker run android
286```
287
288Run `whisker doctor` first to verify your toolchain is set up for each
289target.
290
291## Edit
292
293The UI lives in [`src/lib.rs`](src/lib.rs). Save any change and
294`whisker run` hot-patches the running app in under a second — no
295restart, no state loss.
296
297App-level metadata (bundle id, app name, Android / iOS deployment
298settings) lives in [`whisker.rs`](whisker.rs). Edits there require
299a full `whisker run` restart since they shape the generated native
300project.
301
302## Build for release
303
304Whisker doesn't wrap release builds — drive xcodebuild / gradle the
305same way CI does:
306
307```sh
308# Android release APK
309( cd gen/android && ./gradlew :app:assembleRelease )
310
311# iOS Simulator .app (Release configuration)
312xcodebuild -project gen/ios/<Scheme>.xcodeproj \
313  -scheme <Scheme> -configuration Release \
314  -destination 'generic/platform=iOS Simulator' build
315```
316
317The `gen/` tree is refreshed automatically on every `whisker run`;
318delete it whenever you want a clean re-generate.
319"##,
320        display = v.display_name,
321    )
322}
323
324// ============================================================================
325// Name validation + derivations
326// ============================================================================
327
328/// Reject crate names cargo wouldn't accept. Whisker doesn't add any
329/// constraints on top — the goal is to fail fast with a helpful
330/// message rather than letting `cargo build` print the same complaint
331/// after the scaffold landed on disk.
332fn validate_crate_name(name: &str) -> Result<()> {
333    if name.is_empty() {
334        return Err(anyhow!("crate name is empty"));
335    }
336    let first = name.chars().next().unwrap();
337    if !first.is_ascii_alphabetic() {
338        return Err(anyhow!(
339            "crate name must start with an ASCII letter (got `{first}`)"
340        ));
341    }
342    for c in name.chars() {
343        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
344            return Err(anyhow!(
345                "crate name contains illegal character `{c}` — allowed: ASCII letters, digits, `-`, `_`"
346            ));
347        }
348    }
349    Ok(())
350}
351
352/// Title-case the crate name for the display surface. `my-app` →
353/// `My App`. Underscore behaves like a dash.
354fn derive_display_name(crate_name: &str) -> String {
355    crate_name
356        .split(['-', '_'])
357        .filter(|s| !s.is_empty())
358        .map(|word| {
359            let mut chars = word.chars();
360            match chars.next() {
361                Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
362                None => String::new(),
363            }
364        })
365        .collect::<Vec<_>>()
366        .join(" ")
367}
368
369// ============================================================================
370// Tests
371// ============================================================================
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::sync::atomic::{AtomicU64, Ordering};
377
378    /// Per-process monotonic counter for tempdir suffixes. Keeps
379    /// concurrent test runs from racing on the same `<name>` dir.
380    fn test_seq() -> u64 {
381        static SEQ: AtomicU64 = AtomicU64::new(0);
382        SEQ.fetch_add(1, Ordering::Relaxed)
383    }
384
385    #[test]
386    fn validate_accepts_simple_kebab_name() {
387        validate_crate_name("my-app").unwrap();
388        validate_crate_name("a").unwrap();
389        validate_crate_name("foo_bar").unwrap();
390        validate_crate_name("v2").unwrap();
391    }
392
393    #[test]
394    fn validate_rejects_empty_and_non_letter_lead() {
395        assert!(validate_crate_name("").is_err());
396        assert!(validate_crate_name("1app").is_err());
397        assert!(validate_crate_name("-app").is_err());
398        assert!(validate_crate_name("_app").is_err());
399    }
400
401    #[test]
402    fn validate_rejects_illegal_chars() {
403        assert!(validate_crate_name("my app").is_err()); // space
404        assert!(validate_crate_name("my.app").is_err()); // dot
405        assert!(validate_crate_name("my/app").is_err()); // slash
406        assert!(validate_crate_name("café").is_err()); // non-ASCII
407    }
408
409    #[test]
410    fn display_name_title_cases_kebab_segments() {
411        assert_eq!(derive_display_name("my-app"), "My App");
412        assert_eq!(
413            derive_display_name("awesome-thing-pro"),
414            "Awesome Thing Pro"
415        );
416        assert_eq!(derive_display_name("hello_world"), "Hello World");
417        assert_eq!(derive_display_name("single"), "Single");
418    }
419
420    #[test]
421    fn display_name_skips_empty_segments() {
422        // Double-dash or trailing dash shouldn't produce a doubled
423        // space — `split` with a predicate filters to non-empty.
424        assert_eq!(derive_display_name("a--b"), "A B");
425        assert_eq!(derive_display_name("a-"), "A");
426    }
427
428    #[test]
429    fn scaffold_creates_expected_files() {
430        let tmp = std::env::temp_dir().join(format!(
431            "whisker-new-test-{}-{}",
432            std::process::id(),
433            // No `Instant::now` in cfg(test) constraints; a thread-id
434            // nibble is enough entropy for sequential test runs.
435            test_seq()
436        ));
437        std::fs::create_dir_all(&tmp).unwrap();
438        let args = NewAppArgs {
439            name: "demo-app".into(),
440            path: Some(tmp.clone()),
441            bundle_id: None,
442            display_name: None,
443        };
444        run(args).unwrap();
445
446        let root = tmp.join("demo-app");
447        assert!(root.join("Cargo.toml").is_file());
448        assert!(root.join("src/lib.rs").is_file());
449        assert!(root.join("whisker.rs").is_file());
450        assert!(root.join(".gitignore").is_file());
451        assert!(root.join("README.md").is_file());
452
453        let cargo = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
454        assert!(cargo.contains("name = \"demo-app\""));
455        assert!(cargo.contains("[workspace]"));
456        assert!(cargo.contains("whisker = \"0.1\""));
457
458        let whisker_rs = std::fs::read_to_string(root.join("whisker.rs")).unwrap();
459        // Default display name + bundle id are derived.
460        assert!(whisker_rs.contains("Demo App"));
461        assert!(whisker_rs.contains("rs.example.demo_app"));
462
463        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
464        // Sanity-check the load-bearing entries — losing either of
465        // these would surface as committed `target/` artifacts or a
466        // committed `gen/` tree on the user's first push.
467        assert!(
468            gitignore.contains("target/"),
469            "missing target/ in .gitignore",
470        );
471        assert!(gitignore.contains("gen/"), "missing gen/ in .gitignore");
472        // `Cargo.lock` must NOT be ignored — a Whisker app's lock
473        // file pins the dep tree the CI / production build resolves.
474        assert!(
475            !gitignore.lines().any(|l| l.trim() == "Cargo.lock"),
476            ".gitignore must NOT exclude Cargo.lock",
477        );
478
479        std::fs::remove_dir_all(&tmp).ok();
480    }
481
482    #[test]
483    fn scaffold_respects_overrides() {
484        let tmp = std::env::temp_dir().join(format!(
485            "whisker-new-overrides-{}-{}",
486            std::process::id(),
487            test_seq()
488        ));
489        std::fs::create_dir_all(&tmp).unwrap();
490        let args = NewAppArgs {
491            name: "custom".into(),
492            path: Some(tmp.clone()),
493            bundle_id: Some("com.example.custom".into()),
494            display_name: Some("Custom Display".into()),
495        };
496        run(args).unwrap();
497
498        let whisker_rs = std::fs::read_to_string(tmp.join("custom/whisker.rs")).unwrap();
499        assert!(whisker_rs.contains("Custom Display"));
500        assert!(whisker_rs.contains("com.example.custom"));
501
502        std::fs::remove_dir_all(&tmp).ok();
503    }
504
505    #[test]
506    fn scaffold_refuses_to_clobber_existing_dir() {
507        let tmp = std::env::temp_dir().join(format!(
508            "whisker-new-clobber-{}-{}",
509            std::process::id(),
510            test_seq()
511        ));
512        std::fs::create_dir_all(tmp.join("existing")).unwrap();
513        let args = NewAppArgs {
514            name: "existing".into(),
515            path: Some(tmp.clone()),
516            bundle_id: None,
517            display_name: None,
518        };
519        let err = run(args).unwrap_err();
520        assert!(err.to_string().contains("already exists"));
521
522        std::fs::remove_dir_all(&tmp).ok();
523    }
524}