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 in `docs/module-author-guide.md`.
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 docs/module-author-guide.md 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
204fn cargo_toml(v: &Vars) -> String {
205    format!(
206        r#"[package]
207name = "{name}"
208version = "0.1.0"
209edition = "2021"
210license = "MIT OR Apache-2.0"
211description = "Whisker module — short tagline shown on crates.io."
212
213include = [
214    "Cargo.toml",
215    "Package.swift",
216    "build.gradle.kts",
217    "src/lib.rs",
218    "android/**/*.kt",
219    "ios/**/*.swift",
220    "README.md",
221]
222
223[lib]
224crate-type = ["rlib"]
225
226# Module-system opt-in marker — the bare table identifies this cargo
227# crate as a Whisker module, so `whisker-build` wires its `android/`
228# Gradle subproject + `ios/` SwiftPM package into the host build.
229[package.metadata.whisker]
230
231[dependencies]
232# The umbrella `whisker` crate. The proc macros' emit paths
233# (::whisker::ElementRef, ::whisker::platform_module::WhiskerValue, ...)
234# resolve under the `whisker` name — the same dep app crates use.
235whisker = "0.1"
236"#,
237        name = v.crate_name,
238    )
239}
240
241fn readme(v: &Vars) -> String {
242    format!(
243        r#"# {name}
244
245A Whisker module — registers the `{name}:{tag}` element under Lynx
246and exposes `{tag}` for use in Whisker app `render!` trees.
247
248## Usage
249
250```toml
251[dependencies]
252{name} = "0.1"
253```
254
255```rust
256use whisker::prelude::*;
257use {ident}::*;
258
259#[whisker::main]
260fn app() -> Element {{
261    render! {{
262        {tag}()
263    }}
264}}
265```
266
267See [the Whisker Module Author Guide](https://github.com/whiskerrs/whisker/blob/main/docs/module-author-guide.md)
268for the full reference.
269"#,
270        name = v.crate_name,
271        tag = v.tag,
272        ident = v.ident,
273    )
274}
275
276fn package_swift(v: &Vars) -> String {
277    format!(
278        r#"// swift-tools-version:5.9
279//
280// SwiftPM manifest for the `{name}` module's iOS half. The consumer
281// app's `whisker-build`-generated aggregator depends on the library
282// product below via `.product(name: "{spm}", package: "{name}")`.
283//
284// Package.swift lives at the package root (SwiftPM requires it
285// there); the Swift sources live under the `ios/` subdir alongside
286// `android/` + `src/`.
287
288import PackageDescription
289
290// whisker-build injects the absolute location of Whisker's iOS
291// runtime + macros packages via these env vars, so this module
292// resolves them wherever it lives — in a whisker project, or unpacked
293// from the cargo registry. A Whisker module is only ever built through
294// `whisker run` (which set these), never standalone.
295guard let whiskerRuntimePath = Context.environment["WHISKER_IOS_RUNTIME"],
296      let whiskerMacrosPath = Context.environment["WHISKER_IOS_MACROS"]
297else {{
298    fatalError("""
299        WHISKER_IOS_RUNTIME / WHISKER_IOS_MACROS not set. Build this Whisker \
300        module through `whisker run`, which inject these paths.
301        """)
302}}
303
304let package = Package(
305    name: "{name}",
306    platforms: [.iOS(.v13), .macOS(.v13)],
307    products: [
308        .library(name: "{spm}", targets: ["{spm}"]),
309    ],
310    dependencies: [
311        .package(name: "macros", path: whiskerMacrosPath),
312        .package(name: "WhiskerRuntime", path: whiskerRuntimePath),
313    ],
314    targets: [
315        .target(
316            name: "{spm}",
317            dependencies: [
318                .product(name: "WhiskerModule", package: "WhiskerRuntime"),
319            ],
320            path: "ios/Sources/{spm}",
321            plugins: [
322                .plugin(name: "WhiskerModuleCodegenPlugin", package: "macros"),
323            ]
324        ),
325    ]
326)
327"#,
328        name = v.crate_name,
329        spm = v.spm,
330    )
331}
332
333fn build_gradle(v: &Vars) -> String {
334    format!(
335        r#"// Gradle subproject for the `{name}` Whisker module on Android.
336// Wired into the consumer app's settings.gradle.kts by whisker-build.
337// build.gradle.kts sits at the package root, alongside Package.swift
338// + Cargo.toml; the Kotlin source set points at the `android/` subdir.
339
340plugins {{
341    id("com.android.library")
342    id("org.jetbrains.kotlin.android")
343    id("com.google.devtools.ksp") version "2.0.21-1.0.27"
344}}
345
346android {{
347    namespace = "rs.whisker.modules.{ns}"
348    compileSdk = 34
349
350    defaultConfig {{
351        minSdk = 21
352    }}
353
354    compileOptions {{
355        sourceCompatibility = JavaVersion.VERSION_17
356        targetCompatibility = JavaVersion.VERSION_17
357    }}
358    kotlinOptions {{
359        jvmTarget = "17"
360    }}
361
362    sourceSets {{
363        getByName("main") {{
364            kotlin.srcDirs("android/src/main/kotlin")
365        }}
366    }}
367}}
368
369ksp {{
370    arg("whisker.moduleName", "{spm}")
371    arg("whisker.crateName", "{name}")
372}}
373
374dependencies {{
375    // Single Whisker runtime dep. ksp(rs.whisker:ksp) stays
376    // separate because it's a build-time processor, not on the
377    // runtime classpath. The KSP processor discovers Module
378    // subclasses by inheritance (no marker annotation needed).
379    implementation(project(":module"))
380    ksp("rs.whisker:ksp")
381}}
382"#,
383        name = v.crate_name,
384        ns = v.ns,
385        spm = v.spm,
386    )
387}
388
389fn lib_rs_view(v: &Vars) -> String {
390    format!(
391        r#"//! `{name}` — Whisker view-bearing module.
392//!
393//! Registers a Lynx element under `{name}:{tag}` and exposes the
394//! `{tag}` symbol for use inside `render!`. Platform-side classes
395//! live under `ios/` and `android/`.
396
397use whisker::Signal;
398
399/// View-bearing element. The Lynx tag the bridge registers against
400/// is `{name}:{tag}` — the crate name namespace is auto-prepended by
401/// `#[whisker::module_component]`. Imperative methods on a mounted
402/// instance go through an `ElementRef` (the `ref:` prop) — wrap one
403/// in a typed `{tag}Handle` struct for the public API.
404#[whisker::module_component("{tag}")]
405pub fn {ident}(style: Signal<String>) {{}}
406"#,
407        name = v.crate_name,
408        tag = v.tag,
409        ident = v.ident,
410    )
411}
412
413fn lib_rs_module(v: &Vars) -> String {
414    format!(
415        r#"//! `{name}` — Whisker function-only platform module.
416//!
417//! Exposes typed Rust -> Kotlin/Swift function calls without
418//! rendering UI. Platform-side classes live under `ios/` and
419//! `android/`.
420
421use whisker::platform_module::{{WhiskerModuleError, WhiskerValue}};
422
423/// Typed Rust API for the `Whisker{tag}` platform module.
424///
425/// Hand-written wrapper over the framework primitive: each method
426/// builds the raw `Vec<WhiskerValue>` arg list, dispatches via
427/// `whisker::module!("Whisker{tag}").invoke(method, args)`, and lifts
428/// the returned `WhiskerValue` into a typed `Result`. The `module!`
429/// name MUST match the `Name("...")` in the platform-side
430/// `definition()`; `module!` auto-prepends this crate's name so two
431/// crates can ship same-named modules without colliding.
432pub struct Whisker{tag};
433impl Whisker{tag} {{
434    pub fn placeholder() -> Result<(), WhiskerModuleError> {{
435        // Build args, dispatch, lift the WhiskerValue into a typed result.
436        match whisker::module!("Whisker{tag}").invoke("_placeholder", vec![]) {{
437            WhiskerValue::Error(msg) => Err(WhiskerModuleError(msg)),
438            _ => Ok(()),
439        }}
440    }}
441}}
442"#,
443        name = v.crate_name,
444        tag = v.tag,
445    )
446}
447
448fn swift_view_module(v: &Vars) -> String {
449    format!(
450        r#"// `{module_class}` — iOS side of the `{name}:{tag}` Whisker module.
451//
452// Declares the Lynx element `{name}:{tag}` via the ModuleDefinition
453// DSL. Subclassing `Module` is the registration signal — the SwiftPM
454// codegen plugin walks every `Module` subclass and emits the Lynx
455// behavior registration. The `{view_class}` Lynx UI subclass lives
456// in `{view_class}.swift`.
457
458import WhiskerModule    // Module, ModuleDefinition, DSL
459
460public final class {module_class}: Module {{
461    public override func definition() -> ModuleDefinition {{
462        ModuleDefinition {{
463            Name("{tag}")
464            View({view_class}.self) {{
465                // Declare Prop / Function entries here, e.g.:
466                //   Prop("title") {{ (view: {view_class}, value: String) in
467                //       view.setTitle(value)
468                //   }}
469                //   Function("focus") {{ (view: {view_class}) in view.focus() }}
470            }}
471        }}
472    }}
473}}
474"#,
475        name = v.crate_name,
476        tag = v.tag,
477        module_class = v.module_class,
478        view_class = v.view_class,
479    )
480}
481
482fn swift_view(v: &Vars) -> String {
483    format!(
484        r#"// `{view_class}` — the Lynx UI subclass backing `{name}:{tag}`.
485// Instantiated by Lynx via the behavior `{module_class}.definition()`
486// registers. `@objc({view_class})` pins the Obj-C class name so the
487// codegen plugin's `NSClassFromString` lookup resolves it.
488
489import UIKit
490import WhiskerModule
491
492@objc({view_class})
493public final class {view_class}: WhiskerUI<UIView> {{
494    @objc public override func createView() -> UIView {{
495        let v = UIView()
496        v.backgroundColor = .systemPink
497        return v
498    }}
499}}
500"#,
501        name = v.crate_name,
502        tag = v.tag,
503        module_class = v.module_class,
504        view_class = v.view_class,
505    )
506}
507
508fn kotlin_view_module(v: &Vars) -> String {
509    format!(
510        r#"// `{module_class}` -- Android side of the `{name}:{tag}` Whisker module.
511//
512// Subclassing `Module` is the registration signal — the KSP processor
513// walks every concrete subclass and emits the Lynx behavior
514// registration. The `{view_class}` Lynx UI subclass lives in
515// `{view_class}.kt`.
516//
517// Note the explicit `import rs.whisker.runtime.Module` — without it
518// the unqualified `Module` resolves to `java.lang.Module` (a Kotlin
519// JVM default import).
520
521package rs.whisker.modules.{ns}
522
523import rs.whisker.runtime.Module
524import rs.whisker.runtime.ModuleDefinition
525
526class {module_class} : Module() {{
527    override fun definition() = ModuleDefinition {{
528        Name("{tag}")
529        View({view_class}::class.java) {{
530            // Declare Prop / Function entries here, e.g.:
531            //   Prop("title") {{ view: {view_class}, value: String ->
532            //       view.setTitle(value)
533            //   }}
534            //   Function("focus") {{ view: {view_class} -> view.focus() }}
535        }}
536    }}
537}}
538"#,
539        name = v.crate_name,
540        tag = v.tag,
541        ns = v.ns,
542        module_class = v.module_class,
543        view_class = v.view_class,
544    )
545}
546
547fn kotlin_view(v: &Vars) -> String {
548    format!(
549        r#"// `{view_class}` -- the Lynx UI subclass backing `{name}:{tag}`.
550// Instantiated by the Lynx behavior `{module_class}.definition()`
551// registers. The single-arg `(WhiskerContext)` constructor matches
552// the convention the KSP registration code expects.
553
554package rs.whisker.modules.{ns}
555
556import android.content.Context
557import android.graphics.Color
558import android.view.View
559import rs.whisker.runtime.WhiskerContext
560import rs.whisker.runtime.WhiskerUI
561
562open class {view_class}(context: WhiskerContext) : WhiskerUI<View>(context) {{
563    override fun createView(context: Context): View {{
564        val v = View(context)
565        v.setBackgroundColor(Color.argb(0xff, 0xff, 0x80, 0xa0))
566        return v
567    }}
568}}
569"#,
570        name = v.crate_name,
571        tag = v.tag,
572        ns = v.ns,
573        module_class = v.module_class,
574        view_class = v.view_class,
575    )
576}
577
578fn swift_function_module(v: &Vars) -> String {
579    format!(
580        r#"// `{module_class}` — iOS side of the `{name}` Whisker function-only module.
581//
582// A view-less DSL module: `definition()` has no `View(...)` block,
583// just module-level `Function`s. Subclassing `Module` is the
584// registration signal — the SwiftPM codegen plugin emits a dispatch
585// shim registered under the `Name("...")`, so
586// `Whisker{tag}::placeholder()` on the Rust side routes here.
587
588import WhiskerModule    // Module, ModuleDefinition, DSL
589
590public final class {module_class}: Module {{
591    public override func definition() -> ModuleDefinition {{
592        ModuleDefinition {{
593            // The Name MUST match the Rust sys trait's
594            // `#[whisker::platform_module(name = "...")]`.
595            Name("Whisker{tag}")
596            Function("_placeholder") {{
597                // TODO: implement the function the Rust sys trait declares.
598            }}
599        }}
600    }}
601}}
602"#,
603        name = v.crate_name,
604        tag = v.tag,
605        module_class = v.module_class,
606    )
607}
608
609fn kotlin_function_module(v: &Vars) -> String {
610    format!(
611        r#"// `{module_class}` -- Android side of the `{name}` Whisker function-only module.
612//
613// A view-less DSL module: module-level `Function`s, no `View(...)`.
614// Subclassing `Module` is the registration signal. See the note in
615// the view-bearing template re: the explicit `Module` import.
616
617package rs.whisker.modules.{ns}
618
619import rs.whisker.runtime.Module
620import rs.whisker.runtime.ModuleDefinition
621
622class {module_class} : Module() {{
623    override fun definition() = ModuleDefinition {{
624        // The Name MUST match the Rust sys trait's
625        // `#[whisker::platform_module(name = "...")]`.
626        Name("Whisker{tag}")
627        Function("_placeholder") {{
628            // TODO: implement the function the Rust sys trait declares.
629        }}
630    }}
631}}
632"#,
633        name = v.crate_name,
634        tag = v.tag,
635        ns = v.ns,
636        module_class = v.module_class,
637    )
638}
639
640// ============================================================================
641// Name helpers
642// ============================================================================
643
644/// Validate a cargo crate name. Rejects empty / non-letter-prefixed /
645/// non-`[a-z0-9_-]+` inputs with an actionable message.
646fn validate_crate_name(name: &str) -> Result<()> {
647    if name.is_empty() {
648        bail!("crate name must not be empty");
649    }
650    let first = name.chars().next().unwrap();
651    if !first.is_ascii_alphabetic() {
652        bail!(
653            "crate name must start with a letter, got {first:?}. Try \
654             `whisker-{name}` instead."
655        );
656    }
657    for ch in name.chars() {
658        if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
659            return Err(anyhow!(
660                "crate name {name:?} contains invalid character {ch:?}. \
661                 Use only ASCII letters / digits / `-` / `_`."
662            ));
663        }
664    }
665    Ok(())
666}
667
668/// Derive the PascalCase tag from the crate name.
669///
670/// - `whisker-foo` -> `Foo`
671/// - `whisker-blur-view` -> `BlurView`
672/// - `foo-bar` -> `FooBar` (no `whisker-` prefix → tag is the whole name)
673fn pascal_case_tag(crate_name: &str) -> String {
674    let stripped = crate_name.strip_prefix("whisker-").unwrap_or(crate_name);
675    let mut out = String::new();
676    let mut upper = true;
677    for ch in stripped.chars() {
678        if ch == '-' || ch == '_' {
679            upper = true;
680        } else if upper {
681            out.extend(ch.to_uppercase());
682            upper = false;
683        } else {
684            out.push(ch);
685        }
686    }
687    out
688}
689
690/// Same convention as `whisker_build::ios::crate_to_spm_target`:
691/// `whisker-foo-bar` -> `WhiskerFooBar`.
692fn crate_to_spm_target(crate_name: &str) -> String {
693    let mut out = String::new();
694    let mut upper = true;
695    for ch in crate_name.chars() {
696        if ch == '-' || ch == '_' {
697            upper = true;
698        } else if upper {
699            out.extend(ch.to_uppercase());
700            upper = false;
701        } else {
702            out.push(ch);
703        }
704    }
705    out
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn pascal_strips_whisker_prefix() {
714        assert_eq!(pascal_case_tag("whisker-foo"), "Foo");
715        assert_eq!(pascal_case_tag("whisker-blur-view"), "BlurView");
716    }
717
718    #[test]
719    fn pascal_keeps_full_name_when_no_whisker_prefix() {
720        assert_eq!(pascal_case_tag("foo-bar"), "FooBar");
721    }
722
723    #[test]
724    fn spm_target_pascals_full_crate_name() {
725        assert_eq!(crate_to_spm_target("whisker-foo"), "WhiskerFoo");
726        assert_eq!(crate_to_spm_target("whisker-blur-view"), "WhiskerBlurView");
727    }
728
729    #[test]
730    fn validate_rejects_invalid() {
731        assert!(validate_crate_name("").is_err());
732        assert!(validate_crate_name("1foo").is_err());
733        assert!(validate_crate_name("whisker foo").is_err());
734        assert!(validate_crate_name("whisker-foo").is_ok());
735        assert!(validate_crate_name("whisker_foo").is_ok());
736    }
737}