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