1use anyhow::{anyhow, bail, Context, Result};
32use clap::Args;
33use std::path::{Path, PathBuf};
34
35#[derive(Args, Debug)]
37pub struct NewAppArgs {
38 pub name: String,
42
43 #[arg(long)]
46 pub path: Option<PathBuf>,
47
48 #[arg(long)]
51 pub bundle_id: Option<String>,
52
53 #[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
111struct Vars<'a> {
116 crate_name: &'a str,
118 display_name: &'a str,
120 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
324fn 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
352fn 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#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::sync::atomic::{AtomicU64, Ordering};
377
378 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()); assert!(validate_crate_name("my.app").is_err()); assert!(validate_crate_name("my/app").is_err()); assert!(validate_crate_name("café").is_err()); }
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 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 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 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 assert!(
468 gitignore.contains("target/"),
469 "missing target/ in .gitignore",
470 );
471 assert!(gitignore.contains("gen/"), "missing gen/ in .gitignore");
472 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}