Skip to main content

whisker_cli/
new_module.rs

1//! `whisker new-module <name>` — scaffold a Whisker module crate.
2//!
3//! Creates a directory matching the supplied crate name with a
4//! complete module skeleton: `Cargo.toml` (carrying the
5//! `[package.metadata.whisker]` discovery marker), `Package.swift`,
6//! `build.gradle.kts`, `src/lib.rs`, and the platform sources under
7//! `ios/` and `android/` (Expo-style layout). The skeleton compiles
8//! standalone — the consumer just runs `cargo build` and adds the
9//! crate as a dep to their Whisker app.
10//!
11//! Naming convention: input is the cargo crate name (kebab-case,
12//! `whisker-foo`). The PascalCase tag (`Foo`), the module class
13//! (`FooModule`), and (for view-bearing modules) the view class
14//! (`FooView`) are derived. Lynx registers a view-bearing module's
15//! element under `<crate-name>:<tag>` (`whisker-foo:Foo`).
16//!
17//! Modules are authored with the ModuleDefinition DSL: a class
18//! subclasses `Module` and overrides `definition()`. Subclassing
19//! the base IS the registration trigger — the per-platform
20//! codegen (SwiftPM build plugin / KSP) finds every concrete
21//! `Module` subclass and emits the Lynx registration. Phase M
22//! (Issue #59) dropped the previously-companion `@WhiskerModule`
23//! marker annotation.
24//!
25//! This is a minimal scaffolder — it copies a small set of inline
26//! templates and substitutes a handful of variables. For a richer
27//! template story (multiple module types, custom dirs, …) the
28//! `whisker new-module` subcommand can grow later without breaking
29//! the contract documented at <https://whisker.rs/docs/authoring-a-module>.
30
31use anyhow::{anyhow, bail, Context, Result};
32use clap::Args;
33use std::path::{Path, PathBuf};
34
35/// `whisker new-module` CLI arguments.
36#[derive(Args, Debug)]
37pub struct NewModuleArgs {
38    /// The cargo crate name. Convention: kebab-case, prefixed with
39    /// `whisker-` (e.g. `whisker-camera`, `whisker-blur-view`). Must
40    /// be a valid cargo package name — letters / digits / `-` / `_`,
41    /// must start with a letter.
42    pub name: String,
43
44    /// Optional parent directory. Defaults to the current working
45    /// directory. The new crate lands at `<parent>/<name>/`.
46    #[arg(long)]
47    pub path: Option<PathBuf>,
48
49    /// Module shape. `view-bearing` (the default) generates a
50    /// `#[whisker::module_component]` shim + a DSL module with a
51    /// `View(...)` block and a `WhiskerUI<View>` subclass.
52    /// `function-only` generates a `#[whisker::platform_module]`
53    /// proxy + a DSL module with module-level `Function`s and no
54    /// `View(...)` — for modules that only expose function calls
55    /// (e.g. `whisker-local-store`-style key-value stores).
56    #[arg(long, value_enum, default_value_t = ModuleShape::ViewBearing)]
57    pub shape: ModuleShape,
58}
59
60#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)]
61pub enum ModuleShape {
62    /// View-bearing — renders a native view + supports prop / method
63    /// dispatch via `ElementRef<T>`.
64    #[value(name = "view-bearing")]
65    ViewBearing,
66    /// Function-only — Rust calls platform-side functions; no UI.
67    #[value(name = "function-only")]
68    FunctionOnly,
69}
70
71pub fn run(args: NewModuleArgs) -> Result<()> {
72    validate_crate_name(&args.name)?;
73    let parent = args.path.unwrap_or_else(|| PathBuf::from("."));
74    let target_dir = parent.join(&args.name);
75    if target_dir.exists() {
76        bail!(
77            "{}: directory already exists. Pick a different name or remove it.",
78            target_dir.display(),
79        );
80    }
81
82    let tag = pascal_case_tag(&args.name);
83    let spm = crate_to_spm_target(&args.name);
84    let ns = args.name.replace('-', "_");
85    let ident = args
86        .name
87        .replace('-', "_")
88        .trim_start_matches("whisker_")
89        .to_string();
90    let module_class = format!("{tag}Module");
91    let view_class = format!("{tag}View");
92
93    let v = Vars {
94        crate_name: &args.name,
95        tag: &tag,
96        spm: &spm,
97        ns: &ns,
98        ident: &ident,
99        module_class: &module_class,
100        view_class: &view_class,
101    };
102
103    // Expo-style layout — platform code under `ios/` and `android/`,
104    // each openable directly in Xcode / Android Studio.
105    let ios_src = format!("ios/Sources/{spm}");
106    let android_src = format!("android/src/main/kotlin/rs/whisker/modules/{ns}");
107    std::fs::create_dir_all(target_dir.join(&ios_src))
108        .with_context(|| format!("create {}/{ios_src}", target_dir.display()))?;
109    std::fs::create_dir_all(target_dir.join(&android_src))
110        .with_context(|| format!("create {}/{android_src}", target_dir.display()))?;
111
112    write(&target_dir, "Cargo.toml", &cargo_toml(&v))?;
113    write(&target_dir, "README.md", &readme(&v))?;
114    write(&target_dir, "Package.swift", &package_swift(&v))?;
115    write(&target_dir, "build.gradle.kts", &build_gradle(&v))?;
116
117    match args.shape {
118        ModuleShape::ViewBearing => {
119            write(&target_dir, "src/lib.rs", &lib_rs_view(&v))?;
120            write(
121                &target_dir,
122                &format!("{ios_src}/{module_class}.swift"),
123                &swift_view_module(&v),
124            )?;
125            write(
126                &target_dir,
127                &format!("{ios_src}/{view_class}.swift"),
128                &swift_view(&v),
129            )?;
130            write(
131                &target_dir,
132                &format!("{android_src}/{module_class}.kt"),
133                &kotlin_view_module(&v),
134            )?;
135            write(
136                &target_dir,
137                &format!("{android_src}/{view_class}.kt"),
138                &kotlin_view(&v),
139            )?;
140        }
141        ModuleShape::FunctionOnly => {
142            write(&target_dir, "src/lib.rs", &lib_rs_module(&v))?;
143            write(
144                &target_dir,
145                &format!("{ios_src}/{module_class}.swift"),
146                &swift_function_module(&v),
147            )?;
148            write(
149                &target_dir,
150                &format!("{android_src}/{module_class}.kt"),
151                &kotlin_function_module(&v),
152            )?;
153        }
154    }
155
156    eprintln!(
157        "Created Whisker module skeleton at {}\n\
158         \n\
159         Next steps:\n  \
160         1. cd {}\n  \
161         2. Implement the platform-side logic in ios/ and android/.\n  \
162         3. From your Whisker app: `cargo add --path {}` (or publish to crates.io).\n  \
163         4. See https://whisker.rs/docs/authoring-a-module for the full reference.",
164        target_dir.display(),
165        target_dir.display(),
166        target_dir.display(),
167    );
168    Ok(())
169}
170
171// ============================================================================
172// Template variables + rendering
173// ============================================================================
174
175struct Vars<'a> {
176    /// Cargo crate name, e.g. `whisker-foo`.
177    crate_name: &'a str,
178    /// PascalCase local tag, e.g. `Foo`.
179    tag: &'a str,
180    /// SwiftPM target name == PascalCased full crate name, e.g.
181    /// `WhiskerFoo`.
182    spm: &'a str,
183    /// Android package leaf == crate name with `-` → `_`, e.g.
184    /// `whisker_foo`.
185    ns: &'a str,
186    /// Rust fn identifier == crate name minus the `whisker_` prefix,
187    /// e.g. `foo`.
188    ident: &'a str,
189    /// DSL module class, e.g. `FooModule`.
190    module_class: &'a str,
191    /// View-bearing Lynx UI subclass, e.g. `FooView`.
192    view_class: &'a str,
193}
194
195fn write(root: &Path, rel: &str, content: &str) -> Result<()> {
196    let path = root.join(rel);
197    if let Some(parent) = path.parent() {
198        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
199    }
200    std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
201    Ok(())
202}
203
204/// The `MAJOR.MINOR` version requirement the scaffolded crate should
205/// pin `whisker` to. Derived from whisker-cli's own (workspace-shared)
206/// version so a freshly-scaffolded module unifies with the toolchain
207/// that generated it — e.g. cli `0.2.5` → `"0.2"`. An app on `0.2.x`
208/// can't unify a module that asks for `^0.1`, so a hardcoded `"0.1"`
209/// would break every scaffold after the 0.2 bump.
210fn whisker_dep_version() -> String {
211    let v = env!("CARGO_PKG_VERSION");
212    let mut parts = v.split('.');
213    let major = parts.next().unwrap_or("0");
214    let minor = parts.next().unwrap_or("0");
215    format!("{major}.{minor}")
216}
217
218fn cargo_toml(v: &Vars) -> String {
219    format!(
220        r#"[package]
221name = "{name}"
222version = "0.1.0"
223edition = "2021"
224license = "MIT OR Apache-2.0"
225description = "Whisker module — short tagline shown on crates.io."
226
227include = [
228    "Cargo.toml",
229    "Package.swift",
230    "build.gradle.kts",
231    "src/lib.rs",
232    "android/**/*.kt",
233    "ios/**/*.swift",
234    "README.md",
235]
236
237[lib]
238crate-type = ["rlib"]
239
240# Module-system opt-in marker — the bare table identifies this cargo
241# crate as a Whisker module, so `whisker-build` wires its `android/`
242# Gradle subproject + `ios/` SwiftPM package into the host build.
243[package.metadata.whisker]
244
245[dependencies]
246# The umbrella `whisker` crate. The proc macros' emit paths
247# (::whisker::ElementRef, ::whisker::platform_module::WhiskerValue, ...)
248# resolve under the `whisker` name — the same dep app crates use.
249whisker = "{dep_version}"
250"#,
251        name = v.crate_name,
252        dep_version = whisker_dep_version(),
253    )
254}
255
256fn readme(v: &Vars) -> String {
257    format!(
258        r#"# {name}
259
260A Whisker module — registers the `{name}:{tag}` element under Lynx
261and exposes `{tag}` for use in Whisker app `render!` trees.
262
263## Usage
264
265```toml
266[dependencies]
267{name} = "{dep_version}"
268```
269
270```rust
271use whisker::prelude::*;
272use {ident}::*;
273
274#[whisker::main]
275fn app() -> Element {{
276    render! {{
277        {tag}()
278    }}
279}}
280```
281
282See [the Whisker Module Author Guide](https://whisker.rs/docs/authoring-a-module)
283for the full reference.
284"#,
285        name = v.crate_name,
286        tag = v.tag,
287        ident = v.ident,
288        dep_version = whisker_dep_version(),
289    )
290}
291
292fn package_swift(v: &Vars) -> String {
293    format!(
294        r#"// swift-tools-version:5.9
295//
296// SwiftPM manifest for the `{name}` module's iOS half. The consumer
297// app's `whisker-build`-generated aggregator depends on the library
298// product below via `.product(name: "{spm}", package: "{name}")`.
299//
300// Package.swift lives at the package root (SwiftPM requires it
301// there); the Swift sources live under the `ios/` subdir alongside
302// `android/` + `src/`.
303//
304// The module resolves Whisker's iOS runtime + macros via the published
305// `whisker` SwiftPM package (the same remote-git dependency every
306// first-party module uses) — `WhiskerModule` re-exports Lynx, and
307// `WhiskerRuntime` pulls in the `WhiskerView` / driver symbols. The
308// `WhiskerModuleCodegenPlugin` build-tool plugin walks `Module`
309// subclasses at build time and emits the Lynx registration.
310
311import PackageDescription
312
313let package = Package(
314    name: "{name}",
315    platforms: [.iOS(.v13), .macOS(.v13)],
316    products: [
317        .library(name: "{spm}", targets: ["{spm}"]),
318    ],
319    dependencies: [
320        .package(url: "https://github.com/whiskerrs/whisker.git", exact: "{ios_tag}"),
321    ],
322    targets: [
323        .target(
324            name: "{spm}",
325            dependencies: [
326                .product(name: "WhiskerModule", package: "whisker"),
327                .product(name: "WhiskerRuntime", package: "whisker"),
328            ],
329            path: "ios/Sources/{spm}",
330            plugins: [
331                .plugin(name: "WhiskerModuleCodegenPlugin", package: "whisker"),
332            ]
333        ),
334    ]
335)
336"#,
337        name = v.crate_name,
338        spm = v.spm,
339        ios_tag = WHISKER_IOS_SPM_TAG,
340    )
341}
342
343/// The exact iOS SwiftPM tag the scaffolded `Package.swift` pins for
344/// the `whisker` git dependency. This is the iOS SPM release tag, which
345/// is versioned independently from the cargo crate version — it must
346/// match whatever the first-party modules pin (see
347/// `packages/whisker-webview/Package.swift`, currently `exact: "0.1.0"`).
348const WHISKER_IOS_SPM_TAG: &str = "0.1.0";
349
350fn build_gradle(v: &Vars) -> String {
351    format!(
352        r#"// Gradle subproject for the `{name}` Whisker module on Android.
353// Wired into the consumer app's settings.gradle.kts by whisker-build.
354// build.gradle.kts sits at the package root, alongside Package.swift
355// + Cargo.toml; the Kotlin source set points at the `android/` subdir.
356
357plugins {{
358    id("com.android.library")
359    id("org.jetbrains.kotlin.android")
360    id("com.google.devtools.ksp") version "2.0.21-1.0.27"
361}}
362
363android {{
364    namespace = "rs.whisker.modules.{ns}"
365    compileSdk = 34
366
367    defaultConfig {{
368        minSdk = 21
369    }}
370
371    compileOptions {{
372        sourceCompatibility = JavaVersion.VERSION_17
373        targetCompatibility = JavaVersion.VERSION_17
374    }}
375    kotlinOptions {{
376        jvmTarget = "17"
377    }}
378
379    sourceSets {{
380        getByName("main") {{
381            kotlin.srcDirs("android/src/main/kotlin")
382        }}
383    }}
384}}
385
386ksp {{
387    arg("whisker.moduleName", "{spm}")
388    arg("whisker.crateName", "{name}")
389}}
390
391dependencies {{
392    // Published Whisker runtime + KSP processor — the same Maven
393    // coordinates every first-party module uses. ksp(rs.whisker:ksp)
394    // stays separate because it's a build-time processor, not on the
395    // runtime classpath. The KSP processor discovers Module subclasses
396    // by inheritance (no marker annotation needed). The `{android_tag}`
397    // tag is the Android (Maven) release, versioned independently from
398    // the cargo crate.
399    implementation("rs.whisker:whisker-module-android:{android_tag}")
400    ksp("rs.whisker:ksp:{android_tag}")
401}}
402"#,
403        name = v.crate_name,
404        ns = v.ns,
405        spm = v.spm,
406        android_tag = WHISKER_ANDROID_MAVEN_TAG,
407    )
408}
409
410/// The Maven release tag the scaffolded `build.gradle.kts` pins for the
411/// Whisker Android runtime + KSP processor. Like the iOS SPM tag, the
412/// Android Maven release is versioned independently from the cargo
413/// crate — must match first-party (see
414/// `packages/whisker-webview/build.gradle.kts`, currently `0.1.0`).
415const WHISKER_ANDROID_MAVEN_TAG: &str = "0.1.0";
416
417fn lib_rs_view(v: &Vars) -> String {
418    format!(
419        r#"//! `{name}` — Whisker view-bearing module.
420//!
421//! Registers a Lynx element under `{name}:{tag}` and exposes the
422//! `{tag}` symbol for use inside `render!`. Platform-side classes
423//! live under `ios/` and `android/`.
424
425use whisker::Signal;
426
427/// View-bearing element. The Lynx tag the bridge registers against
428/// is `{name}:{tag}` — the crate name namespace is auto-prepended by
429/// `#[whisker::module_component]`. Imperative methods on a mounted
430/// instance go through an `ElementRef` (the `ref:` prop) — wrap one
431/// in a typed `{tag}Handle` struct for the public API.
432#[whisker::module_component("{tag}")]
433pub fn {ident}(style: Signal<String>) {{}}
434"#,
435        name = v.crate_name,
436        tag = v.tag,
437        ident = v.ident,
438    )
439}
440
441fn lib_rs_module(v: &Vars) -> String {
442    format!(
443        r#"//! `{name}` — Whisker function-only platform module.
444//!
445//! Exposes typed Rust -> Kotlin/Swift function calls without
446//! rendering UI. Platform-side classes live under `ios/` and
447//! `android/`.
448
449use whisker::platform_module::{{WhiskerModuleError, WhiskerValue}};
450
451/// Typed Rust API for the `Whisker{tag}` platform module.
452///
453/// Hand-written wrapper over the framework primitive: each method
454/// builds the raw `Vec<WhiskerValue>` arg list, dispatches via
455/// `whisker::module!("Whisker{tag}").invoke(method, args)`, and lifts
456/// the returned `WhiskerValue` into a typed `Result`. The `module!`
457/// name MUST match the `Name("...")` in the platform-side
458/// `definition()`; `module!` auto-prepends this crate's name so two
459/// crates can ship same-named modules without colliding.
460pub struct Whisker{tag};
461impl Whisker{tag} {{
462    pub fn placeholder() -> Result<(), WhiskerModuleError> {{
463        // Build args, dispatch, lift the WhiskerValue into a typed result.
464        match whisker::module!("Whisker{tag}").invoke("_placeholder", vec![]) {{
465            WhiskerValue::Error(msg) => Err(WhiskerModuleError(msg)),
466            _ => Ok(()),
467        }}
468    }}
469}}
470"#,
471        name = v.crate_name,
472        tag = v.tag,
473    )
474}
475
476fn swift_view_module(v: &Vars) -> String {
477    format!(
478        r#"// `{module_class}` — iOS side of the `{name}:{tag}` Whisker module.
479//
480// Declares the Lynx element `{name}:{tag}` via the ModuleDefinition
481// DSL. Subclassing `Module` is the registration signal — the SwiftPM
482// codegen plugin walks every `Module` subclass and emits the Lynx
483// behavior registration. The `{view_class}` Lynx UI subclass lives
484// in `{view_class}.swift`.
485
486import WhiskerModule    // Module, ModuleDefinition, DSL
487
488public final class {module_class}: Module {{
489    public override func definition() -> ModuleDefinition {{
490        ModuleDefinition {{
491            Name("{tag}")
492            View({view_class}.self) {{
493                // Declare Prop / Function entries here, e.g.:
494                //   Prop("title") {{ (view: {view_class}, value: String) in
495                //       view.setTitle(value)
496                //   }}
497                //   Function("focus") {{ (view: {view_class}) in view.focus() }}
498            }}
499        }}
500    }}
501}}
502"#,
503        name = v.crate_name,
504        tag = v.tag,
505        module_class = v.module_class,
506        view_class = v.view_class,
507    )
508}
509
510fn swift_view(v: &Vars) -> String {
511    format!(
512        r#"// `{view_class}` — the Lynx UI subclass backing `{name}:{tag}`.
513// Instantiated by Lynx via the behavior `{module_class}.definition()`
514// registers. `@objc({view_class})` pins the Obj-C class name so the
515// codegen plugin's `NSClassFromString` lookup resolves it.
516
517import UIKit
518import WhiskerModule
519
520@objc({view_class})
521public final class {view_class}: WhiskerUI<UIView> {{
522    @objc public override func createView() -> UIView {{
523        let v = UIView()
524        v.backgroundColor = .systemPink
525        return v
526    }}
527}}
528"#,
529        name = v.crate_name,
530        tag = v.tag,
531        module_class = v.module_class,
532        view_class = v.view_class,
533    )
534}
535
536fn kotlin_view_module(v: &Vars) -> String {
537    format!(
538        r#"// `{module_class}` -- Android side of the `{name}:{tag}` Whisker module.
539//
540// Subclassing `Module` is the registration signal — the KSP processor
541// walks every concrete subclass and emits the Lynx behavior
542// registration. The `{view_class}` Lynx UI subclass lives in
543// `{view_class}.kt`.
544//
545// Note the explicit `import rs.whisker.runtime.Module` — without it
546// the unqualified `Module` resolves to `java.lang.Module` (a Kotlin
547// JVM default import).
548
549package rs.whisker.modules.{ns}
550
551import rs.whisker.runtime.Module
552import rs.whisker.runtime.ModuleDefinition
553
554class {module_class} : Module() {{
555    override fun definition() = ModuleDefinition {{
556        Name("{tag}")
557        View({view_class}::class.java) {{
558            // Declare Prop / Function entries here, e.g.:
559            //   Prop("title") {{ view: {view_class}, value: String ->
560            //       view.setTitle(value)
561            //   }}
562            //   Function("focus") {{ view: {view_class} -> view.focus() }}
563        }}
564    }}
565}}
566"#,
567        name = v.crate_name,
568        tag = v.tag,
569        ns = v.ns,
570        module_class = v.module_class,
571        view_class = v.view_class,
572    )
573}
574
575fn kotlin_view(v: &Vars) -> String {
576    format!(
577        r#"// `{view_class}` -- the Lynx UI subclass backing `{name}:{tag}`.
578// Instantiated by the Lynx behavior `{module_class}.definition()`
579// registers. The single-arg `(WhiskerContext)` constructor matches
580// the convention the KSP registration code expects.
581
582package rs.whisker.modules.{ns}
583
584import android.content.Context
585import android.graphics.Color
586import android.view.View
587import rs.whisker.runtime.WhiskerContext
588import rs.whisker.runtime.WhiskerUI
589
590open class {view_class}(context: WhiskerContext) : WhiskerUI<View>(context) {{
591    override fun createView(context: Context): View {{
592        val v = View(context)
593        v.setBackgroundColor(Color.argb(0xff, 0xff, 0x80, 0xa0))
594        return v
595    }}
596}}
597"#,
598        name = v.crate_name,
599        tag = v.tag,
600        ns = v.ns,
601        module_class = v.module_class,
602        view_class = v.view_class,
603    )
604}
605
606fn swift_function_module(v: &Vars) -> String {
607    format!(
608        r#"// `{module_class}` — iOS side of the `{name}` Whisker function-only module.
609//
610// A view-less DSL module: `definition()` has no `View(...)` block,
611// just module-level `Function`s. Subclassing `Module` is the
612// registration signal — the SwiftPM codegen plugin emits a dispatch
613// shim registered under the `Name("...")`, so
614// `Whisker{tag}::placeholder()` on the Rust side routes here.
615
616import WhiskerModule    // Module, ModuleDefinition, DSL
617
618public final class {module_class}: Module {{
619    public override func definition() -> ModuleDefinition {{
620        ModuleDefinition {{
621            // The Name MUST match the Rust sys trait's
622            // `#[whisker::platform_module(name = "...")]`.
623            Name("Whisker{tag}")
624            Function("_placeholder") {{
625                // TODO: implement the function the Rust sys trait declares.
626            }}
627        }}
628    }}
629}}
630"#,
631        name = v.crate_name,
632        tag = v.tag,
633        module_class = v.module_class,
634    )
635}
636
637fn kotlin_function_module(v: &Vars) -> String {
638    format!(
639        r#"// `{module_class}` -- Android side of the `{name}` Whisker function-only module.
640//
641// A view-less DSL module: module-level `Function`s, no `View(...)`.
642// Subclassing `Module` is the registration signal. See the note in
643// the view-bearing template re: the explicit `Module` import.
644
645package rs.whisker.modules.{ns}
646
647import rs.whisker.runtime.Module
648import rs.whisker.runtime.ModuleDefinition
649
650class {module_class} : Module() {{
651    override fun definition() = ModuleDefinition {{
652        // The Name MUST match the Rust sys trait's
653        // `#[whisker::platform_module(name = "...")]`.
654        Name("Whisker{tag}")
655        Function("_placeholder") {{
656            // TODO: implement the function the Rust sys trait declares.
657        }}
658    }}
659}}
660"#,
661        name = v.crate_name,
662        tag = v.tag,
663        ns = v.ns,
664        module_class = v.module_class,
665    )
666}
667
668// ============================================================================
669// Name helpers
670// ============================================================================
671
672/// Validate a cargo crate name. Rejects empty / non-letter-prefixed /
673/// non-`[a-z0-9_-]+` inputs with an actionable message.
674fn validate_crate_name(name: &str) -> Result<()> {
675    if name.is_empty() {
676        bail!("crate name must not be empty");
677    }
678    let first = name.chars().next().unwrap();
679    if !first.is_ascii_alphabetic() {
680        bail!(
681            "crate name must start with a letter, got {first:?}. Try \
682             `whisker-{name}` instead."
683        );
684    }
685    for ch in name.chars() {
686        if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
687            return Err(anyhow!(
688                "crate name {name:?} contains invalid character {ch:?}. \
689                 Use only ASCII letters / digits / `-` / `_`."
690            ));
691        }
692    }
693    Ok(())
694}
695
696/// Derive the PascalCase tag from the crate name.
697///
698/// - `whisker-foo` -> `Foo`
699/// - `whisker-blur-view` -> `BlurView`
700/// - `foo-bar` -> `FooBar` (no `whisker-` prefix → tag is the whole name)
701fn pascal_case_tag(crate_name: &str) -> String {
702    let stripped = crate_name.strip_prefix("whisker-").unwrap_or(crate_name);
703    let mut out = String::new();
704    let mut upper = true;
705    for ch in stripped.chars() {
706        if ch == '-' || ch == '_' {
707            upper = true;
708        } else if upper {
709            out.extend(ch.to_uppercase());
710            upper = false;
711        } else {
712            out.push(ch);
713        }
714    }
715    out
716}
717
718/// Same convention as `whisker_build::ios::crate_to_spm_target`:
719/// `whisker-foo-bar` -> `WhiskerFooBar`.
720fn crate_to_spm_target(crate_name: &str) -> String {
721    let mut out = String::new();
722    let mut upper = true;
723    for ch in crate_name.chars() {
724        if ch == '-' || ch == '_' {
725            upper = true;
726        } else if upper {
727            out.extend(ch.to_uppercase());
728            upper = false;
729        } else {
730            out.push(ch);
731        }
732    }
733    out
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn pascal_strips_whisker_prefix() {
742        assert_eq!(pascal_case_tag("whisker-foo"), "Foo");
743        assert_eq!(pascal_case_tag("whisker-blur-view"), "BlurView");
744    }
745
746    #[test]
747    fn pascal_keeps_full_name_when_no_whisker_prefix() {
748        assert_eq!(pascal_case_tag("foo-bar"), "FooBar");
749    }
750
751    #[test]
752    fn spm_target_pascals_full_crate_name() {
753        assert_eq!(crate_to_spm_target("whisker-foo"), "WhiskerFoo");
754        assert_eq!(crate_to_spm_target("whisker-blur-view"), "WhiskerBlurView");
755    }
756
757    #[test]
758    fn validate_rejects_invalid() {
759        assert!(validate_crate_name("").is_err());
760        assert!(validate_crate_name("1foo").is_err());
761        assert!(validate_crate_name("whisker foo").is_err());
762        assert!(validate_crate_name("whisker-foo").is_ok());
763        assert!(validate_crate_name("whisker_foo").is_ok());
764    }
765}