1use anyhow::{anyhow, bail, Context, Result};
33use clap::Args;
34use std::path::{Path, PathBuf};
35
36#[derive(Args, Debug)]
38pub struct NewAppArgs {
39 pub name: String,
43
44 #[arg(long)]
47 pub path: Option<PathBuf>,
48
49 #[arg(long)]
52 pub bundle_id: Option<String>,
53
54 #[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
113struct Vars<'a> {
118 crate_name: &'a str,
120 display_name: &'a str,
122 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
164fn 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
287const 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
355fn 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
383fn 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#[cfg(test)]
405mod tests {
406 use super::*;
407 use std::sync::atomic::{AtomicU64, Ordering};
408
409 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()); assert!(validate_crate_name("my.app").is_err()); assert!(validate_crate_name("my/app").is_err()); assert!(validate_crate_name("café").is_err()); }
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 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 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 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 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 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 assert!(
508 gitignore.contains("target/"),
509 "missing target/ in .gitignore",
510 );
511 assert!(gitignore.contains("gen/"), "missing gen/ in .gitignore");
512 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}