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