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 = "{whisker_version}"
156"#,
157 name = v.crate_name,
158 whisker_version = whisker_dep_version(),
159 )
160}
161
162fn 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
337fn 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
365fn 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#[cfg(test)]
387mod tests {
388 use super::*;
389 use std::sync::atomic::{AtomicU64, Ordering};
390
391 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()); assert!(validate_crate_name("my.app").is_err()); assert!(validate_crate_name("my/app").is_err()); assert!(validate_crate_name("café").is_err()); }
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 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 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 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 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 assert!(
483 gitignore.contains("target/"),
484 "missing target/ in .gitignore",
485 );
486 assert!(gitignore.contains("gen/"), "missing gen/ in .gitignore");
487 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}