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 dev superuser `admin` when no users
672//! exist yet — but ONLY in the Dev environment AND only when you opt in
673//! by exporting a password. There is deliberately NO hardcoded default
674//! password: a bare `./app` launch against an empty production database
675//! must never plant a known-credential admin account.
676//!
677//! To auto-seed the dev superuser:
678//!
679//!   UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
680//!
681//! Otherwise the first boot prints guidance to run
682//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
683//! subsequent boots find the user and stay quiet.
684
685use umbral::Environment;
686use umbral_auth::AuthUser;
687
688/// Env var that opts a fresh install into the dev-superuser seed and
689/// supplies its password. Unset => no seed (print guidance instead).
690const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
691
692pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
693    // Never mint a dev superuser outside the Dev environment — belt and
694    // suspenders on top of the caller only running us on a bare launch.
695    if umbral::settings::get().environment != Environment::Dev {
696        return Ok(());
697    }
698
699    // Idempotent: bail out the moment any user exists.
700    if AuthUser::objects().count().await? > 0 {
701        return Ok(());
702    }
703
704    // Opt-in only: without an explicit password we plant nothing. This
705    // is what keeps a known `admin`/`admin` account off every fresh DB.
706    let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
707        Ok(p) if !p.is_empty() => p,
708        _ => {
709            eprintln!();
710            eprintln!("No users yet, and no dev superuser was seeded. To create one:");
711            eprintln!("  • interactive:  cargo run -- createsuperuser");
712            eprintln!("  • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
713            eprintln!("                  (Dev environment only; never seeds in Prod)");
714            eprintln!();
715            return Ok(());
716        }
717    };
718
719    umbral_auth::create_superuser("admin", "admin@example.com", &password)
720        .await
721        .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
722
723    eprintln!();
724    eprintln!("======================================================================");
725    eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
726    eprintln!("----------------------------------------------------------------------");
727    eprintln!(" Username : admin");
728    eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
729    eprintln!(" Log in   : http://127.0.0.1:8000/admin/");
730    eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
731    eprintln!("======================================================================");
732    eprintln!();
733
734    Ok(())
735}
736"#;
737    write_file(
738        &root,
739        "src/seed/credentials.rs",
740        seed_credentials_rs,
741        &mut files,
742    )?;
743
744    // ------------------------------------------------------------------ //
745    // src/widgets/mod.rs — per-kind re-export layer                       //
746    // ------------------------------------------------------------------ //
747    let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
748//! grouped by kind so each file stays small and focused on one
749//! rendering shape.
750//!
751//! Submodules:
752//!   - `cards` — KPI tiles + dashboard sections.
753//!
754//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
755//! grows, then re-export the builders so `main.rs` calls them as
756//! `widgets::cards::overview_section()` without knowing which file owns
757//! each one. A recommended convention — restructure freely.
758
759pub mod cards;
760"#;
761    write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
762
763    // ------------------------------------------------------------------ //
764    // src/widgets/cards.rs — one builtin dashboard widget so a fresh      //
765    // admin isn't empty                                                    //
766    // ------------------------------------------------------------------ //
767    let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
768//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
769//! your own KPI tiles as the app grows.
770//!
771//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
772//! Each section becomes one row of tiles on the admin dashboard. See
773//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
774//! reference for the data-closure pattern that hits the ORM.
775
776use umbral_admin::WidgetSection;
777
778/// One dashboard section wiring two framework builtins: a model-count
779/// tile and a recent-users list. Mounted from `main.rs` via
780/// `.dashboard_section(widgets::cards::overview_section())`.
781pub fn overview_section() -> WidgetSection {
782    WidgetSection::new("Overview")
783        .subtitle("Framework-wide health + recent activity")
784        .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
785        .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
786}
787"#;
788    write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
789
790    // ------------------------------------------------------------------ //
791    // plugins/ — empty home for local app plugins (umbral startapp)        //
792    // ------------------------------------------------------------------ //
793    write_file(&root, "plugins/.gitkeep", "", &mut files)?;
794    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";
795    write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
796
797    // ------------------------------------------------------------------ //
798    // umbral.toml                                                           //
799    // ------------------------------------------------------------------ //
800    let umbral_toml = format!(
801        r#"# umbral settings for {name}.
802# Environment variables (UMBRAL_*) override these at runtime.
803# See umbral::settings for the full schema.
804
805database_url = "sqlite://{name}.db?mode=rwc"
806
807# Bind address for `cargo run -- serve`.
808# Override via UMBRAL_BIND_ADDR or the --addr flag.
809bind_addr = "127.0.0.1:8000"
810
811environment = "Dev"
812
813# CHANGE THIS IN PRODUCTION. The framework errors at boot when this
814# default key is used with environment = "Prod".
815secret_key = "umbral-insecure-dev-key-change-me"
816"#
817    );
818    write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
819
820    // ------------------------------------------------------------------ //
821    // .env  (working copy — not checked in)                               //
822    // ------------------------------------------------------------------ //
823    let dot_env = format!(
824        r#"# Working .env for {name}. Do not commit this file.
825# Generate a real secret key: openssl rand -hex 32
826UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
827UMBRAL_BIND_ADDR=127.0.0.1:8000
828UMBRAL_SECRET_KEY=umbral-insecure-dev-key-change-me
829RUST_LOG=info,umbral=debug
830"#
831    );
832    write_file(&root, ".env", &dot_env, &mut files)?;
833
834    // ------------------------------------------------------------------ //
835    // .env.example                                                         //
836    // ------------------------------------------------------------------ //
837    let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
838# Settings here override the umbral.toml values at runtime.
839#
840# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
841# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
842# UMBRAL_BIND_ADDR=0.0.0.0:8000
843# UMBRAL_ENVIRONMENT=prod
844# RUST_LOG=info,umbral=debug
845"#;
846    write_file(&root, ".env.example", env_example, &mut files)?;
847
848    // ------------------------------------------------------------------ //
849    // .gitignore                                                           //
850    // ------------------------------------------------------------------ //
851    let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
852    write_file(&root, ".gitignore", &gitignore, &mut files)?;
853
854    // ------------------------------------------------------------------ //
855    // README.md                                                            //
856    // ------------------------------------------------------------------ //
857    let readme = format!(
858        r#"# {name}
859
860A blog-style demo generated by `umbral startproject {name}`.
861
862## What's in the project
863
864| File | What it shows |
865|---|---|
866| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
867| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
868| `/` route | Template rendering with context |
869| `/api/posts` | JSON endpoint via the ORM |
870| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
871| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
872| `AdminPlugin` | Auto CRUD UI at `/admin/` |
873| `OpenApiPlugin` | Swagger UI at `/openapi/` |
874| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
875
876## Running
877
878```bash
879# First run — a bare `cargo run` (no subcommand) auto-migrates the
880# database and then starts the server. Passing an explicit subcommand
881# (like `serve`) SKIPS the auto-migrate, so `serve` alone assumes the
882# schema already exists.
883cargo run
884
885# Separate steps (production pattern) — migrate explicitly, then serve:
886cargo run -- migrate
887cargo run -- serve
888
889# Create a superuser to log in to the admin:
890cargo run -- createsuperuser
891
892# Explore the scaffold:
893cargo run -- showmigrations
894cargo run -- makemigrations
895```
896
897## Where to go next
898
899- Add a plugin: `umbral startapp posts`
900- Docs: https://umbral.dev/docs/v0.0.1/
901- ORM: /docs/v0.0.1/orm/models
902- Migrations: /docs/v0.0.1/migrations/managed-migrations
903- REST: /docs/v0.0.1/plugins/rest
904- Auth: /docs/v0.0.1/plugins/auth
905"#
906    );
907    write_file(&root, "README.md", &readme, &mut files)?;
908
909    // ------------------------------------------------------------------ //
910    // templates/base.html — Tailwind CDN so the demo works standalone     //
911    // ------------------------------------------------------------------ //
912    let base_html = format!(
913        r#"<!doctype html>
914<html lang="en">
915<head>
916  <meta charset="utf-8">
917  <meta name="viewport" content="width=device-width, initial-scale=1">
918  <title>{{% block title %}}{name}{{% endblock %}}</title>
919  <!-- Tailwind CSS via the play CDN: DEV ONLY. This loads a third-party
920       script on every page and is versionless, so it can't take a
921       meaningful Subresource-Integrity (SRI) hash, and the SecurityPlugin's
922       Content-Security-Policy will block it. Before production, replace it
923       with a compiled/vendored CSS bundle you serve yourself. -->
924  <script src="https://cdn.tailwindcss.com"></script>
925</head>
926<body class="bg-gray-50 text-gray-900 min-h-screen">
927  <nav class="bg-white shadow px-6 py-3 flex items-center gap-6">
928    <a href="/" class="font-bold text-lg">{name}</a>
929    <a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-900">Dashboard</a>
930    <a href="/admin/" class="text-sm text-gray-600 hover:text-gray-900">Admin</a>
931    <a href="/openapi/" class="text-sm text-gray-600 hover:text-gray-900">API docs</a>
932  </nav>
933  <main class="max-w-3xl mx-auto px-4 py-8">
934    {{% block content %}}{{% endblock %}}
935  </main>
936</body>
937</html>
938"#
939    );
940    write_file(&root, "templates/base.html", &base_html, &mut files)?;
941
942    // ------------------------------------------------------------------ //
943    // templates/home.html                                                  //
944    // ------------------------------------------------------------------ //
945    let home_html = r#"{% extends "base.html" %}
946{% block title %}Home{% endblock %}
947{% block content %}
948  <h1 class="text-3xl font-bold mb-4">Welcome</h1>
949  <p class="text-gray-600 mb-6">
950    There are <strong>{{ post_count }}</strong> published post(s).
951  </p>
952  <div class="flex gap-4">
953    <a href="/api/post/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
954      Browse posts (JSON)
955    </a>
956    <a href="/dashboard" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
957      Dashboard (login required)
958    </a>
959  </div>
960{% endblock %}
961"#;
962    write_file(&root, "templates/home.html", home_html, &mut files)?;
963
964    // ------------------------------------------------------------------ //
965    // templates/dashboard.html                                             //
966    // ------------------------------------------------------------------ //
967    let dashboard_html = r#"{% extends "base.html" %}
968{% block title %}Dashboard{% endblock %}
969{% block content %}
970  <h1 class="text-3xl font-bold mb-2">Dashboard</h1>
971  <p class="text-gray-500 mb-6">Logged in as <strong>{{ user.username }}</strong></p>
972
973  <h2 class="text-xl font-semibold mb-3">Your posts</h2>
974  {% if my_posts %}
975    <ul class="space-y-2">
976      {% for post in my_posts %}
977        <li class="bg-white rounded shadow p-4">
978          <span class="font-medium">{{ post.title }}</span>
979          {% if post.published %}
980            <span class="ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">published</span>
981          {% else %}
982            <span class="ml-2 text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">draft</span>
983          {% endif %}
984        </li>
985      {% endfor %}
986    </ul>
987  {% else %}
988    <p class="text-gray-400">No posts yet.</p>
989  {% endif %}
990{% endblock %}
991"#;
992    write_file(
993        &root,
994        "templates/dashboard.html",
995        dashboard_html,
996        &mut files,
997    )?;
998
999    // ------------------------------------------------------------------ //
1000    // templates/404.html                                                   //
1001    // ------------------------------------------------------------------ //
1002    let not_found_html = r#"{% extends "base.html" %}
1003{% block title %}Page not found{% endblock %}
1004{% block content %}
1005  <div class="text-center py-16">
1006    <h1 class="text-6xl font-bold text-gray-300 mb-4">404</h1>
1007    <p class="text-xl text-gray-600 mb-2">Page not found</p>
1008    <p class="text-gray-400 mb-8">The path <code class="bg-gray-100 px-1 rounded">{{ path }}</code> doesn't exist.</p>
1009    <a href="/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Go home</a>
1010  </div>
1011{% endblock %}
1012"#;
1013    write_file(&root, "templates/404.html", not_found_html, &mut files)?;
1014
1015    // ------------------------------------------------------------------ //
1016    // templates/500.html                                                   //
1017    // ------------------------------------------------------------------ //
1018    let server_error_html = r#"{% extends "base.html" %}
1019{% block title %}Something went wrong{% endblock %}
1020{% block content %}
1021  <div class="text-center py-16">
1022    <h1 class="text-6xl font-bold text-gray-300 mb-4">500</h1>
1023    <p class="text-xl text-gray-600 mb-2">Something went wrong</p>
1024    <p class="text-gray-400 mb-8">We've been notified and are looking into it.</p>
1025    <a href="/" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Go home</a>
1026  </div>
1027{% endblock %}
1028"#;
1029    write_file(&root, "templates/500.html", server_error_html, &mut files)?;
1030
1031    let next_steps = vec![
1032        format!("cd {name}"),
1033        "cargo run -- migrate  # apply schema migrations".to_string(),
1034        "cargo run -- serve    # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1035        "cargo run -- createsuperuser  # create an admin login".to_string(),
1036        "umbral startapp <name>          # scaffold a new plugin (app)".to_string(),
1037    ];
1038
1039    Ok(ScaffoldReport {
1040        root,
1041        files,
1042        next_steps,
1043        cargo_toml_registered: None,
1044    })
1045}
1046
1047/// Write a new plugin crate at `<project_root>/plugins/<name>/`, using
1048/// the per-concern layout (gaps2 #8):
1049///
1050/// ```text
1051/// plugins/<name>/
1052/// ├── Cargo.toml
1053/// └── src/
1054///     ├── lib.rs     — the `Plugin` impl (name/models/routes/on_ready)
1055///     ├── models.rs  — `#[derive(Model)]` structs
1056///     ├── views.rs   — HTTP handlers
1057///     └── urls.rs    — the URL conf (`router()`): the route table
1058/// ```
1059///
1060/// `lib.rs` declares a `{Name}Plugin` struct whose `routes()` returns
1061/// `urls::router()`. The new crate is auto-registered as a path dep in
1062/// the project's `Cargo.toml` (see [`register_dep_in_cargo_toml`]); the
1063/// user then wires it into their App by adding `.plugin(...)` to the
1064/// builder chain — the next_steps in the returned report spell out the
1065/// exact lines.
1066pub fn scaffold_app(
1067    name: &str,
1068    project_root: &Path,
1069    local_umbral_repo: Option<&Path>,
1070) -> Result<ScaffoldReport, ScaffoldError> {
1071    validate_name(name)?;
1072
1073    // Reject names that collide with built-in umbral plugins. Both crates
1074    // would compile, but the user could never register both via
1075    // `.plugin(...)` without aliasing — and the table-name conflicts
1076    // would surface at boot, not at startapp time.
1077    let normalized = name.replace('-', "_");
1078    if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1079        return Err(ScaffoldError::ReservedName(name.to_string()));
1080    }
1081
1082    let plugins_dir = project_root.join("plugins");
1083    let root = plugins_dir.join(name);
1084    if root.exists() {
1085        return Err(ScaffoldError::AlreadyExists(root));
1086    }
1087
1088    fs::create_dir_all(&root)?;
1089    fs::create_dir_all(root.join("src"))?;
1090
1091    let crate_name = rust_ident(name);
1092    let pascal = pascal_case_from_ident(name);
1093    let mut files = Vec::new();
1094
1095    let version = env!("CARGO_PKG_VERSION");
1096    let cargo_toml = format!(
1097        r#"[package]
1098name = "{name}"
1099version = "0.1.0"
1100edition = "2024"
1101
1102[dependencies]
1103umbral = "{version}"
1104serde = {{ version = "1", features = ["derive"] }}
1105sqlx = {{ version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }}
1106chrono = {{ version = "0.4", features = ["serde"] }}
1107"#
1108    );
1109    let cargo_toml = match local_umbral_repo {
1110        Some(repo) => localize_deps(&cargo_toml, repo),
1111        None => cargo_toml,
1112    };
1113    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1114
1115    let lib_rs = format!(
1116        r#"//! {pascal}Plugin — generated by `umbral startapp {name}`.
1117//!
1118//! A plugin split one file per concern:
1119//!
1120//!   src/
1121//!     lib.rs     — the `Plugin` impl: glues models + routes together (this file)
1122//!     models.rs  — `#[derive(Model)]` structs (this app's tables)
1123//!     views.rs   — HTTP handlers
1124//!     urls.rs    — the URL conf: maps paths to `views::` handlers
1125//!
1126//! Wire this into your App by adding to `src/main.rs`:
1127//!
1128//! ```ignore
1129//! .plugin({crate_name}::{pascal}Plugin::default())
1130//! ```
1131//!
1132//! See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for
1133//! what each `Plugin` method does. This layout is a recommended
1134//! convention — the framework only needs a type that impls `Plugin`.
1135
1136pub mod models;
1137pub mod urls;
1138pub mod views;
1139
1140use umbral::plugin::{{AppContext, Plugin, PluginError}};
1141use umbral::web::Router;
1142
1143#[derive(Debug, Default, Clone)]
1144pub struct {pascal}Plugin;
1145
1146impl Plugin for {pascal}Plugin {{
1147    fn name(&self) -> &'static str {{
1148        "{name}"
1149    }}
1150
1151    fn models(&self) -> Vec<umbral::migrate::ModelMeta> {{
1152        // Register every model the plugin owns so makemigrations
1153        // picks them up. Uncomment + extend once you've defined one
1154        // in src/models.rs.
1155        // vec![umbral::migrate::ModelMeta::for_::<models::Example>()]
1156        Vec::new()
1157    }}
1158
1159    fn routes(&self) -> Router {{
1160        // Routes live in `urls.rs` (this app's URL conf), one place to
1161        // see every path the plugin serves.
1162        urls::router()
1163    }}
1164
1165    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1166        Ok(())
1167    }}
1168}}
1169"#
1170    );
1171    write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1172
1173    // IMP-4 from bugs/tests/testBugs.md: startapp scaffolds a
1174    // `models.rs` stub so the user has an obvious place to declare
1175    // their first `#[derive(Model)]` struct.
1176    let models_rs = format!(
1177        r#"//! Models for the `{name}` plugin.
1178//!
1179//! Declare one `#[derive(umbral::orm::Model)]` struct per database
1180//! table. Once registered via `Plugin::models()` in lib.rs, the
1181//! migration engine picks them up on the next `makemigrations`.
1182//!
1183//! ```ignore
1184//! use chrono::{{DateTime, Utc}};
1185//! use serde::{{Deserialize, Serialize}};
1186//!
1187//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1188//! pub struct Example {{
1189//!     pub id: i64,
1190//!     #[umbral(string, max_length = 200)]
1191//!     pub title: String,
1192//!     #[umbral(noedit)]
1193//!     pub created_at: DateTime<Utc>,
1194//! }}
1195//! ```
1196"#
1197    );
1198    write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1199
1200    // src/views.rs — HTTP handlers for this plugin. One sample `index`
1201    // handler so `urls.rs` has something to route to out of the box.
1202    let views_rs = format!(
1203        r#"//! HTTP handlers for the `{name}` plugin.
1204//!
1205//! Each handler is an axum handler — return anything that implements
1206//! `IntoResponse` (`Html<String>`, `Json<T>`, `&'static str`, a
1207//! `Result<_, (StatusCode, String)>`, …). Read this app's data through
1208//! the ORM (`models::*::objects()`), never raw SQL.
1209//!
1210//! Routes that reach these handlers are declared in `urls.rs`.
1211
1212/// Sample landing handler. `GET /{name}/` hits this; rewire the path in
1213/// `urls.rs`.
1214pub async fn index() -> &'static str {{
1215    "Hello from the {name} plugin"
1216}}
1217"#
1218    );
1219    write_file(&root, "src/views.rs", &views_rs, &mut files)?;
1220
1221    // src/urls.rs — the plugin's URL conf (the route table). One place
1222    // that maps every path to a `views::` handler.
1223    let urls_rs = format!(
1224        r#"//! URL conf for the `{name}` plugin — the route table.
1225//! `router()` returns the axum `Router` that
1226//! `Plugin::routes()` in lib.rs hands back to the framework.
1227//!
1228//! Convention: `/<name>/...` for HTML pages, `/api/<name>/...` for JSON.
1229//! Map each path to a handler in `views.rs` so this file reads as the
1230//! single index of everything the plugin serves.
1231
1232use umbral::web::{{Router, get}};
1233
1234use crate::views;
1235
1236/// Build this plugin's route table. Add one `.route(path, method(handler))`
1237/// line per endpoint.
1238pub fn router() -> Router {{
1239    Router::new().route("/{name}/", get(views::index))
1240}}
1241"#
1242    );
1243    write_file(&root, "src/urls.rs", &urls_rs, &mut files)?;
1244
1245    // Auto-register the new crate as a path dep in the project's Cargo.toml.
1246    // This is a best-effort step: if it fails (e.g. the user ran startapp
1247    // from a directory that isn't a Cargo project), we warn but don't roll
1248    // back the scaffold files already written.
1249    let project_cargo_toml = project_root.join("Cargo.toml");
1250    let cargo_toml_registered = if project_cargo_toml.is_file() {
1251        match register_dep_in_cargo_toml(&project_cargo_toml, name) {
1252            Ok(added) => Some(added),
1253            Err(_) => None,
1254        }
1255    } else {
1256        None
1257    };
1258
1259    let next_steps = vec![
1260        "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1261        format!("    .plugin({crate_name}::{pascal}Plugin::default())"),
1262        "(The plugin crate was auto-added to your project dependencies.)".to_string(),
1263        "Declare your first model in src/models.rs and uncomment the".to_string(),
1264        "    `Plugin::models()` line in src/lib.rs.".to_string(),
1265        "Add handlers in src/views.rs and route them in src/urls.rs.".to_string(),
1266    ];
1267
1268    Ok(ScaffoldReport {
1269        root,
1270        files,
1271        next_steps,
1272        cargo_toml_registered,
1273    })
1274}
1275
1276/// Write a richer plugin scaffold at `<project_root>/plugins/<name>/`
1277/// targeted at *distributable* / reusable plugins (third-party crates
1278/// you'd publish or share across projects). Layout:
1279///
1280/// ```text
1281/// plugins/<name>/
1282/// ├── Cargo.toml         — deps: umbral, serde, sqlx, chrono, async-trait
1283/// ├── README.md          — what this plugin does, how to wire it
1284/// └── src/
1285///     ├── lib.rs         — Plugin trait impl, glues models + routes
1286///     ├── models.rs      — one example Model showing common field types
1287///     │                    (Text + max_length, Choice enum, optional DateTime)
1288///     └── handlers.rs    — one example axum handler using AppContext
1289/// ```
1290///
1291/// Contrast with [`scaffold_app`], which writes a minimal skeleton
1292/// (Cargo.toml + lib.rs with a stub Plugin impl, nothing else). Use
1293/// `startplugin` when you're building a plugin you intend to ship; use
1294/// `startapp` for an internal module that just needs a `Plugin` seam.
1295pub fn scaffold_plugin(
1296    name: &str,
1297    project_root: &Path,
1298    local_umbral_repo: Option<&Path>,
1299) -> Result<ScaffoldReport, ScaffoldError> {
1300    validate_name(name)?;
1301
1302    let normalized = name.replace('-', "_");
1303    if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1304        return Err(ScaffoldError::ReservedName(name.to_string()));
1305    }
1306
1307    let plugins_dir = project_root.join("plugins");
1308    let root = plugins_dir.join(name);
1309    if root.exists() {
1310        return Err(ScaffoldError::AlreadyExists(root));
1311    }
1312
1313    fs::create_dir_all(&root)?;
1314    fs::create_dir_all(root.join("src"))?;
1315
1316    let crate_name = rust_ident(name);
1317    let pascal = pascal_case_from_ident(name);
1318    let mut files = Vec::new();
1319
1320    // Cargo.toml — pulls in the deps the example modules use. async-
1321    // trait is here because Plugin trait methods are sync today, but
1322    // the generated handlers.rs example uses an async axum extractor,
1323    // and most plugins grow async work quickly. Cheap to ship now,
1324    // saves the user a Cargo.toml edit later.
1325    let version = env!("CARGO_PKG_VERSION");
1326    let cargo_toml = format!(
1327        r#"[package]
1328name = "{name}"
1329version = "0.1.0"
1330edition = "2024"
1331description = "A {crate_name} plugin for umbral."
1332
1333[dependencies]
1334umbral = "{version}"
1335serde = {{ version = "1", features = ["derive"] }}
1336sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1337chrono = {{ version = "0.4", features = ["serde"] }}
1338async-trait = "0.1"
1339"#
1340    );
1341    let cargo_toml = match local_umbral_repo {
1342        Some(repo) => localize_deps(&cargo_toml, repo),
1343        None => cargo_toml,
1344    };
1345    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1346
1347    // README.md — the user-facing tour. Mirrors the file structure so
1348    // a reader who clones the crate knows where to look first.
1349    let readme = format!(
1350        r#"# {name}
1351
1352A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1353
1354Generated by `umbral startplugin {name}`.
1355
1356## What's inside
1357
1358| File | Purpose |
1359|---|---|
1360| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1361| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1362| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1363
1364## Wiring it in
1365
1366In your project's `Cargo.toml`:
1367
1368```toml
1369[dependencies]
1370{name} = {{ path = "plugins/{name}" }}
1371```
1372
1373In `src/main.rs`:
1374
1375```rust,ignore
1376let app = umbral::App::builder()
1377    .plugin({crate_name}::{pascal}Plugin::default())
1378    // ... your other plugins
1379    .build()?;
1380```
1381
1382Then:
1383
1384```sh
1385cargo run -- makemigrations   # generates 0001_initial.json from your models
1386cargo run -- migrate          # applies the schema
1387cargo run -- serve            # boots the HTTP server
1388```
1389
1390## Next steps
1391
1392- Add your own models in `src/models.rs` (or split into a `models/` module).
1393- Add routes in `routes()` and handlers in `src/handlers.rs`.
1394- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1395- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1396"#
1397    );
1398    write_file(&root, "README.md", &readme, &mut files)?;
1399
1400    // src/lib.rs — Plugin impl that pulls models + routes from the
1401    // sibling modules. `models()` returns the registered model meta;
1402    // `routes()` returns the axum Router with the example handler.
1403    let lib_rs = format!(
1404        r#"//! {pascal}Plugin — a richer starter scaffold for distributable
1405//! umbral plugins. Generated by `umbral startplugin {name}`.
1406//!
1407//! Wire this into your App in `src/main.rs`:
1408//!
1409//! ```ignore
1410//! .plugin({crate_name}::{pascal}Plugin::default())
1411//! ```
1412//!
1413//! See `README.md` for the full file tour.
1414
1415pub mod handlers;
1416pub mod models;
1417
1418use async_trait::async_trait;
1419use umbral::migrate::ModelMeta;
1420use umbral::orm::Model;
1421use umbral::plugin::{{AppContext, Plugin, PluginError}};
1422use umbral::web::{{Router, get}};
1423
1424/// The plugin entry point. Register one instance per `App::builder()`.
1425#[derive(Debug, Default, Clone)]
1426pub struct {pascal}Plugin;
1427
1428#[async_trait]
1429impl Plugin for {pascal}Plugin {{
1430    fn name(&self) -> &'static str {{
1431        "{name}"
1432    }}
1433
1434    /// Models the framework's migration engine should track. Each
1435    /// returned [`ModelMeta`] becomes one row in the
1436    /// `umbral_migrations` tracking table once the initial migration
1437    /// applies.
1438    fn models(&self) -> Vec<ModelMeta> {{
1439        vec![models::{pascal}Item::meta()]
1440    }}
1441
1442    /// HTTP routes contributed by this plugin. The base path is
1443    /// up to you — convention is `/<name>/...` for HTML and
1444    /// `/api/<name>/...` for JSON.
1445    fn routes(&self) -> Router {{
1446        Router::new().route("/{name}/hello", get(handlers::hello))
1447    }}
1448
1449    /// One-shot setup after `App::build()` finishes. Use this for
1450    /// seeding default rows, registering signal handlers, or any
1451    /// work that needs the database available. Sync because the
1452    /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1453    /// reach into a runtime via `tokio::runtime::Handle::current()
1454    /// .block_on(...)` if you need to await something here.
1455    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1456        Ok(())
1457    }}
1458}}
1459"#
1460    );
1461    write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1462
1463    // src/models.rs — one Model showing the field types most plugins
1464    // need: a Text with max_length, a Choice enum, an optional
1465    // DateTime. Keeps it small enough to read in one screen.
1466    let models_rs = format!(
1467        r#"//! Example model. Replace or extend with your own.
1468//!
1469//! What this demonstrates:
1470//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1471//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1472//!   `enum` and a Postgres `CHECK (col IN (...))` constraint.
1473//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1474//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1475//!   PUT/PATCH through the REST plugin.
1476
1477use chrono::{{DateTime, Utc}};
1478use serde::{{Deserialize, Serialize}};
1479
1480/// One {crate_name} item. Replace with whatever your plugin actually
1481/// stores.
1482#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1483pub struct {pascal}Item {{
1484    /// Auto-incrementing primary key.
1485    pub id: i64,
1486
1487    /// Display title. Capped at 200 chars; admin renders a single-line
1488    /// input.
1489    #[umbral(string, max_length = 200)]
1490    pub title: String,
1491
1492    /// Lifecycle state. The choices map 1:1 to enum variants; the
1493    /// migration engine emits a CHECK constraint, the admin renders a
1494    /// `<select>`, and the OpenAPI schema gets an `enum` array.
1495    pub status: {pascal}Status,
1496
1497    /// When the item was last published. Read-only on edit forms.
1498    #[umbral(noedit)]
1499    pub published_at: Option<DateTime<Utc>>,
1500}}
1501
1502/// Lifecycle state for [`{pascal}Item`].
1503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
1504#[sqlx(rename_all = "lowercase")]
1505#[serde(rename_all = "lowercase")]
1506pub enum {pascal}Status {{
1507    Draft,
1508    Review,
1509    Published,
1510    Archived,
1511}}
1512"#
1513    );
1514    write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1515
1516    // src/handlers.rs — one axum handler returning JSON. Shows the
1517    // Query extractor + the framework's Json response shape.
1518    let handlers_rs = format!(
1519        r#"//! Example HTTP handlers. Replace or extend with your own.
1520//!
1521//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1522
1523use serde::{{Deserialize, Serialize}};
1524use umbral::web::{{Json, extract::Query}};
1525
1526#[derive(Debug, Deserialize, Default)]
1527pub struct HelloParams {{
1528    /// Who to greet. Defaults to "{name}" when omitted.
1529    #[serde(default)]
1530    pub name: Option<String>,
1531}}
1532
1533#[derive(Debug, Serialize)]
1534pub struct HelloResponse {{
1535    pub greeting: String,
1536}}
1537
1538pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1539    let who = params.name.as_deref().unwrap_or("{name}");
1540    Json(HelloResponse {{
1541        greeting: format!("Hello, {{who}}!"),
1542    }})
1543}}
1544"#
1545    );
1546    write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1547
1548    // Auto-register the new crate as a path dep in the project's Cargo.toml.
1549    let project_cargo_toml = project_root.join("Cargo.toml");
1550    let cargo_toml_registered = if project_cargo_toml.is_file() {
1551        match register_dep_in_cargo_toml(&project_cargo_toml, name) {
1552            Ok(added) => Some(added),
1553            Err(_) => None,
1554        }
1555    } else {
1556        None
1557    };
1558
1559    let next_steps = vec![
1560        "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1561        format!("    .plugin({crate_name}::{pascal}Plugin::default())"),
1562        "Generate + apply the initial migration:".to_string(),
1563        "    cargo run -- makemigrations".to_string(),
1564        "    cargo run -- migrate".to_string(),
1565        format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1566    ];
1567
1568    Ok(ScaffoldReport {
1569        root,
1570        files,
1571        next_steps,
1572        cargo_toml_registered,
1573    })
1574}
1575
1576/// Write a file under `root` at the given relative path. Records the
1577/// relative path in `files` for the user-facing report.
1578fn write_file(
1579    root: &Path,
1580    rel_path: &str,
1581    contents: &str,
1582    files: &mut Vec<PathBuf>,
1583) -> io::Result<()> {
1584    let full = root.join(rel_path);
1585    if let Some(parent) = full.parent() {
1586        fs::create_dir_all(parent)?;
1587    }
1588    fs::write(&full, contents)?;
1589    files.push(PathBuf::from(rel_path));
1590    Ok(())
1591}
1592
1593/// Attempt to register `<name> = { path = "plugins/<name>" }` under
1594/// `[dependencies]` in the project's `Cargo.toml`.
1595///
1596/// Returns:
1597/// - `Ok(true)`  — dep was added.
1598/// - `Ok(false)` — dep was already present (idempotent; no duplicate written).
1599/// - `Err(_)`    — the file couldn't be read or written. Callers treat this
1600///   as a soft failure: the scaffold files are already on disk, so we warn
1601///   but don't roll them back.
1602///
1603/// The insertion uses minimal string surgery (find the `[dependencies]`
1604/// header, append one line immediately after it) so comments, ordering,
1605/// and formatting of existing deps are preserved. `toml_edit` is not yet
1606/// a dep of umbral-cli; if it's added later this function is the right
1607/// place to switch to it.
1608pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
1609    let text = fs::read_to_string(cargo_toml_path)?;
1610
1611    // The dep line we want present. Match on `name =` to catch both
1612    // quoted and unquoted forms that `cargo new` might emit.
1613    let dep_key = format!("{name} =");
1614    if text.lines().any(|l| l.trim_start().starts_with(&dep_key)) {
1615        // Already registered — nothing to do.
1616        return Ok(false);
1617    }
1618
1619    // Find the `[dependencies]` section header and insert immediately after it.
1620    // We insert after the header line itself so the new dep sits at the top of
1621    // the block, before any existing deps. This is the least-surprising position:
1622    // the user can re-order freely after.
1623    let dep_line = format!("{name} = {{ path = \"plugins/{name}\" }}\n");
1624
1625    let mut out = String::with_capacity(text.len() + dep_line.len());
1626    let mut inserted = false;
1627
1628    for line in text.split_inclusive('\n') {
1629        out.push_str(line);
1630        // Match `[dependencies]` exactly (trimmed), not `[dev-dependencies]`
1631        // or `[build-dependencies]`.
1632        if !inserted && line.trim() == "[dependencies]" {
1633            out.push_str(&dep_line);
1634            inserted = true;
1635        }
1636    }
1637
1638    if !inserted {
1639        // No `[dependencies]` section found — append one at the end so the
1640        // manifest stays valid rather than silently failing.
1641        if !out.ends_with('\n') {
1642            out.push('\n');
1643        }
1644        out.push_str("\n[dependencies]\n");
1645        out.push_str(&dep_line);
1646    }
1647
1648    fs::write(cargo_toml_path, &out)?;
1649    Ok(true)
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654    use super::*;
1655
1656    #[test]
1657    fn validate_name_accepts_simple_identifiers() {
1658        assert!(validate_name("posts").is_ok());
1659        assert!(validate_name("blog_engine").is_ok());
1660        assert!(validate_name("blog-engine").is_ok());
1661        assert!(validate_name("api2").is_ok());
1662    }
1663
1664    #[test]
1665    fn validate_name_rejects_empty() {
1666        assert!(validate_name("").is_err());
1667    }
1668
1669    #[test]
1670    fn validate_name_rejects_leading_digit() {
1671        assert!(validate_name("2cool").is_err());
1672    }
1673
1674    #[test]
1675    fn validate_name_rejects_special_chars() {
1676        assert!(validate_name("foo bar").is_err());
1677        assert!(validate_name("foo!bar").is_err());
1678        assert!(validate_name("foo/bar").is_err());
1679    }
1680
1681    #[test]
1682    fn pascal_case_handles_kebab_and_snake() {
1683        assert_eq!(pascal_case_from_ident("posts"), "Posts");
1684        assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
1685        assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
1686        assert_eq!(pascal_case_from_ident("api2"), "Api2");
1687    }
1688
1689    #[test]
1690    fn rust_ident_replaces_hyphens() {
1691        assert_eq!(rust_ident("blog-engine"), "blog_engine");
1692        assert_eq!(rust_ident("posts"), "posts");
1693    }
1694
1695    #[test]
1696    fn scaffold_app_rejects_reserved_built_in_plugin_names() {
1697        let tmp = tempfile::tempdir().expect("tempdir");
1698        for name in RESERVED_PLUGIN_NAMES {
1699            let result = scaffold_app(name, tmp.path(), None);
1700            assert!(
1701                matches!(result, Err(ScaffoldError::ReservedName(_))),
1702                "expected ReservedName error for `{name}`, got: {result:?}",
1703            );
1704            assert!(
1705                !tmp.path().join("plugins").join(name).exists(),
1706                "directory must NOT be created when name is reserved: {name}",
1707            );
1708        }
1709    }
1710
1711    #[test]
1712    fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
1713        // `static` is reserved; so is `my-static`-anything? No — only
1714        // exact matches. But hyphens should normalize to underscores so
1715        // someone typing `umbral-storage` or `umbral_storage` doesn't slip
1716        // through. We compare on the underscored form.
1717        let tmp = tempfile::tempdir().expect("tempdir");
1718        // Pure name check: built-in names contain no hyphens today, but
1719        // the normalization defends against future built-ins like
1720        // `slack-bot` versus `slack_bot`.
1721        let result = scaffold_app("auth", tmp.path(), None);
1722        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
1723    }
1724
1725    #[test]
1726    fn scaffold_app_message_lists_reserved_names() {
1727        let err = ScaffoldError::ReservedName("auth".to_string());
1728        let msg = format!("{err}");
1729        assert!(msg.contains("`auth`"), "error names the offending input");
1730        assert!(
1731            msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
1732            "error lists the reserved set so the user can pick again: {msg}",
1733        );
1734    }
1735
1736    #[test]
1737    fn scaffold_app_already_exists_message_says_app() {
1738        // Gap 39: the AlreadyExists message used to say "target" which
1739        // didn't tell a user that there's an existing APP. The new copy
1740        // names the app directly.
1741        let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
1742        let msg = format!("{err}");
1743        assert!(msg.contains("app already exists"), "got: {msg}");
1744        assert!(msg.contains("plugins/blog"), "got: {msg}");
1745    }
1746
1747    // ----------------------------------------------------------------- //
1748    // scaffold_plugin (gap #63)                                         //
1749    // ----------------------------------------------------------------- //
1750
1751    #[test]
1752    fn scaffold_plugin_writes_richer_layout() {
1753        let tmp = tempfile::tempdir().expect("tempdir");
1754        let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1755
1756        let root = tmp.path().join("plugins").join("widgets");
1757        assert!(root.is_dir());
1758
1759        // The richer layout: README + lib + models + handlers.
1760        for rel in [
1761            "Cargo.toml",
1762            "README.md",
1763            "src/lib.rs",
1764            "src/models.rs",
1765            "src/handlers.rs",
1766        ] {
1767            assert!(
1768                root.join(rel).exists(),
1769                "missing expected file: {rel}; got {:?}",
1770                report.files,
1771            );
1772        }
1773    }
1774
1775    #[test]
1776    fn scaffold_plugin_lib_rs_references_sibling_modules() {
1777        let tmp = tempfile::tempdir().expect("tempdir");
1778        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1779        let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
1780
1781        assert!(
1782            lib.contains("pub mod handlers;"),
1783            "lib.rs must publish handlers"
1784        );
1785        assert!(
1786            lib.contains("pub mod models;"),
1787            "lib.rs must publish models"
1788        );
1789        assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
1790        assert!(
1791            lib.contains("models::WidgetsItem::meta()"),
1792            "models() should register the example model",
1793        );
1794        assert!(
1795            lib.contains("/widgets/hello"),
1796            "routes() should register the example handler",
1797        );
1798    }
1799
1800    #[test]
1801    fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
1802        let tmp = tempfile::tempdir().expect("tempdir");
1803        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
1804        let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
1805
1806        assert!(
1807            models.contains("umbral::orm::Model"),
1808            "model derive must reference the framework's Model trait",
1809        );
1810        assert!(
1811            models.contains("max_length = 200"),
1812            "example model should demonstrate max_length",
1813        );
1814        assert!(
1815            models.contains("WidgetsStatus"),
1816            "example model should declare a Choice enum",
1817        );
1818        assert!(
1819            models.contains("noedit"),
1820            "example model should show the noedit attribute",
1821        );
1822    }
1823
1824    #[test]
1825    fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
1826        let tmp = tempfile::tempdir().expect("tempdir");
1827        for name in RESERVED_PLUGIN_NAMES {
1828            let result = scaffold_plugin(name, tmp.path(), None);
1829            assert!(
1830                matches!(result, Err(ScaffoldError::ReservedName(_))),
1831                "expected ReservedName error for `{name}`, got: {result:?}",
1832            );
1833        }
1834    }
1835
1836    #[test]
1837    fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
1838        let tmp = tempfile::tempdir().expect("tempdir");
1839        scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
1840        let result = scaffold_plugin("widgets", tmp.path(), None);
1841        assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
1842    }
1843
1844    // ----------------------------------------------------------------- //
1845    // scaffold_project per-concern layout (gaps2 #8) + SecurityPlugin    //
1846    // default (gaps2 #25)                                                //
1847    // ----------------------------------------------------------------- //
1848
1849    #[test]
1850    fn scaffold_project_writes_per_concern_tree() {
1851        let tmp = tempfile::tempdir().expect("tempdir");
1852        let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1853
1854        let root = tmp.path().join("blog");
1855        assert!(root.is_dir());
1856
1857        // The per-concern tree: views/, seed/, widgets/, plugins/.
1858        for rel in [
1859            "src/main.rs",
1860            "src/views/mod.rs",
1861            "src/views/public.rs",
1862            "src/seed/mod.rs",
1863            "src/seed/credentials.rs",
1864            "src/widgets/mod.rs",
1865            "src/widgets/cards.rs",
1866            "plugins/.gitkeep",
1867            "plugins/README.md",
1868        ] {
1869            assert!(
1870                root.join(rel).exists(),
1871                "missing expected file: {rel}; got {:?}",
1872                report.files,
1873            );
1874        }
1875    }
1876
1877    #[test]
1878    fn scaffold_project_mod_files_carry_orchestrator_markers() {
1879        let tmp = tempfile::tempdir().expect("tempdir");
1880        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1881        let root = tmp.path().join("blog");
1882
1883        let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
1884        assert!(
1885            views_mod.contains("re-export"),
1886            "views/mod.rs should describe itself as the re-export layer",
1887        );
1888        assert!(
1889            views_mod.contains("fn internal_error"),
1890            "views/mod.rs must carry the shared internal_error helper",
1891        );
1892
1893        let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
1894        assert!(
1895            seed_mod.contains("pub async fn all()"),
1896            "seed/mod.rs must declare the all() orchestrator",
1897        );
1898        assert!(
1899            seed_mod.contains("credentials::test_credentials()"),
1900            "seed::all() must call the credentials step",
1901        );
1902        assert!(
1903            seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
1904            "seed/mod.rs should explain it pins dependency order",
1905        );
1906
1907        let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
1908        assert!(
1909            credentials.contains("fn test_credentials"),
1910            "credentials.rs must define the test_credentials seed",
1911        );
1912        assert!(
1913            credentials.contains("count().await? > 0"),
1914            "test_credentials must be idempotent (short-circuit on existing users)",
1915        );
1916
1917        let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
1918        assert!(
1919            widgets_mod.contains("pub mod cards;"),
1920            "widgets/mod.rs must publish the cards submodule",
1921        );
1922
1923        let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
1924        assert!(
1925            cards.contains("builtin_total_models_widget")
1926                || cards.contains("builtin_recent_users_widget"),
1927            "cards.rs should re-export a builtin widget so the dashboard isn't empty",
1928        );
1929    }
1930
1931    #[test]
1932    fn scaffold_project_main_declares_modules_and_mounts_security() {
1933        let tmp = tempfile::tempdir().expect("tempdir");
1934        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1935        let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
1936
1937        // The table-of-contents module declarations.
1938        assert!(
1939            main.contains("mod views;"),
1940            "main.rs must declare mod views"
1941        );
1942        assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
1943        assert!(
1944            main.contains("mod widgets;"),
1945            "main.rs must declare mod widgets",
1946        );
1947
1948        // Routes reference the per-concern handlers.
1949        assert!(
1950            main.contains("views::public::home"),
1951            "route table should wire views::public::home",
1952        );
1953        // Boot runs the seed orchestrator.
1954        assert!(
1955            main.contains("seed::all().await"),
1956            "boot should run seed::all()",
1957        );
1958
1959        // SecurityPlugin mounted by default (gaps2 #25).
1960        assert!(
1961            main.contains("SecurityPlugin"),
1962            "SecurityPlugin must be mounted by default",
1963        );
1964    }
1965
1966    #[test]
1967    fn scaffold_project_creates_empty_plugins_dir() {
1968        let tmp = tempfile::tempdir().expect("tempdir");
1969        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
1970        let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
1971        assert!(
1972            readme.contains("umbral startapp"),
1973            "plugins/README.md should point at `umbral startapp`",
1974        );
1975    }
1976
1977    // ----------------------------------------------------------------- //
1978    // scaffold_app per-concern plugin layout (gaps2 #8)                  //
1979    // ----------------------------------------------------------------- //
1980
1981    #[test]
1982    fn scaffold_app_writes_per_concern_plugin_layout() {
1983        let tmp = tempfile::tempdir().expect("tempdir");
1984        let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
1985
1986        let root = tmp.path().join("plugins").join("posts");
1987        assert!(root.is_dir());
1988
1989        for rel in [
1990            "Cargo.toml",
1991            "src/lib.rs",
1992            "src/models.rs",
1993            "src/views.rs",
1994            "src/urls.rs",
1995        ] {
1996            assert!(
1997                root.join(rel).exists(),
1998                "missing expected file: {rel}; got {:?}",
1999                report.files,
2000            );
2001        }
2002    }
2003
2004    #[test]
2005    fn scaffold_app_lib_wires_urls_and_views() {
2006        let tmp = tempfile::tempdir().expect("tempdir");
2007        scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2008        let root = tmp.path().join("plugins/posts");
2009
2010        let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2011        assert!(
2012            lib.contains("pub mod models;"),
2013            "lib.rs must publish models"
2014        );
2015        assert!(lib.contains("pub mod views;"), "lib.rs must publish views");
2016        assert!(lib.contains("pub mod urls;"), "lib.rs must publish urls");
2017        assert!(
2018            lib.contains("urls::router()"),
2019            "routes() must return urls::router()",
2020        );
2021        assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2022
2023        let urls = fs::read_to_string(root.join("src/urls.rs")).unwrap();
2024        assert!(
2025            urls.contains("pub fn router() -> Router"),
2026            "urls.rs must expose a router() returning a Router",
2027        );
2028        assert!(
2029            urls.contains("views::index"),
2030            "urls.rs route table should map to a views:: handler",
2031        );
2032
2033        let views = fs::read_to_string(root.join("src/views.rs")).unwrap();
2034        assert!(
2035            views.contains("pub async fn index"),
2036            "views.rs should ship a sample index handler",
2037        );
2038    }
2039
2040    #[test]
2041    fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2042        let tmp = tempfile::tempdir().expect("tempdir");
2043        // Fixture project Cargo.toml with a [dependencies] section.
2044        let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2045        fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2046
2047        let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2048        assert_eq!(
2049            report.cargo_toml_registered,
2050            Some(true),
2051            "the path dep should have been added",
2052        );
2053
2054        let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2055        assert!(
2056            cargo.contains("posts = { path = \"plugins/posts\" }"),
2057            "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2058        );
2059
2060        // Idempotent: a second run reports `false` (already present).
2061        // (Different name would re-add; same name short-circuits.)
2062        let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2063        assert!(!second, "re-registering the same dep must be a no-op");
2064    }
2065
2066    #[test]
2067    fn scaffold_app_still_rejects_reserved_names() {
2068        let tmp = tempfile::tempdir().expect("tempdir");
2069        let result = scaffold_app("auth", tmp.path(), None);
2070        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2071    }
2072
2073    #[test]
2074    fn scaffold_plugin_validates_name_like_startapp() {
2075        let tmp = tempfile::tempdir().expect("tempdir");
2076        assert!(matches!(
2077            scaffold_plugin("2cool", tmp.path(), None),
2078            Err(ScaffoldError::InvalidName(_))
2079        ));
2080        assert!(matches!(
2081            scaffold_plugin("foo bar", tmp.path(), None),
2082            Err(ScaffoldError::InvalidName(_))
2083        ));
2084    }
2085}