Skip to main content

umbral_cli/
scaffold.rs

1//! Project + plugin scaffolding.
2//!
3//! Two functions:
4//!
5//! - [`scaffold_project`] writes a complete new project directory.
6//!   Maps to `umbral startproject <name>`.
7//! - [`scaffold_app`] writes a new plugin crate at
8//!   `plugins/<name>/`. Maps to `umbral startapp <name>`.
9//!
10//! Both are pure: take a target path and the new name, write files,
11//! return what was written. The binary's `main.rs` wraps them with
12//! CLI parsing + a stdout report.
13
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18use umbral_casing::pascal_case_from_ident;
19
20/// Error type for scaffolding operations. Wraps I/O and validation
21/// failures with enough context for a user-facing message.
22#[derive(Debug)]
23pub enum ScaffoldError {
24    /// The user-provided name isn't valid as a Rust crate / package
25    /// identifier (must be ASCII alphanumeric or underscore/hyphen,
26    /// can't start with a digit).
27    InvalidName(String),
28    /// The target directory already exists. We never overwrite —
29    /// users move it aside or pick a different name.
30    AlreadyExists(PathBuf),
31    /// The chosen name collides with a built-in plugin name shipped
32    /// by umbral. Both crates would compile, but the user would never
33    /// be able to register both `.plugin(<their app>)` and
34    /// `.plugin(<built-in>)` without an alias dance, and route /
35    /// table-name collisions would land at boot. We reject the name
36    /// up front to prevent this confusion.
37    ReservedName(String),
38    /// I/O failure during file creation.
39    Io(io::Error),
40}
41
42/// Built-in plugin names that `umbral startapp` refuses to scaffold over.
43/// Adding a new built-in plugin? Add its name here so future
44/// `startapp <name>` calls fail fast with a clear message.
45pub const RESERVED_PLUGIN_NAMES: &[&str] = &[
46    "admin",
47    "app",
48    "auth",
49    "cache",
50    "email",
51    "openapi",
52    "permissions",
53    "rest",
54    "rls",
55    "security",
56    "sessions",
57    "signals",
58    "static",
59    "tasks",
60];
61
62impl std::fmt::Display for ScaffoldError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::InvalidName(s) => write!(
66                f,
67                "invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, not starting with a digit",
68            ),
69            Self::AlreadyExists(p) => write!(
70                f,
71                "an app already exists at `{}`; move it aside or pick a different name",
72                p.display()
73            ),
74            Self::ReservedName(s) => write!(
75                f,
76                "`{s}` is the name of a built-in umbral plugin; pick a different name to avoid conflicts at registration time. Reserved names: {}.",
77                RESERVED_PLUGIN_NAMES.join(", ")
78            ),
79            Self::Io(e) => write!(f, "{e}"),
80        }
81    }
82}
83
84impl std::error::Error for ScaffoldError {}
85
86impl From<io::Error> for ScaffoldError {
87    fn from(e: io::Error) -> Self {
88        Self::Io(e)
89    }
90}
91
92/// Report returned by both scaffolding functions: the paths written,
93/// so the binary can print them to the user.
94#[derive(Debug, Clone)]
95pub struct ScaffoldReport {
96    /// Root directory the scaffold landed in (project dir, or
97    /// `plugins/<name>/`).
98    pub root: PathBuf,
99    /// All files written, relative to `root`.
100    pub files: Vec<PathBuf>,
101    /// Post-scaffold instructions for the user. The binary prints
102    /// these after the file list.
103    pub next_steps: Vec<String>,
104    /// Whether the project's `Cargo.toml` was updated to include the
105    /// new plugin as a path dependency. `None` means the operation
106    /// wasn't attempted (e.g. `scaffold_project` doesn't auto-register).
107    /// `Some(true)` = dep added, `Some(false)` = dep already present
108    /// (idempotent — no duplicate written).
109    pub cargo_toml_registered: Option<bool>,
110}
111
112/// Validate a name is acceptable as a Rust crate identifier.
113///
114/// Rules: ASCII alphanumeric + `_` + `-`, can't start with a digit,
115/// can't be empty. Same rules `cargo new` uses. Crates with hyphens
116/// have to use `_` in their Rust identifiers, but Cargo handles the
117/// translation transparently — the user can pick either form.
118fn validate_name(name: &str) -> Result<(), ScaffoldError> {
119    if name.is_empty() {
120        return Err(ScaffoldError::InvalidName(String::new()));
121    }
122    let first = name.chars().next().unwrap();
123    if first.is_ascii_digit() {
124        return Err(ScaffoldError::InvalidName(name.to_string()));
125    }
126    if !name
127        .chars()
128        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
129    {
130        return Err(ScaffoldError::InvalidName(name.to_string()));
131    }
132    Ok(())
133}
134
135// `pascal_case` replaced by `umbral_casing::pascal_case_from_ident` (imported
136// above) in the gaps2 #77 consolidation refactor.
137
138/// Convert a name to its Rust identifier form (hyphens → underscores).
139/// Rewrite git-deps to path-deps anchored at `umbral_repo`. Closes
140/// BUG-17 in `bugs/tests/testBugs.md` — `umbral startproject --local
141/// /path/to/umbral foo` now produces a `Cargo.toml` that path-deps
142/// every umbral crate against the local checkout instead of the
143/// published crates.io version. Comments + commented-out optional
144/// plugin lines all flow through; any trailing descriptive comment
145/// after the dependency spec is preserved.
146///
147/// Subdirectory mapping mirrors the umbral repo layout: facade
148/// crates (`umbral`, `umbral-cli`, `umbral-core`, `umbral-macros`,
149/// `umbral-testing`) live under `crates/`; everything else
150/// (`umbral-auth`, `umbral-sessions`, `umbral-admin`, …) lives
151/// under `plugins/`.
152pub(crate) fn localize_deps(text: &str, umbral_repo: &Path) -> String {
153    let repo_str = umbral_repo.display().to_string();
154    let mut out = String::with_capacity(text.len());
155    for line in text.split_inclusive('\n') {
156        out.push_str(&rewrite_line(line, &repo_str));
157    }
158    out
159}
160
161/// Rewrite one `Cargo.toml` line: if it declares an umbral dependency
162/// (`umbral-xxx = "<version>"` or `umbral-xxx = { ... }`, optionally
163/// commented out with a leading `#`), replace the dependency spec with a
164/// local `{ path = "<repo>/<subdir>/<crate>" }`. Any other line is
165/// returned unchanged, including the otel example comment whose left
166/// side is prose, not a bare crate name.
167fn rewrite_line(line: &str, repo: &str) -> String {
168    // Find the LHS crate name. Strip a leading `#` (commented-out
169    // optional plugins) and whitespace, then take the substring up to
170    // the first `=`.
171    let body_start = line
172        .char_indices()
173        .find(|(_, c)| !matches!(*c, '#' | ' ' | '\t'))
174        .map(|(i, _)| i)
175        .unwrap_or(0);
176    let body = &line[body_start..];
177    let Some(eq_idx) = body.find('=') else {
178        return line.to_string();
179    };
180    let crate_name = body[..eq_idx].trim();
181    // Only bare umbral crate names get localized (skips prose comments
182    // like the otel example, whose LHS contains spaces/backticks).
183    if !crate_name.starts_with("umbral") || crate_name.contains(|c: char| c.is_whitespace()) {
184        return line.to_string();
185    }
186    // The dependency spec follows `=`: either a version string
187    // (`"0.0.1"`) or an inline table (`{ ... }`). Find where it ends so
188    // any trailing descriptive `# comment` survives verbatim.
189    let after_eq = &body[eq_idx + 1..];
190    let spec_offset = after_eq.len() - after_eq.trim_start().len();
191    let spec = after_eq.trim_start();
192    let spec_len = if let Some(rest) = spec.strip_prefix('"') {
193        match rest.find('"') {
194            Some(i) => 1 + i + 1,
195            None => return line.to_string(),
196        }
197    } else if spec.starts_with('{') {
198        match spec.find('}') {
199            Some(i) => i + 1,
200            None => return line.to_string(),
201        }
202    } else {
203        return line.to_string();
204    };
205    let spec_start = body_start + eq_idx + 1 + spec_offset;
206    let spec_end = spec_start + spec_len;
207    let subdir = match crate_name {
208        "umbral" | "umbral-cli" | "umbral-core" | "umbral-macros" | "umbral-testing" => "crates",
209        _ => "plugins",
210    };
211    let path = format!("{repo}/{subdir}/{crate_name}");
212    let prefix = &line[..spec_start];
213    let suffix = &line[spec_end..];
214    format!("{prefix}{{ path = \"{path}\" }}{suffix}")
215}
216
217fn rust_ident(name: &str) -> String {
218    name.replace('-', "_")
219}
220
221/// A random 64-hex-char dev secret key, unique per scaffold (audit_2
222/// macros-cli #7). Replaces the old shared `umbral-insecure-dev-key-change-me`
223/// literal so two scaffolded projects never share a key. Dev-only — production
224/// still requires a real key (the boot guard rejects a default/dev key under
225/// `environment = "Prod"`). Entropy comes from the OS-seeded `RandomState`; a
226/// crypto dependency isn't warranted for a dev-only, prod-boot-guarded value.
227fn random_dev_secret_key() -> String {
228    use std::hash::{BuildHasher, Hasher};
229    // Each `RandomState::new()` pulls a fresh OS-seeded random state, so the
230    // key differs across scaffold runs. Fold four seeded hashes into 64 hex
231    // chars (256 bits of key material).
232    let seed = std::collections::hash_map::RandomState::new();
233    let mut out = String::with_capacity(64);
234    for i in 0..4u64 {
235        let mut h = seed.build_hasher();
236        h.write_u64(i);
237        h.write_u64(i.wrapping_mul(0x9E37_79B9_7F4A_7C15));
238        out.push_str(&format!("{:016x}", h.finish()));
239    }
240    out
241}
242
243/// Write a new umbral project at `parent_dir/<name>/`.
244///
245/// The generated layout is a complete blog-style demo that exercises every
246/// major umbral surface: models with FK, migrations on boot, auth + sessions,
247/// `login_required`, REST with filters, admin, templates, transactions, and
248/// custom error pages.
249///
250/// The layout follows the per-concern convention we landed on in
251/// `examples/shop` (gaps2 #8): `main.rs` reads like a table of contents
252/// and every subsystem lives behind a `mod.rs` re-export/orchestrator
253/// layer, so the project opens to something that scales past 1000 lines.
254///
255/// ```text
256/// <name>/
257/// ├── Cargo.toml
258/// ├── umbral.toml
259/// ├── .env
260/// ├── .env.example
261/// ├── .gitignore
262/// ├── README.md
263/// ├── src/
264/// │   ├── main.rs           # App builder + route table + boot helpers
265/// │   ├── views/
266/// │   │   ├── mod.rs        # re-export layer + shared `internal_error`
267/// │   │   └── public.rs     # public/unauth handlers
268/// │   ├── seed/
269/// │   │   ├── mod.rs        # `all()` orchestrator (pins dependency order)
270/// │   │   └── credentials.rs# idempotent dev-superuser seed
271/// │   └── widgets/
272/// │       ├── mod.rs        # per-kind re-export layer
273/// │       └── cards.rs      # one builtin admin dashboard widget
274/// ├── plugins/
275/// │   ├── .gitkeep          # local app plugins land here (umbral startapp)
276/// │   └── README.md
277/// └── templates/
278///     ├── base.html
279///     ├── home.html
280///     ├── dashboard.html
281///     ├── 404.html
282///     └── 500.html
283/// ```
284///
285/// `main.rs` wires `umbral_cli::dispatch(app)` so the project's binary
286/// hosts the management commands. These directories are a *recommended*
287/// convention, not a requirement — the runtime reads `main.rs` directly
288/// and doesn't care whether handlers live in `views/`, `handlers/`, or
289/// inline.
290pub fn scaffold_project(
291    name: &str,
292    parent_dir: &Path,
293    local_umbral_repo: Option<&Path>,
294) -> Result<ScaffoldReport, ScaffoldError> {
295    validate_name(name)?;
296
297    let root = parent_dir.join(name);
298    if root.exists() {
299        return Err(ScaffoldError::AlreadyExists(root));
300    }
301
302    fs::create_dir_all(&root)?;
303    fs::create_dir_all(root.join("src"))?;
304    fs::create_dir_all(root.join("src/views"))?;
305    fs::create_dir_all(root.join("src/seed"))?;
306    fs::create_dir_all(root.join("src/widgets"))?;
307    fs::create_dir_all(root.join("plugins"))?;
308    fs::create_dir_all(root.join("templates"))?;
309
310    let crate_name = rust_ident(name);
311    let mut files = Vec::new();
312
313    // ------------------------------------------------------------------ //
314    // Cargo.toml                                                           //
315    // ------------------------------------------------------------------ //
316    let version = env!("CARGO_PKG_VERSION");
317    let cargo_toml = format!(
318        r#"[package]
319name = "{name}"
320version = "0.1.0"
321edition = "2024"
322
323[dependencies]
324
325# ----- Framework core (always required) ------------------------------------
326umbral         = "{version}"
327umbral-cli     = "{version}"
328
329# ----- Active by default ---------------------------------------------------
330# What the generated `src/main.rs` wires in. Comment any of these out only
331# if you also remove the matching `.plugin(...)` line.
332umbral-auth     = "{version}"
333umbral-sessions = "{version}"
334umbral-admin    = "{version}"
335umbral-rest     = "{version}"
336umbral-openapi  = "{version}"
337umbral-security = "{version}"
338# Observability init helper (structured JSON logging). Enable the `otel`
339# feature to ALSO export OpenTelemetry traces over OTLP to a collector
340# (Jaeger/Tempo/Honeycomb): `umbral-logs = {{ version = "{version}", features = ["otel"] }}`.
341umbral-logs     = "{version}"
342
343# ----- Available built-ins (uncomment + register in main.rs to enable) -----
344# umbral-playground   = "{version}"  # Interactive API playground UI (think mini-Postman) at /playground/.
345# umbral-tasks        = "{version}"  # DB-backed background task queue with a worker process.
346# umbral-permissions  = "{version}"  # ContentType + Group + Permission model.
347# umbral-rls          = "{version}"  # Postgres row-level security policy registration.
348# umbral-cache        = "{version}"  # Per-request caching helper.
349# umbral-email        = "{version}"  # SMTP + MIME email composer + sender.
350# umbral-storage      = "{version}"  # Unified storage: static-file serving (prod, whitenoise-equivalent) + uploaded-file storage (local FS + S3).
351# umbral-signals      = "{version}"  # Pre/post save/delete signal dispatch.
352# umbral-livereload   = "{version}"  # Dev-only browser live-reload (SSE push + file watcher). Add `.plugin(LiveReloadPlugin::new())`.
353
354# ----- Third-party + framework runtime deps --------------------------------
355tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
356tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
357serde = {{ version = "1", features = ["derive"] }}
358chrono = {{ version = "0.4", features = ["serde"] }}
359sqlx = {{ version = "0.8", features = ["macros", "sqlite", "postgres", "chrono", "runtime-tokio"] }}
360
361# Once you `umbral startapp <plugin>` or `umbral startplugin <plugin>`, add
362# the plugin crate here:
363# {crate_name}-posts = {{ path = "plugins/posts" }}
364"#
365    );
366    // BUG-17 fix: when `--local <PATH>` is set, rewrite every umbral
367    // dependency to a `{ path = "<umbral>/<sub>/<crate>" }` form
368    // anchored at the supplied umbral-repo path. Comments, active and
369    // commented-out dep lines all go through. Without the flag, the
370    // published crates.io version deps are kept verbatim, which is what
371    // a user installing umbral from crates.io gets.
372    let cargo_toml = match local_umbral_repo {
373        Some(repo) => localize_deps(&cargo_toml, repo),
374        None => cargo_toml,
375    };
376    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
377
378    // ------------------------------------------------------------------ //
379    // src/main.rs — the demo wires every umbral surface in ~100 lines      //
380    // ------------------------------------------------------------------ //
381    let main_rs = format!(
382        r#"//! {name} — generated by `umbral startproject {name}`.
383//!
384//! This `main.rs` reads like a table of contents: the App builder lists
385//! every model, plugin, and route, and the per-concern submodules below
386//! own the detail. As the project grows you slot new handlers into
387//! `views/`, new seed steps into `seed/`, and new dashboard widgets into
388//! `widgets/` — `main.rs` stays a thin wiring layer.
389//!
390//!   src/
391//!     main.rs      — App builder + route table + boot helpers (this file)
392//!     views/       — HTTP handlers, one file per resource grouping
393//!     seed/        — first-run data, `seed::all()` pins dependency order
394//!     widgets/     — admin dashboard widgets, one file per kind
395//!     ../plugins/  — local app plugins (`umbral startapp <name>`)
396//!
397//! Run with:
398//!   cargo run -- migrate   # apply pending migrations (run once after checkout)
399//!   cargo run -- serve     # boot the HTTP server
400//!
401//! Other management commands:
402//!   cargo run -- makemigrations
403//!   cargo run -- showmigrations
404//!   cargo run -- createsuperuser
405
406// --- Per-concern modules (the table of contents) ---------------------------
407mod seed;
408mod views;
409mod widgets;
410
411use umbral::prelude::*;
412use umbral::web::{{SlashRedirect}};
413use umbral::migrate::MigrateError;
414use umbral_auth::{{AuthPlugin, AuthUser, login_required_html}};
415use umbral_sessions::SessionsPlugin;
416use umbral_admin::AdminPlugin;
417use umbral_rest::{{RestPlugin, ResourceConfig}};
418use umbral_openapi::OpenApiPlugin;
419use umbral_security::{{SecurityConfig, SecurityPlugin}};
420
421// ---------------------------------------------------------------------------
422// Models
423// ---------------------------------------------------------------------------
424
425/// A blog post. `author` is a FK to the built-in `AuthUser` model — the
426/// migration engine emits `REFERENCES "auth_user"("id")` automatically.
427#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
428pub struct Post {{
429    pub id: i64,
430    pub title: String,
431    pub body: String,
432    pub published: bool,
433    pub author: ForeignKey<AuthUser>,
434    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
435}}
436
437// ---------------------------------------------------------------------------
438// App wiring
439// ---------------------------------------------------------------------------
440
441#[tokio::main]
442async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
443    // Observability: structured logging + (under the `otel` feature on
444    // `umbral-logs`) OpenTelemetry OTLP trace export. Reads RUST_LOG,
445    // UMBRAL_LOG_FORMAT=json, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME.
446    // Keep the guard alive for the whole program: it flushes the OTLP
447    // exporter on drop so trailing spans aren't lost at exit.
448    let _obs = umbral_logs::observability::init(umbral_logs::ObservabilityConfig::from_env());
449
450    let settings = Settings::from_env()?;
451    let pool = umbral::db::connect(&settings.database_url).await?;
452
453    let app = App::builder()
454        .settings(settings)
455        .database("default", pool)
456
457        // --- Models ----------------------------------------------------------
458        // AuthUser and Session are contributed by their plugins below.
459        // List your own models here.
460        .model::<Post>()
461
462        // --- Plugins ---------------------------------------------------------
463        // Auth: user table, password hashing, createsuperuser command.
464        .plugin(AuthPlugin::<AuthUser>::default())
465        // Sessions: session table + cookie middleware.
466        .plugin(SessionsPlugin::default())
467        // Admin: auto CRUD UI at /admin/ for every registered model.
468        // The dashboard mounts one builtin widget from `widgets/` so a
469        // fresh admin isn't empty — add your own with `.dashboard_section`.
470        .plugin(
471            AdminPlugin::default()
472                .dashboard_section(widgets::cards::overview_section()),
473        )
474        // REST: JSON CRUD + filtering at /api/<table>/.
475        // The Post resource has query-string filtering enabled so
476        // GET /api/post/?published=true works out of the box.
477        .plugin(
478            RestPlugin::default()
479                .resource(ResourceConfig::new("post")),
480        )
481        // OpenAPI: Swagger UI at /openapi/ (override with
482        // `.at("/api/docs")` if you prefer a different mount).
483        .plugin(OpenApiPlugin::new())
484        // Security (on by default): CSRF + clickjacking/HSTS hardening
485        // headers across the app. `/api` is exempt so token-authenticated
486        // JSON clients can POST without a browser form CSRF cookie.
487        .plugin(SecurityPlugin::with_config(SecurityConfig {{
488            csrf_exempt_paths: vec!["/api".to_string()],
489            ..Default::default()
490        }}))
491
492        // --- Templates -------------------------------------------------------
493        .templates_dir("templates")
494        .not_found_template("404.html")
495        .server_error_template("500.html")
496
497        // Redirect /foo → /foo/  (append trailing slash).
498        .slash_redirect(SlashRedirect::Append)
499
500        // --- Routes ----------------------------------------------------------
501        // The Routes builder records each (method, path) pair as you
502        // declare it, so the dev-mode 404 panel surfaces them without
503        // a parallel declaration list. Handlers live in `views/`; this
504        // table is the URL conf — open `views/mod.rs` to see them all.
505        // Per-route middleware (here, login_required_html on /dashboard)
506        // goes through the explicit `.layered(method, path, mr)` form so
507        // the layer attaches just to that handler — not all routes.
508        .routes(
509            Routes::new()
510                // Public home page.
511                .get("/", views::public::home)
512                // API: list posts as JSON (no auth required — demo).
513                .get("/api/posts", views::public::api_list_posts)
514                // Dashboard: only reachable when logged in. The
515                // login_required_html("/login") layer issues a 302 to
516                // /login?next=/dashboard/ for anonymous visitors.
517                .layered(
518                    "GET",
519                    "/dashboard",
520                    get(views::public::dashboard).layer(login_required_html("/login")),
521                ),
522        )
523        .build()?;
524
525    // Auto-migrate + seed on boot so `cargo run -- serve` Just Works
526    // against a fresh database — but only when we're actually starting
527    // the server. Running `cargo run -- makemigrations` or `migrate`
528    // from the CLI used to silently trigger `auto_migrate()` first and
529    // then report "no changes detected" (IMP-1 in bugs/tests/testBugs.md).
530    // The guard reads `std::env::args` before dispatch picks them apart
531    // so it matches whatever subcommand the user actually typed.
532    let argv: Vec<String> = std::env::args().collect();
533    let user_invoked_cli = argv.iter().skip(1).any(|a| !a.starts_with('-'));
534    if !user_invoked_cli {{
535        auto_migrate().await?;
536        // First-run data. `seed::all()` is idempotent — see seed/mod.rs.
537        seed::all().await?;
538    }}
539
540    umbral_cli::dispatch(app).await
541}}
542
543// ---------------------------------------------------------------------------
544// Boot helpers
545// ---------------------------------------------------------------------------
546
547/// Run `makemigrations` + `migrate` on boot. Demo-only convenience.
548async fn auto_migrate() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
549    match umbral::migrate::make().await {{
550        Ok(paths) => {{
551            for path in paths {{
552                eprintln!("auto-migrate: wrote {{}}",  path.display());
553            }}
554        }}
555        Err(MigrateError::NoChanges) => {{}}
556        Err(err) => return Err(Box::new(err)),
557    }}
558    let n = umbral::migrate::run().await?;
559    if n > 0 {{
560        eprintln!("auto-migrate: applied {{n}} migration(s)");
561    }}
562    Ok(())
563}}
564"#
565    );
566    write_file(&root, "src/main.rs", &main_rs, &mut files)?;
567
568    // ------------------------------------------------------------------ //
569    // src/views/mod.rs — re-export layer + shared internal_error helper    //
570    // ------------------------------------------------------------------ //
571    let views_mod_rs = r#"//! HTTP handlers, split by concern — the re-export / discoverability
572//! layer. Open this file and you see the whole web surface in a few
573//! lines: one submodule per resource grouping.
574//!
575//! Submodules:
576//!   - `public` — pages anyone can hit (home, JSON listings).
577//!
578//! Add `pub mod account;` here when auth-gated views land (dashboard,
579//! /me, staff-only pages), then re-export it below so `main.rs` keeps
580//! referencing handlers as `views::public::home` without caring which
581//! file owns each one. This is a recommended convention, not a rule —
582//! the router reads handlers directly, so you're free to restructure.
583
584pub mod public;
585
586use umbral::web::StatusCode;
587
588/// Convert any displayable error into a 500 response. Shared by every
589/// handler that bubbles a `?` through `.map_err(internal_error)`.
590pub(crate) fn internal_error<E: std::fmt::Display>(err: E) -> (StatusCode, String) {
591    (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
592}
593"#;
594    write_file(&root, "src/views/mod.rs", views_mod_rs, &mut files)?;
595
596    // ------------------------------------------------------------------ //
597    // src/views/public.rs — public/unauth handlers                        //
598    // ------------------------------------------------------------------ //
599    let views_public_rs = r#"//! Public storefront views — anyone can hit these, no auth required.
600//!
601//! Every handler hands a context to `umbral::templates::render` and wraps
602//! DB / template errors with the shared `internal_error` helper.
603
604use umbral::prelude::*;
605use umbral::templates::context;
606use umbral::web::{Html, Json, StatusCode};
607
608use super::internal_error;
609use crate::Post;
610use crate::post;
611
612/// Home page. Counts published posts and renders home.html.
613pub async fn home() -> Result<Html<String>, (StatusCode, String)> {
614    let post_count = Post::objects()
615        .filter(post::PUBLISHED.eq(true))
616        .count()
617        .await
618        .map_err(internal_error)?;
619
620    let body = umbral::templates::render("home.html", &context!(post_count)).map_err(internal_error)?;
621    Ok(Html(body))
622}
623
624/// JSON list of all posts — demonstrates the ORM QuerySet.
625pub async fn api_list_posts() -> Result<Json<Vec<Post>>, (StatusCode, String)> {
626    let posts = Post::objects()
627        .order_by(post::ID.desc())
628        .fetch()
629        .await
630        .map_err(internal_error)?;
631    Ok(Json(posts))
632}
633
634/// Dashboard: only reachable when logged in (see the `login_required_html`
635/// layer in `main.rs`). The `LoggedIn<AuthUser>` extractor supplies the
636/// current user — the layer already checked the session, so this is a
637/// cheap field read, not a second DB query.
638pub async fn dashboard(
639    user: umbral_auth::LoggedIn<umbral_auth::AuthUser>,
640) -> Result<Html<String>, (StatusCode, String)> {
641    // Demonstrates a transaction: fetch the user's post list atomically.
642    let user_id = user.id;
643    let my_posts = umbral::transaction(|tx| {
644        Box::pin(async move {
645            Post::objects()
646                .filter(post::AUTHOR.eq(user_id))
647                .on_tx(tx)
648                .fetch()
649                .await
650        })
651    })
652    .await
653    .map_err(internal_error)?;
654
655    let body =
656        umbral::templates::render("dashboard.html", &context!(user, my_posts)).map_err(internal_error)?;
657    Ok(Html(body))
658}
659"#;
660    write_file(&root, "src/views/public.rs", views_public_rs, &mut files)?;
661
662    // ------------------------------------------------------------------ //
663    // src/seed/mod.rs — the seed orchestrator                              //
664    // ------------------------------------------------------------------ //
665    let seed_mod_rs = r#"//! Seed orchestrator — the re-export / dependency-order layer. One
666//! file per concern keeps each step small and focused; `all()` pins
667//! the order in which they run.
668//!
669//! Submodules:
670//!   - `credentials` — first-run dev superuser so you can log in to
671//!                     /admin/ without a manual `createsuperuser`.
672//!
673//! Add a `pub mod <concern>;` here for each new seed step, then call it
674//! from `all()` in dependency order (e.g. catalog rows before the orders
675//! that reference them). The order in `all()` doubles as documentation
676//! of which step depends on which.
677
678pub mod credentials;
679
680/// Run every seed step in the right order. Each step is idempotent
681/// (short-circuits on a non-empty table), so calling `all()` on a
682/// partially-seeded DB tops up the missing pieces without re-inserting.
683pub async fn all() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
684    credentials::test_credentials().await?;
685    Ok(())
686}
687"#;
688    write_file(&root, "src/seed/mod.rs", seed_mod_rs, &mut files)?;
689
690    // ------------------------------------------------------------------ //
691    // src/seed/credentials.rs — idempotent dev superuser                  //
692    // ------------------------------------------------------------------ //
693    let seed_credentials_rs = r#"//! First-run convenience: mints a dev superuser `admin` when no users
694//! exist yet — but ONLY in the Dev environment AND only when you opt in
695//! by exporting a password. There is deliberately NO hardcoded default
696//! password: a bare `./app` launch against an empty production database
697//! must never plant a known-credential admin account.
698//!
699//! To auto-seed the dev superuser:
700//!
701//!   UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
702//!
703//! Otherwise the first boot prints guidance to run
704//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
705//! subsequent boots find the user and stay quiet.
706
707use umbral::Environment;
708use umbral_auth::AuthUser;
709
710/// Env var that opts a fresh install into the dev-superuser seed and
711/// supplies its password. Unset => no seed (print guidance instead).
712const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
713
714pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
715    // Never mint a dev superuser outside the Dev environment — belt and
716    // suspenders on top of the caller only running us on a bare launch.
717    if umbral::settings::get().environment != Environment::Dev {
718        return Ok(());
719    }
720
721    // Idempotent: bail out the moment any user exists.
722    if AuthUser::objects().count().await? > 0 {
723        return Ok(());
724    }
725
726    // Opt-in only: without an explicit password we plant nothing. This
727    // is what keeps a known `admin`/`admin` account off every fresh DB.
728    let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
729        Ok(p) if !p.is_empty() => p,
730        _ => {
731            eprintln!();
732            eprintln!("No users yet, and no dev superuser was seeded. To create one:");
733            eprintln!("  • interactive:  cargo run -- createsuperuser");
734            eprintln!("  • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
735            eprintln!("                  (Dev environment only; never seeds in Prod)");
736            eprintln!();
737            return Ok(());
738        }
739    };
740
741    umbral_auth::create_superuser("admin", "admin@example.com", &password)
742        .await
743        .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
744
745    eprintln!();
746    eprintln!("======================================================================");
747    eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
748    eprintln!("----------------------------------------------------------------------");
749    eprintln!(" Username : admin");
750    eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
751    eprintln!(" Log in   : http://127.0.0.1:8000/admin/");
752    eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
753    eprintln!("======================================================================");
754    eprintln!();
755
756    Ok(())
757}
758"#;
759    write_file(
760        &root,
761        "src/seed/credentials.rs",
762        seed_credentials_rs,
763        &mut files,
764    )?;
765
766    // ------------------------------------------------------------------ //
767    // src/widgets/mod.rs — per-kind re-export layer                       //
768    // ------------------------------------------------------------------ //
769    let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
770//! grouped by kind so each file stays small and focused on one
771//! rendering shape.
772//!
773//! Submodules:
774//!   - `cards` — KPI tiles + dashboard sections.
775//!
776//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
777//! grows, then re-export the builders so `main.rs` calls them as
778//! `widgets::cards::overview_section()` without knowing which file owns
779//! each one. A recommended convention — restructure freely.
780
781pub mod cards;
782"#;
783    write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
784
785    // ------------------------------------------------------------------ //
786    // src/widgets/cards.rs — one builtin dashboard widget so a fresh      //
787    // admin isn't empty                                                    //
788    // ------------------------------------------------------------------ //
789    let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
790//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
791//! your own KPI tiles as the app grows.
792//!
793//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
794//! Each section becomes one row of tiles on the admin dashboard. See
795//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
796//! reference for the data-closure pattern that hits the ORM.
797
798use umbral_admin::WidgetSection;
799
800/// One dashboard section wiring two framework builtins: a model-count
801/// tile and a recent-users list. Mounted from `main.rs` via
802/// `.dashboard_section(widgets::cards::overview_section())`.
803pub fn overview_section() -> WidgetSection {
804    WidgetSection::new("Overview")
805        .subtitle("Framework-wide health + recent activity")
806        .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
807        .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
808}
809"#;
810    write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
811
812    // ------------------------------------------------------------------ //
813    // plugins/ — empty home for local app plugins (umbral startapp)        //
814    // ------------------------------------------------------------------ //
815    write_file(&root, "plugins/.gitkeep", "", &mut files)?;
816    let plugins_readme = "# plugins/\n\nLocal app plugins go here; create one with `umbral startapp <name>`.\nEach is its own crate (`lib/models/views/urls`) and is\nauto-wired into this project's `Cargo.toml` `[dependencies]`.\n";
817    write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
818
819    // ------------------------------------------------------------------ //
820    // umbral.toml                                                           //
821    // ------------------------------------------------------------------ //
822    // A random dev secret, unique per scaffolded project (audit_2 macros-cli #7)
823    // — shared into both umbral.toml and the working .env below so they match.
824    let dev_secret = random_dev_secret_key();
825    let umbral_toml = format!(
826        r#"# umbral settings for {name}.
827# Environment variables (UMBRAL_*) override these at runtime.
828# See umbral::settings for the full schema.
829
830database_url = "sqlite://{name}.db?mode=rwc"
831
832# Bind address for `cargo run -- serve`.
833# Override via UMBRAL_BIND_ADDR or the --addr flag.
834bind_addr = "127.0.0.1:8000"
835
836environment = "Dev"
837
838# A random dev-only key, unique to this project. CHANGE THIS IN PRODUCTION —
839# the framework errors at boot if a dev key is used with environment = "Prod".
840secret_key = "{dev_secret}"
841"#
842    );
843    write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
844
845    // ------------------------------------------------------------------ //
846    // .env  (working copy — not checked in)                               //
847    // ------------------------------------------------------------------ //
848    let dot_env = format!(
849        r#"# Working .env for {name}. Do not commit this file.
850# Generate a real secret key: openssl rand -hex 32
851UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
852UMBRAL_BIND_ADDR=127.0.0.1:8000
853UMBRAL_SECRET_KEY={dev_secret}
854RUST_LOG=info,umbral=debug
855"#
856    );
857    write_file(&root, ".env", &dot_env, &mut files)?;
858
859    // ------------------------------------------------------------------ //
860    // .env.example                                                         //
861    // ------------------------------------------------------------------ //
862    let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
863# Settings here override the umbral.toml values at runtime.
864#
865# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
866# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
867# UMBRAL_BIND_ADDR=0.0.0.0:8000
868# UMBRAL_ENVIRONMENT=prod
869# RUST_LOG=info,umbral=debug
870"#;
871    write_file(&root, ".env.example", env_example, &mut files)?;
872
873    // ------------------------------------------------------------------ //
874    // .gitignore                                                           //
875    // ------------------------------------------------------------------ //
876    let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
877    write_file(&root, ".gitignore", &gitignore, &mut files)?;
878
879    // ------------------------------------------------------------------ //
880    // README.md                                                            //
881    // ------------------------------------------------------------------ //
882    let readme = format!(
883        r#"# {name}
884
885A blog-style demo generated by `umbral startproject {name}`.
886
887## What's in the project
888
889| File | What it shows |
890|---|---|
891| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
892| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
893| `/` route | Template rendering with context |
894| `/api/posts` | JSON endpoint via the ORM |
895| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
896| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
897| `AdminPlugin` | Auto CRUD UI at `/admin/` |
898| `OpenApiPlugin` | Swagger UI at `/openapi/` |
899| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
900
901## Running
902
903```bash
904# First run — a bare `cargo run` (no subcommand) auto-migrates the
905# database and then starts the server. Passing an explicit subcommand
906# (like `serve`) SKIPS the auto-migrate, so `serve` alone assumes the
907# schema already exists.
908cargo run
909
910# Separate steps (production pattern) — migrate explicitly, then serve:
911cargo run -- migrate
912cargo run -- serve
913
914# Create a superuser to log in to the admin:
915cargo run -- createsuperuser
916
917# Explore the scaffold:
918cargo run -- showmigrations
919cargo run -- makemigrations
920```
921
922## Where to go next
923
924- Add a plugin: `umbral startapp posts`
925- Docs: https://umbral.dev/docs/v0.0.1/
926- ORM: /docs/v0.0.1/orm/models
927- Migrations: /docs/v0.0.1/migrations/managed-migrations
928- REST: /docs/v0.0.1/plugins/rest
929- Auth: /docs/v0.0.1/plugins/auth
930"#
931    );
932    write_file(&root, "README.md", &readme, &mut files)?;
933
934    // ------------------------------------------------------------------ //
935    // templates/base.html — Tailwind CDN so the demo works standalone     //
936    // ------------------------------------------------------------------ //
937    let base_html = format!(
938        r#"<!doctype html>
939<html lang="en">
940<head>
941  <meta charset="utf-8">
942  <meta name="viewport" content="width=device-width, initial-scale=1">
943  <title>{{% block title %}}{name}{{% endblock %}}</title>
944  <!-- Tailwind CSS via the play CDN: DEV ONLY. This loads a third-party
945       script on every page and is versionless, so it can't take a
946       meaningful Subresource-Integrity (SRI) hash, and the SecurityPlugin's
947       Content-Security-Policy will block it. Before production, replace it
948       with a compiled/vendored CSS bundle you serve yourself. -->
949  <script src="https://cdn.tailwindcss.com"></script>
950</head>
951<body class="bg-gray-50 text-gray-900 min-h-screen">
952  <nav class="bg-white shadow px-6 py-3 flex items-center gap-6">
953    <a href="/" class="font-bold text-lg">{name}</a>
954    <a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-900">Dashboard</a>
955    <a href="/admin/" class="text-sm text-gray-600 hover:text-gray-900">Admin</a>
956    <a href="/openapi/" class="text-sm text-gray-600 hover:text-gray-900">API docs</a>
957  </nav>
958  <main class="max-w-3xl mx-auto px-4 py-8">
959    {{% block content %}}{{% endblock %}}
960  </main>
961</body>
962</html>
963"#
964    );
965    write_file(&root, "templates/base.html", &base_html, &mut files)?;
966
967    // ------------------------------------------------------------------ //
968    // templates/home.html                                                  //
969    // ------------------------------------------------------------------ //
970    let home_html = r#"{% extends "base.html" %}
971{% block title %}Home{% endblock %}
972{% block content %}
973  <h1 class="text-3xl font-bold mb-4">Welcome</h1>
974  <p class="text-gray-600 mb-6">
975    There are <strong>{{ post_count }}</strong> published post(s).
976  </p>
977  <div class="flex gap-4">
978    <a href="/api/post/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
979      Browse posts (JSON)
980    </a>
981    <a href="/dashboard" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
982      Dashboard (login required)
983    </a>
984  </div>
985{% endblock %}
986"#;
987    write_file(&root, "templates/home.html", home_html, &mut files)?;
988
989    // ------------------------------------------------------------------ //
990    // templates/dashboard.html                                             //
991    // ------------------------------------------------------------------ //
992    let dashboard_html = r#"{% extends "base.html" %}
993{% block title %}Dashboard{% endblock %}
994{% block content %}
995  <h1 class="text-3xl font-bold mb-2">Dashboard</h1>
996  <p class="text-gray-500 mb-6">Logged in as <strong>{{ user.username }}</strong></p>
997
998  <h2 class="text-xl font-semibold mb-3">Your posts</h2>
999  {% if my_posts %}
1000    <ul class="space-y-2">
1001      {% for post in my_posts %}
1002        <li class="bg-white rounded shadow p-4">
1003          <span class="font-medium">{{ post.title }}</span>
1004          {% if post.published %}
1005            <span class="ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">published</span>
1006          {% else %}
1007            <span class="ml-2 text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">draft</span>
1008          {% endif %}
1009        </li>
1010      {% endfor %}
1011    </ul>
1012  {% else %}
1013    <p class="text-gray-400">No posts yet.</p>
1014  {% endif %}
1015{% endblock %}
1016"#;
1017    write_file(
1018        &root,
1019        "templates/dashboard.html",
1020        dashboard_html,
1021        &mut files,
1022    )?;
1023
1024    // ------------------------------------------------------------------ //
1025    // templates/404.html                                                   //
1026    // ------------------------------------------------------------------ //
1027    let not_found_html = r#"{% extends "base.html" %}
1028{% block title %}Page not found{% endblock %}
1029{% block content %}
1030  <div class="text-center py-16">
1031    <h1 class="text-6xl font-bold text-gray-300 mb-4">404</h1>
1032    <p class="text-xl text-gray-600 mb-2">Page not found</p>
1033    <p class="text-gray-400 mb-8">The path <code class="bg-gray-100 px-1 rounded">{{ path }}</code> doesn't exist.</p>
1034    <a href="/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Go home</a>
1035  </div>
1036{% endblock %}
1037"#;
1038    write_file(&root, "templates/404.html", not_found_html, &mut files)?;
1039
1040    // ------------------------------------------------------------------ //
1041    // templates/500.html                                                   //
1042    // ------------------------------------------------------------------ //
1043    let server_error_html = r#"{% extends "base.html" %}
1044{% block title %}Something went wrong{% endblock %}
1045{% block content %}
1046  <div class="text-center py-16">
1047    <h1 class="text-6xl font-bold text-gray-300 mb-4">500</h1>
1048    <p class="text-xl text-gray-600 mb-2">Something went wrong</p>
1049    <p class="text-gray-400 mb-8">We've been notified and are looking into it.</p>
1050    <a href="/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Go home</a>
1051  </div>
1052{% endblock %}
1053"#;
1054    write_file(&root, "templates/500.html", server_error_html, &mut files)?;
1055
1056    let next_steps = vec![
1057        format!("cd {name}"),
1058        "cargo run -- migrate  # apply schema migrations".to_string(),
1059        "cargo run -- serve    # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1060        "cargo run -- createsuperuser  # create an admin login".to_string(),
1061        "umbral startapp <name>          # scaffold a new plugin (app)".to_string(),
1062    ];
1063
1064    Ok(ScaffoldReport {
1065        root,
1066        files,
1067        next_steps,
1068        cargo_toml_registered: None,
1069    })
1070}
1071
1072/// Write a new plugin crate at `<project_root>/plugins/<name>/`, using
1073/// the per-concern layout (gaps2 #8):
1074///
1075/// ```text
1076/// plugins/<name>/
1077/// ├── Cargo.toml
1078/// └── src/
1079///     ├── lib.rs     — the `Plugin` impl (name/models/routes/on_ready)
1080///     ├── models.rs  — `#[derive(Model)]` structs
1081///     ├── views.rs   — HTTP handlers
1082///     └── urls.rs    — the URL conf (`router()`): the route table
1083/// ```
1084///
1085/// `lib.rs` declares a `{Name}Plugin` struct whose `routes()` returns
1086/// `urls::router()`. The new crate is auto-registered as a path dep in
1087/// the project's `Cargo.toml` (see [`register_dep_in_cargo_toml`]); the
1088/// user then wires it into their App by adding `.plugin(...)` to the
1089/// builder chain — the next_steps in the returned report spell out the
1090/// exact lines.
1091pub fn scaffold_app(
1092    name: &str,
1093    project_root: &Path,
1094    local_umbral_repo: Option<&Path>,
1095) -> Result<ScaffoldReport, ScaffoldError> {
1096    validate_name(name)?;
1097
1098    // Reject names that collide with built-in umbral plugins. Both crates
1099    // would compile, but the user could never register both via
1100    // `.plugin(...)` without aliasing — and the table-name conflicts
1101    // would surface at boot, not at startapp time.
1102    let normalized = name.replace('-', "_");
1103    if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1104        return Err(ScaffoldError::ReservedName(name.to_string()));
1105    }
1106
1107    let plugins_dir = project_root.join("plugins");
1108    let root = plugins_dir.join(name);
1109    if root.exists() {
1110        return Err(ScaffoldError::AlreadyExists(root));
1111    }
1112
1113    fs::create_dir_all(&root)?;
1114    fs::create_dir_all(root.join("src"))?;
1115
1116    let crate_name = rust_ident(name);
1117    let pascal = pascal_case_from_ident(name);
1118    let mut files = Vec::new();
1119
1120    let version = env!("CARGO_PKG_VERSION");
1121    let cargo_toml = format!(
1122        r#"[package]
1123name = "{name}"
1124version = "0.1.0"
1125edition = "2024"
1126
1127[dependencies]
1128umbral = "{version}"
1129serde = {{ version = "1", features = ["derive"] }}
1130sqlx = {{ version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }}
1131chrono = {{ version = "0.4", features = ["serde"] }}
1132"#
1133    );
1134    let cargo_toml = match local_umbral_repo {
1135        Some(repo) => localize_deps(&cargo_toml, repo),
1136        None => cargo_toml,
1137    };
1138    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1139
1140    let lib_rs = format!(
1141        r#"//! {pascal}Plugin — generated by `umbral startapp {name}`.
1142//!
1143//! A plugin split one file per concern:
1144//!
1145//!   src/
1146//!     lib.rs     — the `Plugin` impl: glues models + routes together (this file)
1147//!     models.rs  — `#[derive(Model)]` structs (this app's tables)
1148//!     views.rs   — HTTP handlers
1149//!     urls.rs    — the URL conf: maps paths to `views::` handlers
1150//!
1151//! Wire this into your App by adding to `src/main.rs`:
1152//!
1153//! ```ignore
1154//! .plugin({crate_name}::{pascal}Plugin::default())
1155//! ```
1156//!
1157//! See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for
1158//! what each `Plugin` method does. This layout is a recommended
1159//! convention — the framework only needs a type that impls `Plugin`.
1160
1161pub mod models;
1162pub mod urls;
1163pub mod views;
1164
1165use umbral::plugin::{{AppContext, Plugin, PluginError}};
1166use umbral::web::Router;
1167
1168#[derive(Debug, Default, Clone)]
1169pub struct {pascal}Plugin;
1170
1171impl Plugin for {pascal}Plugin {{
1172    fn name(&self) -> &'static str {{
1173        "{name}"
1174    }}
1175
1176    fn models(&self) -> Vec<umbral::migrate::ModelMeta> {{
1177        // Register every model the plugin owns so makemigrations
1178        // picks them up. Uncomment + extend once you've defined one
1179        // in src/models.rs.
1180        // vec![umbral::migrate::ModelMeta::for_::<models::Example>()]
1181        Vec::new()
1182    }}
1183
1184    fn routes(&self) -> Router {{
1185        // Routes live in `urls.rs` (this app's URL conf), one place to
1186        // see every path the plugin serves.
1187        urls::router()
1188    }}
1189
1190    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1191        Ok(())
1192    }}
1193}}
1194"#
1195    );
1196    write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1197
1198    // IMP-4 from bugs/tests/testBugs.md: startapp scaffolds a
1199    // `models.rs` stub so the user has an obvious place to declare
1200    // their first `#[derive(Model)]` struct.
1201    let models_rs = format!(
1202        r#"//! Models for the `{name}` plugin.
1203//!
1204//! Declare one `#[derive(umbral::orm::Model)]` struct per database
1205//! table. Once registered via `Plugin::models()` in lib.rs, the
1206//! migration engine picks them up on the next `makemigrations`.
1207//!
1208//! ```ignore
1209//! use chrono::{{DateTime, Utc}};
1210//! use serde::{{Deserialize, Serialize}};
1211//!
1212//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1213//! pub struct Example {{
1214//!     pub id: i64,
1215//!     #[umbral(string, max_length = 200)]
1216//!     pub title: String,
1217//!     #[umbral(noedit)]
1218//!     pub created_at: DateTime<Utc>,
1219//! }}
1220//! ```
1221"#
1222    );
1223    write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1224
1225    // src/views.rs — HTTP handlers for this plugin. One sample `index`
1226    // handler so `urls.rs` has something to route to out of the box.
1227    let views_rs = format!(
1228        r#"//! HTTP handlers for the `{name}` plugin.
1229//!
1230//! Each handler is an axum handler — return anything that implements
1231//! `IntoResponse` (`Html<String>`, `Json<T>`, `&'static str`, a
1232//! `Result<_, (StatusCode, String)>`, …). Read this app's data through
1233//! the ORM (`models::*::objects()`), never raw SQL.
1234//!
1235//! Routes that reach these handlers are declared in `urls.rs`.
1236
1237/// Sample landing handler. `GET /{name}/` hits this; rewire the path in
1238/// `urls.rs`.
1239pub async fn index() -> &'static str {{
1240    "Hello from the {name} plugin"
1241}}
1242"#
1243    );
1244    write_file(&root, "src/views.rs", &views_rs, &mut files)?;
1245
1246    // src/urls.rs — the plugin's URL conf (the route table). One place
1247    // that maps every path to a `views::` handler.
1248    let urls_rs = format!(
1249        r#"//! URL conf for the `{name}` plugin — the route table.
1250//! `router()` returns the axum `Router` that
1251//! `Plugin::routes()` in lib.rs hands back to the framework.
1252//!
1253//! Convention: `/<name>/...` for HTML pages, `/api/<name>/...` for JSON.
1254//! Map each path to a handler in `views.rs` so this file reads as the
1255//! single index of everything the plugin serves.
1256
1257use umbral::web::{{Router, get}};
1258
1259use crate::views;
1260
1261/// Build this plugin's route table. Add one `.route(path, method(handler))`
1262/// line per endpoint.
1263pub fn router() -> Router {{
1264    Router::new().route("/{name}/", get(views::index))
1265}}
1266"#
1267    );
1268    write_file(&root, "src/urls.rs", &urls_rs, &mut files)?;
1269
1270    // Auto-register the new crate as a path dep in the project's Cargo.toml.
1271    // This is a best-effort step: if it fails (e.g. the user ran startapp
1272    // from a directory that isn't a Cargo project), we warn but don't roll
1273    // back the scaffold files already written.
1274    let project_cargo_toml = project_root.join("Cargo.toml");
1275    let cargo_toml_registered = if project_cargo_toml.is_file() {
1276        match register_dep_in_cargo_toml(&project_cargo_toml, name) {
1277            Ok(added) => Some(added),
1278            Err(_) => None,
1279        }
1280    } else {
1281        None
1282    };
1283
1284    let next_steps = vec![
1285        "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1286        format!("    .plugin({crate_name}::{pascal}Plugin::default())"),
1287        "(The plugin crate was auto-added to your project dependencies.)".to_string(),
1288        "Declare your first model in src/models.rs and uncomment the".to_string(),
1289        "    `Plugin::models()` line in src/lib.rs.".to_string(),
1290        "Add handlers in src/views.rs and route them in src/urls.rs.".to_string(),
1291    ];
1292
1293    Ok(ScaffoldReport {
1294        root,
1295        files,
1296        next_steps,
1297        cargo_toml_registered,
1298    })
1299}
1300
1301/// Write a richer plugin scaffold at `<project_root>/plugins/<name>/`
1302/// targeted at *distributable* / reusable plugins (third-party crates
1303/// you'd publish or share across projects). Layout:
1304///
1305/// ```text
1306/// plugins/<name>/
1307/// ├── Cargo.toml         — deps: umbral, serde, sqlx, chrono, async-trait
1308/// ├── README.md          — what this plugin does, how to wire it
1309/// └── src/
1310///     ├── lib.rs         — Plugin trait impl, glues models + routes
1311///     ├── models.rs      — one example Model showing common field types
1312///     │                    (Text + max_length, Choice enum, optional DateTime)
1313///     └── handlers.rs    — one example axum handler using AppContext
1314/// ```
1315///
1316/// Contrast with [`scaffold_app`], which writes a minimal skeleton
1317/// (Cargo.toml + lib.rs with a stub Plugin impl, nothing else). Use
1318/// `startplugin` when you're building a plugin you intend to ship; use
1319/// `startapp` for an internal module that just needs a `Plugin` seam.
1320pub fn scaffold_plugin(
1321    name: &str,
1322    project_root: &Path,
1323    local_umbral_repo: Option<&Path>,
1324) -> Result<ScaffoldReport, ScaffoldError> {
1325    validate_name(name)?;
1326
1327    let normalized = name.replace('-', "_");
1328    if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1329        return Err(ScaffoldError::ReservedName(name.to_string()));
1330    }
1331
1332    let plugins_dir = project_root.join("plugins");
1333    let root = plugins_dir.join(name);
1334    if root.exists() {
1335        return Err(ScaffoldError::AlreadyExists(root));
1336    }
1337
1338    fs::create_dir_all(&root)?;
1339    fs::create_dir_all(root.join("src"))?;
1340
1341    let crate_name = rust_ident(name);
1342    let pascal = pascal_case_from_ident(name);
1343    let mut files = Vec::new();
1344
1345    // Cargo.toml — pulls in the deps the example modules use. async-
1346    // trait is here because Plugin trait methods are sync today, but
1347    // the generated handlers.rs example uses an async axum extractor,
1348    // and most plugins grow async work quickly. Cheap to ship now,
1349    // saves the user a Cargo.toml edit later.
1350    let version = env!("CARGO_PKG_VERSION");
1351    let cargo_toml = format!(
1352        r#"[package]
1353name = "{name}"
1354version = "0.1.0"
1355edition = "2024"
1356description = "A {crate_name} plugin for umbral."
1357
1358[dependencies]
1359umbral = "{version}"
1360serde = {{ version = "1", features = ["derive"] }}
1361sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1362chrono = {{ version = "0.4", features = ["serde"] }}
1363async-trait = "0.1"
1364"#
1365    );
1366    let cargo_toml = match local_umbral_repo {
1367        Some(repo) => localize_deps(&cargo_toml, repo),
1368        None => cargo_toml,
1369    };
1370    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1371
1372    // README.md — the user-facing tour. Mirrors the file structure so
1373    // a reader who clones the crate knows where to look first.
1374    let readme = format!(
1375        r#"# {name}
1376
1377A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1378
1379Generated by `umbral startplugin {name}`.
1380
1381## What's inside
1382
1383| File | Purpose |
1384|---|---|
1385| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1386| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1387| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1388
1389## Wiring it in
1390
1391In your project's `Cargo.toml`:
1392
1393```toml
1394[dependencies]
1395{name} = {{ path = "plugins/{name}" }}
1396```
1397
1398In `src/main.rs`:
1399
1400```rust,ignore
1401let app = umbral::App::builder()
1402    .plugin({crate_name}::{pascal}Plugin::default())
1403    // ... your other plugins
1404    .build()?;
1405```
1406
1407Then:
1408
1409```sh
1410cargo run -- makemigrations   # generates 0001_initial.json from your models
1411cargo run -- migrate          # applies the schema
1412cargo run -- serve            # boots the HTTP server
1413```
1414
1415## Next steps
1416
1417- Add your own models in `src/models.rs` (or split into a `models/` module).
1418- Add routes in `routes()` and handlers in `src/handlers.rs`.
1419- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1420- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1421"#
1422    );
1423    write_file(&root, "README.md", &readme, &mut files)?;
1424
1425    // src/lib.rs — Plugin impl that pulls models + routes from the
1426    // sibling modules. `models()` returns the registered model meta;
1427    // `routes()` returns the axum Router with the example handler.
1428    let lib_rs = format!(
1429        r#"//! {pascal}Plugin — a richer starter scaffold for distributable
1430//! umbral plugins. Generated by `umbral startplugin {name}`.
1431//!
1432//! Wire this into your App in `src/main.rs`:
1433//!
1434//! ```ignore
1435//! .plugin({crate_name}::{pascal}Plugin::default())
1436//! ```
1437//!
1438//! See `README.md` for the full file tour.
1439
1440pub mod handlers;
1441pub mod models;
1442
1443use async_trait::async_trait;
1444use umbral::migrate::ModelMeta;
1445use umbral::orm::Model;
1446use umbral::plugin::{{AppContext, Plugin, PluginError}};
1447use umbral::web::{{Router, get}};
1448
1449/// The plugin entry point. Register one instance per `App::builder()`.
1450#[derive(Debug, Default, Clone)]
1451pub struct {pascal}Plugin;
1452
1453#[async_trait]
1454impl Plugin for {pascal}Plugin {{
1455    fn name(&self) -> &'static str {{
1456        "{name}"
1457    }}
1458
1459    /// Models the framework's migration engine should track. Each
1460    /// returned [`ModelMeta`] becomes one row in the
1461    /// `umbral_migrations` tracking table once the initial migration
1462    /// applies.
1463    fn models(&self) -> Vec<ModelMeta> {{
1464        vec![models::{pascal}Item::meta()]
1465    }}
1466
1467    /// HTTP routes contributed by this plugin. The base path is
1468    /// up to you — convention is `/<name>/...` for HTML and
1469    /// `/api/<name>/...` for JSON.
1470    fn routes(&self) -> Router {{
1471        Router::new().route("/{name}/hello", get(handlers::hello))
1472    }}
1473
1474    /// One-shot setup after `App::build()` finishes. Use this for
1475    /// seeding default rows, registering signal handlers, or any
1476    /// work that needs the database available. Sync because the
1477    /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1478    /// reach into a runtime via `tokio::runtime::Handle::current()
1479    /// .block_on(...)` if you need to await something here.
1480    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1481        Ok(())
1482    }}
1483}}
1484"#
1485    );
1486    write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1487
1488    // src/models.rs — one Model showing the field types most plugins
1489    // need: a Text with max_length, a Choice enum, an optional
1490    // DateTime. Keeps it small enough to read in one screen.
1491    let models_rs = format!(
1492        r#"//! Example model. Replace or extend with your own.
1493//!
1494//! What this demonstrates:
1495//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1496//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1497//!   `enum` and a Postgres `CHECK (col IN (...))` constraint.
1498//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1499//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1500//!   PUT/PATCH through the REST plugin.
1501
1502use chrono::{{DateTime, Utc}};
1503use serde::{{Deserialize, Serialize}};
1504
1505/// One {crate_name} item. Replace with whatever your plugin actually
1506/// stores.
1507#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1508pub struct {pascal}Item {{
1509    /// Auto-incrementing primary key.
1510    pub id: i64,
1511
1512    /// Display title. Capped at 200 chars; admin renders a single-line
1513    /// input.
1514    #[umbral(string, max_length = 200)]
1515    pub title: String,
1516
1517    /// Lifecycle state. The choices map 1:1 to enum variants; the
1518    /// migration engine emits a CHECK constraint, the admin renders a
1519    /// `<select>`, and the OpenAPI schema gets an `enum` array.
1520    pub status: {pascal}Status,
1521
1522    /// When the item was last published. Read-only on edit forms.
1523    #[umbral(noedit)]
1524    pub published_at: Option<DateTime<Utc>>,
1525}}
1526
1527/// Lifecycle state for [`{pascal}Item`].
1528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
1529#[sqlx(rename_all = "lowercase")]
1530#[serde(rename_all = "lowercase")]
1531pub enum {pascal}Status {{
1532    Draft,
1533    Review,
1534    Published,
1535    Archived,
1536}}
1537"#
1538    );
1539    write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1540
1541    // src/handlers.rs — one axum handler returning JSON. Shows the
1542    // Query extractor + the framework's Json response shape.
1543    let handlers_rs = format!(
1544        r#"//! Example HTTP handlers. Replace or extend with your own.
1545//!
1546//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1547
1548use serde::{{Deserialize, Serialize}};
1549use umbral::web::{{Json, extract::Query}};
1550
1551#[derive(Debug, Deserialize, Default)]
1552pub struct HelloParams {{
1553    /// Who to greet. Defaults to "{name}" when omitted.
1554    #[serde(default)]
1555    pub name: Option<String>,
1556}}
1557
1558#[derive(Debug, Serialize)]
1559pub struct HelloResponse {{
1560    pub greeting: String,
1561}}
1562
1563pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1564    let who = params.name.as_deref().unwrap_or("{name}");
1565    Json(HelloResponse {{
1566        greeting: format!("Hello, {{who}}!"),
1567    }})
1568}}
1569"#
1570    );
1571    write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1572
1573    // Auto-register the new crate as a path dep in the project's Cargo.toml.
1574    let project_cargo_toml = project_root.join("Cargo.toml");
1575    let cargo_toml_registered = if project_cargo_toml.is_file() {
1576        match register_dep_in_cargo_toml(&project_cargo_toml, name) {
1577            Ok(added) => Some(added),
1578            Err(_) => None,
1579        }
1580    } else {
1581        None
1582    };
1583
1584    let next_steps = vec![
1585        "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1586        format!("    .plugin({crate_name}::{pascal}Plugin::default())"),
1587        "Generate + apply the initial migration:".to_string(),
1588        "    cargo run -- makemigrations".to_string(),
1589        "    cargo run -- migrate".to_string(),
1590        format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1591    ];
1592
1593    Ok(ScaffoldReport {
1594        root,
1595        files,
1596        next_steps,
1597        cargo_toml_registered,
1598    })
1599}
1600
1601/// Write a file under `root` at the given relative path. Records the
1602/// relative path in `files` for the user-facing report.
1603fn write_file(
1604    root: &Path,
1605    rel_path: &str,
1606    contents: &str,
1607    files: &mut Vec<PathBuf>,
1608) -> io::Result<()> {
1609    let full = root.join(rel_path);
1610    if let Some(parent) = full.parent() {
1611        fs::create_dir_all(parent)?;
1612    }
1613    fs::write(&full, contents)?;
1614    files.push(PathBuf::from(rel_path));
1615    Ok(())
1616}
1617
1618/// Attempt to register `<name> = { path = "plugins/<name>" }` under
1619/// `[dependencies]` in the project's `Cargo.toml`.
1620///
1621/// Returns:
1622/// - `Ok(true)`  — dep was added.
1623/// - `Ok(false)` — dep was already present (idempotent; no duplicate written).
1624/// - `Err(_)`    — the file couldn't be read or written. Callers treat this
1625///   as a soft failure: the scaffold files are already on disk, so we warn
1626///   but don't roll them back.
1627///
1628/// The insertion uses minimal string surgery (find the `[dependencies]`
1629/// header, append one line immediately after it) so comments, ordering,
1630/// and formatting of existing deps are preserved. `toml_edit` is not yet
1631/// a dep of umbral-cli; if it's added later this function is the right
1632/// place to switch to it.
1633pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
1634    let text = fs::read_to_string(cargo_toml_path)?;
1635
1636    // The dep line we want present. Match on `name =` to catch both
1637    // quoted and unquoted forms that `cargo new` might emit.
1638    let dep_key = format!("{name} =");
1639    if text.lines().any(|l| l.trim_start().starts_with(&dep_key)) {
1640        // Already registered — nothing to do.
1641        return Ok(false);
1642    }
1643
1644    // Find the `[dependencies]` section header and insert immediately after it.
1645    // We insert after the header line itself so the new dep sits at the top of
1646    // the block, before any existing deps. This is the least-surprising position:
1647    // the user can re-order freely after.
1648    let dep_line = format!("{name} = {{ path = \"plugins/{name}\" }}\n");
1649
1650    let mut out = String::with_capacity(text.len() + dep_line.len());
1651    let mut inserted = false;
1652
1653    for line in text.split_inclusive('\n') {
1654        out.push_str(line);
1655        // Match `[dependencies]` exactly (trimmed), not `[dev-dependencies]`
1656        // or `[build-dependencies]`.
1657        if !inserted && line.trim() == "[dependencies]" {
1658            out.push_str(&dep_line);
1659            inserted = true;
1660        }
1661    }
1662
1663    if !inserted {
1664        // No `[dependencies]` section found — append one at the end so the
1665        // manifest stays valid rather than silently failing.
1666        if !out.ends_with('\n') {
1667            out.push('\n');
1668        }
1669        out.push_str("\n[dependencies]\n");
1670        out.push_str(&dep_line);
1671    }
1672
1673    fs::write(cargo_toml_path, &out)?;
1674    Ok(true)
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679    use super::*;
1680
1681    #[test]
1682    fn validate_name_accepts_simple_identifiers() {
1683        assert!(validate_name("posts").is_ok());
1684        assert!(validate_name("blog_engine").is_ok());
1685        assert!(validate_name("blog-engine").is_ok());
1686        assert!(validate_name("api2").is_ok());
1687    }
1688
1689    #[test]
1690    fn validate_name_rejects_empty() {
1691        assert!(validate_name("").is_err());
1692    }
1693
1694    #[test]
1695    fn validate_name_rejects_leading_digit() {
1696        assert!(validate_name("2cool").is_err());
1697    }
1698
1699    #[test]
1700    fn validate_name_rejects_special_chars() {
1701        assert!(validate_name("foo bar").is_err());
1702        assert!(validate_name("foo!bar").is_err());
1703        assert!(validate_name("foo/bar").is_err());
1704    }
1705
1706    #[test]
1707    fn pascal_case_handles_kebab_and_snake() {
1708        assert_eq!(pascal_case_from_ident("posts"), "Posts");
1709        assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
1710        assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
1711        assert_eq!(pascal_case_from_ident("api2"), "Api2");
1712    }
1713
1714    #[test]
1715    fn rust_ident_replaces_hyphens() {
1716        assert_eq!(rust_ident("blog-engine"), "blog_engine");
1717        assert_eq!(rust_ident("posts"), "posts");
1718    }
1719
1720    #[test]
1721    fn scaffold_app_rejects_reserved_built_in_plugin_names() {
1722        let tmp = tempfile::tempdir().expect("tempdir");
1723        for name in RESERVED_PLUGIN_NAMES {
1724            let result = scaffold_app(name, tmp.path(), None);
1725            assert!(
1726                matches!(result, Err(ScaffoldError::ReservedName(_))),
1727                "expected ReservedName error for `{name}`, got: {result:?}",
1728            );
1729            assert!(
1730                !tmp.path().join("plugins").join(name).exists(),
1731                "directory must NOT be created when name is reserved: {name}",
1732            );
1733        }
1734    }
1735
1736    #[test]
1737    fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
1738        // `static` is reserved; so is `my-static`-anything? No — only
1739        // exact matches. But hyphens should normalize to underscores so
1740        // someone typing `umbral-storage` or `umbral_storage` doesn't slip
1741        // through. We compare on the underscored form.
1742        let tmp = tempfile::tempdir().expect("tempdir");
1743        // Pure name check: built-in names contain no hyphens today, but
1744        // the normalization defends against future built-ins like
1745        // `slack-bot` versus `slack_bot`.
1746        let result = scaffold_app("auth", tmp.path(), None);
1747        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
1748    }
1749
1750    #[test]
1751    fn scaffold_app_message_lists_reserved_names() {
1752        let err = ScaffoldError::ReservedName("auth".to_string());
1753        let msg = format!("{err}");
1754        assert!(msg.contains("`auth`"), "error names the offending input");
1755        assert!(
1756            msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
1757            "error lists the reserved set so the user can pick again: {msg}",
1758        );
1759    }
1760
1761    #[test]
1762    fn scaffold_app_already_exists_message_says_app() {
1763        // Gap 39: the AlreadyExists message used to say "target" which
1764        // didn't tell a user that there's an existing APP. The new copy
1765        // names the app directly.
1766        let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
1767        let msg = format!("{err}");
1768        assert!(msg.contains("app already exists"), "got: {msg}");
1769        assert!(msg.contains("plugins/blog"), "got: {msg}");
1770    }
1771
1772    // ----------------------------------------------------------------- //
1773    // scaffold_plugin (gap #63)                                         //
1774    // ----------------------------------------------------------------- //
1775
1776    #[test]
1777    fn scaffold_plugin_writes_richer_layout() {
1778        let tmp = tempfile::tempdir().expect("tempdir");
1779        let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1780
1781        let root = tmp.path().join("plugins").join("widgets");
1782        assert!(root.is_dir());
1783
1784        // The richer layout: README + lib + models + handlers.
1785        for rel in [
1786            "Cargo.toml",
1787            "README.md",
1788            "src/lib.rs",
1789            "src/models.rs",
1790            "src/handlers.rs",
1791        ] {
1792            assert!(
1793                root.join(rel).exists(),
1794                "missing expected file: {rel}; got {:?}",
1795                report.files,
1796            );
1797        }
1798    }
1799
1800    #[test]
1801    fn scaffold_plugin_lib_rs_references_sibling_modules() {
1802        let tmp = tempfile::tempdir().expect("tempdir");
1803        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1804        let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
1805
1806        assert!(
1807            lib.contains("pub mod handlers;"),
1808            "lib.rs must publish handlers"
1809        );
1810        assert!(
1811            lib.contains("pub mod models;"),
1812            "lib.rs must publish models"
1813        );
1814        assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
1815        assert!(
1816            lib.contains("models::WidgetsItem::meta()"),
1817            "models() should register the example model",
1818        );
1819        assert!(
1820            lib.contains("/widgets/hello"),
1821            "routes() should register the example handler",
1822        );
1823    }
1824
1825    #[test]
1826    fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
1827        let tmp = tempfile::tempdir().expect("tempdir");
1828        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1829        let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
1830
1831        assert!(
1832            models.contains("umbral::orm::Model"),
1833            "model derive must reference the framework's Model trait",
1834        );
1835        assert!(
1836            models.contains("max_length = 200"),
1837            "example model should demonstrate max_length",
1838        );
1839        assert!(
1840            models.contains("WidgetsStatus"),
1841            "example model should declare a Choice enum",
1842        );
1843        assert!(
1844            models.contains("noedit"),
1845            "example model should show the noedit attribute",
1846        );
1847    }
1848
1849    #[test]
1850    fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
1851        let tmp = tempfile::tempdir().expect("tempdir");
1852        for name in RESERVED_PLUGIN_NAMES {
1853            let result = scaffold_plugin(name, tmp.path(), None);
1854            assert!(
1855                matches!(result, Err(ScaffoldError::ReservedName(_))),
1856                "expected ReservedName error for `{name}`, got: {result:?}",
1857            );
1858        }
1859    }
1860
1861    #[test]
1862    fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
1863        let tmp = tempfile::tempdir().expect("tempdir");
1864        scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
1865        let result = scaffold_plugin("widgets", tmp.path(), None);
1866        assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
1867    }
1868
1869    // ----------------------------------------------------------------- //
1870    // scaffold_project per-concern layout (gaps2 #8) + SecurityPlugin    //
1871    // default (gaps2 #25)                                                //
1872    // ----------------------------------------------------------------- //
1873
1874    #[test]
1875    fn scaffold_project_writes_per_concern_tree() {
1876        let tmp = tempfile::tempdir().expect("tempdir");
1877        let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1878
1879        let root = tmp.path().join("blog");
1880        assert!(root.is_dir());
1881
1882        // The per-concern tree: views/, seed/, widgets/, plugins/.
1883        for rel in [
1884            "src/main.rs",
1885            "src/views/mod.rs",
1886            "src/views/public.rs",
1887            "src/seed/mod.rs",
1888            "src/seed/credentials.rs",
1889            "src/widgets/mod.rs",
1890            "src/widgets/cards.rs",
1891            "plugins/.gitkeep",
1892            "plugins/README.md",
1893        ] {
1894            assert!(
1895                root.join(rel).exists(),
1896                "missing expected file: {rel}; got {:?}",
1897                report.files,
1898            );
1899        }
1900    }
1901
1902    #[test]
1903    fn scaffold_project_mod_files_carry_orchestrator_markers() {
1904        let tmp = tempfile::tempdir().expect("tempdir");
1905        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1906        let root = tmp.path().join("blog");
1907
1908        let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
1909        assert!(
1910            views_mod.contains("re-export"),
1911            "views/mod.rs should describe itself as the re-export layer",
1912        );
1913        assert!(
1914            views_mod.contains("fn internal_error"),
1915            "views/mod.rs must carry the shared internal_error helper",
1916        );
1917
1918        let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
1919        assert!(
1920            seed_mod.contains("pub async fn all()"),
1921            "seed/mod.rs must declare the all() orchestrator",
1922        );
1923        assert!(
1924            seed_mod.contains("credentials::test_credentials()"),
1925            "seed::all() must call the credentials step",
1926        );
1927        assert!(
1928            seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
1929            "seed/mod.rs should explain it pins dependency order",
1930        );
1931
1932        let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
1933        assert!(
1934            credentials.contains("fn test_credentials"),
1935            "credentials.rs must define the test_credentials seed",
1936        );
1937        assert!(
1938            credentials.contains("count().await? > 0"),
1939            "test_credentials must be idempotent (short-circuit on existing users)",
1940        );
1941
1942        let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
1943        assert!(
1944            widgets_mod.contains("pub mod cards;"),
1945            "widgets/mod.rs must publish the cards submodule",
1946        );
1947
1948        let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
1949        assert!(
1950            cards.contains("builtin_total_models_widget")
1951                || cards.contains("builtin_recent_users_widget"),
1952            "cards.rs should re-export a builtin widget so the dashboard isn't empty",
1953        );
1954    }
1955
1956    #[test]
1957    fn scaffold_project_main_declares_modules_and_mounts_security() {
1958        let tmp = tempfile::tempdir().expect("tempdir");
1959        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1960        let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
1961
1962        // The table-of-contents module declarations.
1963        assert!(
1964            main.contains("mod views;"),
1965            "main.rs must declare mod views"
1966        );
1967        assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
1968        assert!(
1969            main.contains("mod widgets;"),
1970            "main.rs must declare mod widgets",
1971        );
1972
1973        // Routes reference the per-concern handlers.
1974        assert!(
1975            main.contains("views::public::home"),
1976            "route table should wire views::public::home",
1977        );
1978        // Boot runs the seed orchestrator.
1979        assert!(
1980            main.contains("seed::all().await"),
1981            "boot should run seed::all()",
1982        );
1983
1984        // SecurityPlugin mounted by default (gaps2 #25).
1985        assert!(
1986            main.contains("SecurityPlugin"),
1987            "SecurityPlugin must be mounted by default",
1988        );
1989    }
1990
1991    #[test]
1992    fn scaffold_project_creates_empty_plugins_dir() {
1993        let tmp = tempfile::tempdir().expect("tempdir");
1994        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1995        let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
1996        assert!(
1997            readme.contains("umbral startapp"),
1998            "plugins/README.md should point at `umbral startapp`",
1999        );
2000    }
2001
2002    // ----------------------------------------------------------------- //
2003    // scaffold_app per-concern plugin layout (gaps2 #8)                  //
2004    // ----------------------------------------------------------------- //
2005
2006    #[test]
2007    fn scaffold_app_writes_per_concern_plugin_layout() {
2008        let tmp = tempfile::tempdir().expect("tempdir");
2009        let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2010
2011        let root = tmp.path().join("plugins").join("posts");
2012        assert!(root.is_dir());
2013
2014        for rel in [
2015            "Cargo.toml",
2016            "src/lib.rs",
2017            "src/models.rs",
2018            "src/views.rs",
2019            "src/urls.rs",
2020        ] {
2021            assert!(
2022                root.join(rel).exists(),
2023                "missing expected file: {rel}; got {:?}",
2024                report.files,
2025            );
2026        }
2027    }
2028
2029    #[test]
2030    fn scaffold_app_lib_wires_urls_and_views() {
2031        let tmp = tempfile::tempdir().expect("tempdir");
2032        scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2033        let root = tmp.path().join("plugins/posts");
2034
2035        let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2036        assert!(
2037            lib.contains("pub mod models;"),
2038            "lib.rs must publish models"
2039        );
2040        assert!(lib.contains("pub mod views;"), "lib.rs must publish views");
2041        assert!(lib.contains("pub mod urls;"), "lib.rs must publish urls");
2042        assert!(
2043            lib.contains("urls::router()"),
2044            "routes() must return urls::router()",
2045        );
2046        assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2047
2048        let urls = fs::read_to_string(root.join("src/urls.rs")).unwrap();
2049        assert!(
2050            urls.contains("pub fn router() -> Router"),
2051            "urls.rs must expose a router() returning a Router",
2052        );
2053        assert!(
2054            urls.contains("views::index"),
2055            "urls.rs route table should map to a views:: handler",
2056        );
2057
2058        let views = fs::read_to_string(root.join("src/views.rs")).unwrap();
2059        assert!(
2060            views.contains("pub async fn index"),
2061            "views.rs should ship a sample index handler",
2062        );
2063    }
2064
2065    #[test]
2066    fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2067        let tmp = tempfile::tempdir().expect("tempdir");
2068        // Fixture project Cargo.toml with a [dependencies] section.
2069        let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2070        fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2071
2072        let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2073        assert_eq!(
2074            report.cargo_toml_registered,
2075            Some(true),
2076            "the path dep should have been added",
2077        );
2078
2079        let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2080        assert!(
2081            cargo.contains("posts = { path = \"plugins/posts\" }"),
2082            "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2083        );
2084
2085        // Idempotent: a second run reports `false` (already present).
2086        // (Different name would re-add; same name short-circuits.)
2087        let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2088        assert!(!second, "re-registering the same dep must be a no-op");
2089    }
2090
2091    #[test]
2092    fn scaffold_app_still_rejects_reserved_names() {
2093        let tmp = tempfile::tempdir().expect("tempdir");
2094        let result = scaffold_app("auth", tmp.path(), None);
2095        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2096    }
2097
2098    #[test]
2099    fn scaffold_plugin_validates_name_like_startapp() {
2100        let tmp = tempfile::tempdir().expect("tempdir");
2101        assert!(matches!(
2102            scaffold_plugin("2cool", tmp.path(), None),
2103            Err(ScaffoldError::InvalidName(_))
2104        ));
2105        assert!(matches!(
2106            scaffold_plugin("foo bar", tmp.path(), None),
2107            Err(ScaffoldError::InvalidName(_))
2108        ));
2109    }
2110}