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    /// The chosen command name is already a framework built-in (`migrate`,
39    /// `serve`, …) or a built-in plugin's command (`createsuperuser`, …).
40    /// Registering it would shadow the real one at dispatch — the plugin/app
41    /// layer is tried before the built-in clap parser — so `migrate` would
42    /// stop migrating. Rejected at scaffold time, where the fix is free.
43    ReservedCommandName(String),
44    /// `startcommand --in <plugin>` named a plugin that isn't under
45    /// `plugins/`. Carries the names that ARE there, so the message can
46    /// list the real choices instead of just saying no.
47    NoSuchPlugin {
48        asked: String,
49        available: Vec<String>,
50    },
51    /// `startcommand` needs a project to put the command in, and this
52    /// directory has no `src/main.rs` (root) / `src/lib.rs` (plugin).
53    NotAProject(PathBuf),
54    /// I/O failure during file creation.
55    Io(io::Error),
56}
57
58/// Built-in plugin names that `umbral startapp` refuses to scaffold over.
59/// Adding a new built-in plugin? Add its name here so future
60/// `startapp <name>` calls fail fast with a clear message.
61pub const RESERVED_PLUGIN_NAMES: &[&str] = &[
62    "admin",
63    "app",
64    "auth",
65    "cache",
66    "email",
67    "openapi",
68    "permissions",
69    "rest",
70    "rls",
71    "security",
72    "sessions",
73    "signals",
74    "static",
75    "tasks",
76];
77
78/// Commands shipped by a **built-in plugin**. Unlike the framework's own
79/// subcommands, these can't be read off a clap parser — they only exist
80/// once the plugin is registered on an App, and `startcommand` runs
81/// outside any App. So they're listed.
82///
83/// Adding a command to a built-in plugin? Add its name here, or a user's
84/// `startcommand createsuperuser` will scaffold a command that silently
85/// shadows the real one.
86pub const RESERVED_PLUGIN_COMMAND_NAMES: &[&str] = &[
87    "clearsessions",
88    "collectstatic",
89    "createsuperuser",
90    "gen-client",
91    "migrate_schemas",
92    "startauthentication",
93    "startpagination",
94    "startpermission",
95    "startthrottle",
96    "tasks-beat",
97    "tasks-worker",
98];
99
100/// Every command name a new command may not take: the framework's own
101/// subcommands plus [`RESERVED_PLUGIN_COMMAND_NAMES`].
102///
103/// The framework half is read off the derived clap parser rather than
104/// hand-listed, so adding a subcommand to `Command` in `lib.rs`
105/// automatically reserves its name here. A hand-maintained copy would
106/// have drifted the first time someone added one.
107///
108/// This matters because dispatch tries app/plugin commands *before* the
109/// built-in parser (`lib.rs`, step 1 vs step 2). A user command named
110/// `migrate` wouldn't collide loudly — it would just quietly take over,
111/// and their migrations would stop applying.
112pub fn reserved_command_names() -> Vec<String> {
113    let mut names = crate::builtin_command_names();
114    names.extend(RESERVED_PLUGIN_COMMAND_NAMES.iter().map(|s| s.to_string()));
115    names.sort();
116    names.dedup();
117    names
118}
119
120impl std::fmt::Display for ScaffoldError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            Self::InvalidName(s) => write!(
124                f,
125                "invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, not starting with a digit",
126            ),
127            Self::AlreadyExists(p) => write!(
128                f,
129                "an app already exists at `{}`; move it aside or pick a different name",
130                p.display()
131            ),
132            Self::ReservedName(s) => write!(
133                f,
134                "`{s}` is the name of a built-in umbral plugin; pick a different name to avoid conflicts at registration time. Reserved names: {}.",
135                RESERVED_PLUGIN_NAMES.join(", ")
136            ),
137            Self::ReservedCommandName(s) => write!(
138                f,
139                "`{s}` is already an umbral command; pick another name. A command you register \
140                 is dispatched BEFORE the built-in of the same name, so this one would shadow \
141                 it. Taken names: {}.",
142                reserved_command_names().join(", ")
143            ),
144            Self::NoSuchPlugin { asked, available } => {
145                if available.is_empty() {
146                    write!(
147                        f,
148                        "no plugin named `{asked}` — this project has no `plugins/` directory yet. \
149                         Create one with `umbral startapp <name>`, or place the command at the \
150                         project root with `--in root`."
151                    )
152                } else {
153                    write!(
154                        f,
155                        "no plugin named `{asked}`. Available: root, {}.",
156                        available.join(", ")
157                    )
158                }
159            }
160            Self::NotAProject(p) => write!(
161                f,
162                "`{}` doesn't look like an umbral project — no `src/main.rs`. cd into your \
163                 project directory, or pass `--path <dir>`.",
164                p.display()
165            ),
166            Self::Io(e) => write!(f, "{e}"),
167        }
168    }
169}
170
171impl std::error::Error for ScaffoldError {}
172
173impl From<io::Error> for ScaffoldError {
174    fn from(e: io::Error) -> Self {
175        Self::Io(e)
176    }
177}
178
179/// The generator primitives in `umbral::codegen` fail with their own error;
180/// `startcommand` reports through `ScaffoldError` like the rest of this
181/// module. The variants line up one-for-one — they were the same errors, which
182/// is why `codegen` exists.
183impl From<umbral::codegen::CodegenError> for ScaffoldError {
184    fn from(e: umbral::codegen::CodegenError) -> Self {
185        use umbral::codegen::CodegenError as C;
186        match e {
187            C::InvalidName(s) => Self::InvalidName(s),
188            C::AlreadyExists(p) => Self::AlreadyExists(p),
189            C::NoSuchPlugin { asked, available } => Self::NoSuchPlugin { asked, available },
190            C::NotAProject(p) => Self::NotAProject(p),
191            C::Io(e) => Self::Io(e),
192        }
193    }
194}
195
196/// Report returned by both scaffolding functions: the paths written,
197/// so the binary can print them to the user.
198#[derive(Debug, Clone)]
199pub struct ScaffoldReport {
200    /// Root directory the scaffold landed in (project dir, or
201    /// `plugins/<name>/`).
202    pub root: PathBuf,
203    /// All files written, relative to `root`.
204    pub files: Vec<PathBuf>,
205    /// Post-scaffold instructions for the user. The binary prints
206    /// these after the file list.
207    pub next_steps: Vec<String>,
208    /// Whether the project's `Cargo.toml` was updated to include the
209    /// new plugin as a path dependency. `None` means the operation
210    /// wasn't attempted (e.g. `scaffold_project` doesn't auto-register).
211    /// `Some(true)` = dep added, `Some(false)` = dep already present
212    /// (idempotent — no duplicate written).
213    pub cargo_toml_registered: Option<bool>,
214    /// Whether the thing that was scaffolded is actually **registered** — the
215    /// owner file (`main.rs` / the plugin's `lib.rs`) now reaches it.
216    ///
217    /// `false` when the tool could not edit the owner file and handed the user
218    /// the lines instead. The caller MUST consult this before printing a
219    /// success line: `startcommand` used to announce "Registered `x` on the App
220    /// builder" purely because the user asked for `--in root`, whether or not
221    /// it had managed to wire anything. The user read the success line, ran the
222    /// command, and got `unknown command` — the CLI asserted a registration it
223    /// knew it had not performed. `None` for scaffolders where registration
224    /// isn't a concept (`startproject`).
225    pub registered: Option<bool>,
226}
227
228/// Validate a name is acceptable as a Rust crate / module identifier.
229///
230/// Delegates to `umbral::codegen::validate_ident` — the same check every other
231/// generator makes. This used to be a private copy of those rules WITHOUT the
232/// Rust-keyword guard, which meant `umbral startcommand move` sailed through
233/// validation and wrote `pub mod move;` into the user's registry: a syntax
234/// error in a file they never touched. The copy also drifted from the one the
235/// codegen tests assert on, so the suite read greener than the CLI shipped.
236fn validate_name(name: &str) -> Result<(), ScaffoldError> {
237    umbral::codegen::validate_ident(name).map_err(Into::into)
238}
239
240// `pascal_case` replaced by `umbral_casing::pascal_case_from_ident` (imported
241// above) in the gaps2 #77 consolidation refactor.
242
243/// Convert a name to its Rust identifier form (hyphens → underscores).
244/// Rewrite git-deps to path-deps anchored at `umbral_repo`. Closes
245/// BUG-17 in `bugs/tests/testBugs.md` — `umbral startproject --local
246/// /path/to/umbral foo` now produces a `Cargo.toml` that path-deps
247/// every umbral crate against the local checkout instead of the
248/// published crates.io version. Comments + commented-out optional
249/// plugin lines all flow through; any trailing descriptive comment
250/// after the dependency spec is preserved.
251///
252/// Subdirectory mapping mirrors the umbral repo layout: facade
253/// crates (`umbral`, `umbral-cli`, `umbral-core`, `umbral-macros`,
254/// `umbral-testing`) live under `crates/`; everything else
255/// (`umbral-auth`, `umbral-sessions`, `umbral-admin`, …) lives
256/// under `plugins/`.
257pub(crate) fn localize_deps(text: &str, umbral_repo: &Path) -> String {
258    let repo_str = umbral_repo.display().to_string();
259    let mut out = String::with_capacity(text.len());
260    for line in text.split_inclusive('\n') {
261        out.push_str(&rewrite_line(line, &repo_str));
262    }
263    out
264}
265
266/// Rewrite one `Cargo.toml` line: if it declares an umbral dependency
267/// (`umbral-xxx = "<version>"` or `umbral-xxx = { ... }`, optionally
268/// commented out with a leading `#`), replace the dependency spec with a
269/// local `{ path = "<repo>/<subdir>/<crate>" }`. Any other line is
270/// returned unchanged, including the otel example comment whose left
271/// side is prose, not a bare crate name.
272fn rewrite_line(line: &str, repo: &str) -> String {
273    // Find the LHS crate name. Strip a leading `#` (commented-out
274    // optional plugins) and whitespace, then take the substring up to
275    // the first `=`.
276    let body_start = line
277        .char_indices()
278        .find(|(_, c)| !matches!(*c, '#' | ' ' | '\t'))
279        .map(|(i, _)| i)
280        .unwrap_or(0);
281    let body = &line[body_start..];
282    let Some(eq_idx) = body.find('=') else {
283        return line.to_string();
284    };
285    let crate_name = body[..eq_idx].trim();
286    // Only bare umbral crate names get localized (skips prose comments
287    // like the otel example, whose LHS contains spaces/backticks).
288    if !crate_name.starts_with("umbral") || crate_name.contains(|c: char| c.is_whitespace()) {
289        return line.to_string();
290    }
291    // The dependency spec follows `=`: either a version string
292    // (`"0.0.1"`) or an inline table (`{ ... }`). Find where it ends so
293    // any trailing descriptive `# comment` survives verbatim.
294    let after_eq = &body[eq_idx + 1..];
295    let spec_offset = after_eq.len() - after_eq.trim_start().len();
296    let spec = after_eq.trim_start();
297    let spec_len = if let Some(rest) = spec.strip_prefix('"') {
298        match rest.find('"') {
299            Some(i) => 1 + i + 1,
300            None => return line.to_string(),
301        }
302    } else if spec.starts_with('{') {
303        match spec.find('}') {
304            Some(i) => i + 1,
305            None => return line.to_string(),
306        }
307    } else {
308        return line.to_string();
309    };
310    let spec_start = body_start + eq_idx + 1 + spec_offset;
311    let spec_end = spec_start + spec_len;
312    let subdir = match crate_name {
313        "umbral" | "umbral-cli" | "umbral-core" | "umbral-macros" | "umbral-testing" => "crates",
314        _ => "plugins",
315    };
316    let path = format!("{repo}/{subdir}/{crate_name}");
317    let prefix = &line[..spec_start];
318    let suffix = &line[spec_end..];
319    format!("{prefix}{{ path = \"{path}\" }}{suffix}")
320}
321
322fn rust_ident(name: &str) -> String {
323    name.replace('-', "_")
324}
325
326/// A random 64-hex-char dev secret key, unique per scaffold (audit_2
327/// macros-cli #7). Replaces the old shared `umbral-insecure-dev-key-change-me`
328/// literal so two scaffolded projects never share a key. Dev-only — production
329/// still requires a real key (the boot guard rejects a default/dev key under
330/// `environment = "Prod"`). Entropy comes from the OS-seeded `RandomState`; a
331/// crypto dependency isn't warranted for a dev-only, prod-boot-guarded value.
332fn random_dev_secret_key() -> String {
333    use std::hash::{BuildHasher, Hasher};
334    // Each `RandomState::new()` pulls a fresh OS-seeded random state, so the
335    // key differs across scaffold runs. Fold four seeded hashes into 64 hex
336    // chars (256 bits of key material).
337    let seed = std::collections::hash_map::RandomState::new();
338    let mut out = String::with_capacity(64);
339    for i in 0..4u64 {
340        let mut h = seed.build_hasher();
341        h.write_u64(i);
342        h.write_u64(i.wrapping_mul(0x9E37_79B9_7F4A_7C15));
343        out.push_str(&format!("{:016x}", h.finish()));
344    }
345    out
346}
347/// Where the generated templates point their "Docs" links.
348const DOCS_URL: &str = "https://dalmasonto.github.io/umbral/docs/v0.0.1";
349
350/// Write a new umbral project at `parent_dir/<name>/`.
351///
352/// The generated layout is a complete blog-style demo that exercises every
353/// major umbral surface: models with FK, migrations on boot, auth + sessions,
354/// `login_required`, REST with filters, admin, templates, transactions, and
355/// custom error pages.
356///
357/// The layout follows the per-concern convention we landed on in
358/// `examples/shop` (gaps2 #8): `main.rs` reads like a table of contents
359/// and every subsystem lives behind a `mod.rs` re-export/orchestrator
360/// layer, so the project opens to something that scales past 1000 lines.
361///
362/// ```text
363/// <name>/
364/// ├── Cargo.toml
365/// ├── umbral.toml
366/// ├── .env
367/// ├── .env.example
368/// ├── .gitignore
369/// ├── README.md
370/// ├── src/
371/// │   ├── main.rs           # App builder + route table + boot helpers
372/// │   ├── views/
373/// │   │   ├── mod.rs        # re-export layer (handlers return ApiError)
374/// │   │   └── public.rs     # public/unauth handlers
375/// │   ├── seed/
376/// │   │   ├── mod.rs        # `all()` orchestrator (pins dependency order)
377/// │   │   └── credentials.rs# idempotent dev-superuser seed
378/// │   └── widgets/
379/// │       ├── mod.rs        # per-kind re-export layer
380/// │       └── cards.rs      # one builtin admin dashboard widget
381/// ├── plugins/
382/// │   ├── .gitkeep          # local app plugins land here (umbral startapp)
383/// │   └── README.md
384/// └── templates/
385///     ├── base.html
386///     ├── home.html
387///     ├── dashboard.html
388///     ├── 404.html
389///     └── 500.html
390/// ```
391///
392/// `main.rs` wires `umbral_cli::dispatch(app)` so the project's binary
393/// hosts the management commands. These directories are a *recommended*
394/// convention, not a requirement — the runtime reads `main.rs` directly
395/// and doesn't care whether handlers live in `views/`, `handlers/`, or
396/// inline.
397/// Walk up from `start` looking for an umbral source checkout.
398///
399/// Identified by `crates/umbral-core/Cargo.toml`, which no consumer project has.
400fn find_umbral_checkout(start: &Path) -> Option<PathBuf> {
401    start
402        .ancestors()
403        .find(|d| d.join("crates/umbral-core/Cargo.toml").is_file())
404        .map(Path::to_path_buf)
405}
406
407/// Warn when `startproject` is run from inside the umbral repo WITHOUT `--local`.
408///
409/// The generated `Cargo.toml` pins `env!("CARGO_PKG_VERSION")` — the CLI's own version, which
410/// during development is the LAST PUBLISHED release. So a `cargo run -p umbral-cli --
411/// startproject foo` from a HEAD checkout writes `umbral = "<last release>"` and then
412/// generates code against **main's** API. Any surface added since that release makes the new
413/// project fail to compile, and the failure looks like a bug in the framework rather than a
414/// version skew.
415///
416/// It heals itself at release (the scaffold and the libs ship together), so end users of a
417/// published CLI never see it. The only person who hits it is a contributor testing their own
418/// change — which is exactly the person who most needs `--local`, and exactly the person the
419/// silence misleads. gaps3 #65.
420fn warn_if_run_from_a_source_checkout(name: &str, parent_dir: &Path) {
421    let from_cwd = std::env::current_dir()
422        .ok()
423        .and_then(|d| find_umbral_checkout(&d));
424    let Some(repo) = from_cwd.or_else(|| find_umbral_checkout(parent_dir)) else {
425        return;
426    };
427    let version = env!("CARGO_PKG_VERSION");
428    let repo = repo.display();
429    eprintln!(
430        "warning: running `startproject` from an umbral source checkout ({repo}) without `--local`."
431    );
432    eprintln!();
433    eprintln!(
434        "  The new project will depend on the PUBLISHED umbral {version}, while your checkout is on"
435    );
436    eprintln!(
437        "  whatever you have got. Any framework surface you have added since {version} was released"
438    );
439    eprintln!(
440        "  will be missing, and the generated project will fail to compile against it — looking for"
441    );
442    eprintln!("  all the world like a framework bug rather than a version skew.");
443    eprintln!();
444    eprintln!("  To build against this checkout instead:");
445    eprintln!();
446    eprintln!("      umbral startproject {name} --local {repo}");
447    eprintln!();
448}
449
450pub fn scaffold_project(
451    name: &str,
452    parent_dir: &Path,
453    local_umbral_repo: Option<&Path>,
454) -> Result<ScaffoldReport, ScaffoldError> {
455    validate_name(name)?;
456
457    if local_umbral_repo.is_none() {
458        warn_if_run_from_a_source_checkout(name, parent_dir);
459    }
460
461    let root = parent_dir.join(name);
462    if root.exists() {
463        return Err(ScaffoldError::AlreadyExists(root));
464    }
465
466    fs::create_dir_all(&root)?;
467    fs::create_dir_all(root.join("src"))?;
468    fs::create_dir_all(root.join("src/views"))?;
469    fs::create_dir_all(root.join("src/seed"))?;
470    fs::create_dir_all(root.join("src/widgets"))?;
471    fs::create_dir_all(root.join("plugins"))?;
472    fs::create_dir_all(root.join("templates"))?;
473
474    let crate_name = rust_ident(name);
475    let mut files = Vec::new();
476
477    // ------------------------------------------------------------------ //
478    // Cargo.toml                                                           //
479    // ------------------------------------------------------------------ //
480    let version = env!("CARGO_PKG_VERSION");
481    let cargo_toml = format!(
482        r#"[package]
483name = "{name}"
484version = "0.1.0"
485edition = "2024"
486
487[dependencies]
488
489# ----- Framework core (always required) ------------------------------------
490umbral         = "{version}"
491umbral-cli     = "{version}"
492
493# ----- Active by default ---------------------------------------------------
494# What the generated `src/main.rs` wires in. Comment any of these out only
495# if you also remove the matching `.plugin(...)` line.
496umbral-auth     = "{version}"
497umbral-sessions = "{version}"
498umbral-admin    = "{version}"
499umbral-rest     = "{version}"
500umbral-openapi  = "{version}"
501umbral-security = "{version}"
502# Observability init helper (structured JSON logging). Enable the `otel`
503# feature to ALSO export OpenTelemetry traces over OTLP to a collector
504# (Jaeger/Tempo/Honeycomb): `umbral-logs = {{ version = "{version}", features = ["otel"] }}`.
505umbral-logs     = "{version}"
506# Serves ./static at /static — including the compiled Tailwind bundle this
507# project ships. Not optional: the SecurityPlugin's CSP blocks third-party
508# script/style CDNs, so an app must serve its own assets.
509umbral-storage  = "{version}"
510
511# ----- Available built-ins (uncomment + register in main.rs to enable) -----
512# umbral-playground   = "{version}"  # Interactive API playground UI (think mini-Postman) at /playground/.
513# umbral-tasks        = "{version}"  # DB-backed background task queue with a worker process.
514# umbral-permissions  = "{version}"  # ContentType + Group + Permission model.
515# umbral-rls          = "{version}"  # Postgres row-level security policy registration.
516# umbral-cache        = "{version}"  # Per-request caching helper.
517# umbral-email        = "{version}"  # SMTP + MIME email composer + sender.
518# umbral-signals      = "{version}"  # Pre/post save/delete signal dispatch.
519# umbral-livereload   = "{version}"  # Dev-only browser live-reload (SSE push + file watcher). Add `.plugin(LiveReloadPlugin::new())`.
520
521# ----- Third-party + framework runtime deps --------------------------------
522tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
523tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
524serde = {{ version = "1", features = ["derive"] }}
525chrono = {{ version = "0.4", features = ["serde"] }}
526sqlx = {{ version = "0.8", features = ["macros", "sqlite", "postgres", "chrono", "runtime-tokio"] }}
527
528# Once you `umbral startapp <plugin>` or `umbral startplugin <plugin>`, add
529# the plugin crate here:
530# {crate_name}-posts = {{ path = "plugins/posts" }}
531"#
532    );
533    // BUG-17 fix: when `--local <PATH>` is set, rewrite every umbral
534    // dependency to a `{ path = "<umbral>/<sub>/<crate>" }` form
535    // anchored at the supplied umbral-repo path. Comments, active and
536    // commented-out dep lines all go through. Without the flag, the
537    // published crates.io version deps are kept verbatim, which is what
538    // a user installing umbral from crates.io gets.
539    let cargo_toml = match local_umbral_repo {
540        Some(repo) => localize_deps(&cargo_toml, repo),
541        None => cargo_toml,
542    };
543    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
544
545    // ------------------------------------------------------------------ //
546    // src/main.rs — the demo wires every umbral surface in ~100 lines      //
547    // ------------------------------------------------------------------ //
548    let main_rs = format!(
549        r#"//! {name} — application entrypoint.
550//!
551//! This `main.rs` reads like a table of contents: the App builder lists
552//! every model, plugin, and route, and the per-concern submodules below
553//! own the detail. As the project grows you slot new handlers into
554//! `views/`, new seed steps into `seed/`, and new dashboard widgets into
555//! `widgets/` — `main.rs` stays a thin wiring layer.
556//!
557//!   src/
558//!     main.rs      — App builder + route table + boot helpers (this file)
559//!     views/       — HTTP handlers, one file per resource grouping
560//!     seed/        — first-run data, `seed::all()` pins dependency order
561//!     widgets/     — admin dashboard widgets, one file per kind
562//!     ../plugins/  — local app plugins (`umbral startapp <name>`)
563//!
564//! Run with:
565//!   cargo run -- migrate   # apply pending migrations (run once after checkout)
566//!   cargo run -- serve     # boot the HTTP server
567//!
568//! Other management commands:
569//!   cargo run -- makemigrations
570//!   cargo run -- showmigrations
571//!   cargo run -- createsuperuser
572
573// --- Per-concern modules (the table of contents) ---------------------------
574mod seed;
575mod views;
576mod widgets;
577
578use umbral::prelude::*;
579use umbral::web::{{SlashRedirect}};
580use umbral::migrate::MigrateError;
581use umbral_auth::{{AuthPlugin, AuthUser, login_required_html}};
582use umbral_sessions::SessionsPlugin;
583use umbral_admin::AdminPlugin;
584use umbral_rest::{{RestPlugin, ResourceConfig}};
585use umbral_openapi::OpenApiPlugin;
586use umbral_security::{{SecurityConfig, SecurityPlugin}};
587use umbral_storage::StoragePlugin;
588
589// ---------------------------------------------------------------------------
590// Models
591// ---------------------------------------------------------------------------
592
593/// A blog post. `author` is a FK to the built-in `AuthUser` model — the
594/// migration engine emits `REFERENCES "auth_user"("id")` automatically.
595#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
596pub struct Post {{
597    pub id: i64,
598    pub title: String,
599    pub body: String,
600    pub published: bool,
601    pub author: ForeignKey<AuthUser>,
602    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
603}}
604
605// ---------------------------------------------------------------------------
606// App wiring
607// ---------------------------------------------------------------------------
608
609#[tokio::main]
610async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
611    // Observability: structured logging + (under the `otel` feature on
612    // `umbral-logs`) OpenTelemetry OTLP trace export. Reads RUST_LOG,
613    // UMBRAL_LOG_FORMAT=json, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME.
614    // Keep the guard alive for the whole program: it flushes the OTLP
615    // exporter on drop so trailing spans aren't lost at exit.
616    let _obs = umbral_logs::observability::init(umbral_logs::ObservabilityConfig::from_env());
617
618    let settings = Settings::from_env()?;
619    let pool = umbral::db::connect(&settings.database_url).await?;
620
621    let app = App::builder()
622        .settings(settings)
623        .database("default", pool)
624
625        // --- Models ----------------------------------------------------------
626        // AuthUser and Session are contributed by their plugins below.
627        // List your own models here.
628        .model::<Post>()
629
630        // --- Plugins ---------------------------------------------------------
631        // Auth: user table, password hashing, createsuperuser command.
632        .plugin(AuthPlugin::<AuthUser>::default())
633        // Sessions: session table + cookie middleware.
634        .plugin(SessionsPlugin::default())
635        // Admin: auto CRUD UI at /admin/ for every registered model.
636        // The dashboard mounts one builtin widget from `widgets/` so a
637        // fresh admin isn't empty — add your own with `.dashboard_section`.
638        .plugin(
639            AdminPlugin::default()
640                .dashboard_section(widgets::cards::overview_section()),
641        )
642        // REST: JSON CRUD + filtering at /api/<table>/.
643        // The Post resource has query-string filtering enabled so
644        // GET /api/post/?published=true works out of the box.
645        .plugin(
646            RestPlugin::default()
647                .resource(ResourceConfig::new("post")),
648        )
649        // OpenAPI: Swagger UI at /openapi/ (override with
650        // `.at("/api/docs")` if you prefer a different mount).
651        .plugin(OpenApiPlugin::new())
652        // Static files: serves ./static at /static, which is where the compiled
653        // Tailwind bundle lives. Use `{{ static('css/app.css') }}` in templates
654        // rather than a hardcoded path — in production it resolves through the
655        // hashed-asset manifest so you get cache-busting for free.
656        //
657        // The same plugin also gives you uploaded-file storage (local FS or S3)
658        // when you add a FileField / ImageField: `.media("/media", "./media")`.
659        .plugin(StoragePlugin::new().static_files("/static", "./static"))
660        // Security (on by default): CSRF + clickjacking/HSTS hardening
661        // headers across the app. `/api` is exempt so token-authenticated
662        // JSON clients can POST without a browser form CSRF cookie.
663        .plugin(SecurityPlugin::with_config(SecurityConfig {{
664            csrf_exempt_paths: vec!["/api".to_string()],
665            ..Default::default()
666        }}))
667
668        // --- Templates -------------------------------------------------------
669        .templates_dir("templates")
670        .not_found_template("404.html")
671        .server_error_template("500.html")
672
673        // Redirect /foo → /foo/  (append trailing slash).
674        .slash_redirect(SlashRedirect::Append)
675
676        // --- Routes ----------------------------------------------------------
677        // The Routes builder records each (method, path) pair as you
678        // declare it, so the dev-mode 404 panel surfaces them without
679        // a parallel declaration list. Handlers live in `views/`; this
680        // table is the URL conf — open `views/mod.rs` to see them all.
681        // Per-route middleware (here, login_required_html on /dashboard)
682        // goes through the explicit `.layered(method, path, mr)` form so
683        // the layer attaches just to that handler — not all routes.
684        .routes(
685            Routes::new()
686                // Public home page.
687                .get("/", views::public::home)
688                // API: list posts as JSON (no auth required — demo).
689                .get("/api/posts", views::public::api_list_posts)
690                // Dashboard: only reachable when logged in. The
691                // login_required_html("/login") layer issues a 302 to
692                // /login?next=/dashboard/ for anonymous visitors.
693                .layered(
694                    "GET",
695                    "/dashboard",
696                    get(views::public::dashboard).layer(login_required_html("/login")),
697                ),
698        )
699        // `build_deferred`, not `build`: it wires everything (pools, model
700        // registry, router, system checks) but leaves each plugin's `on_ready`
701        // hook unfired. Those hooks seed content and backfill rows, so they must
702        // not run during `migrate` — the command whose whole job is to create the
703        // tables they write to. `dispatch` fires them once it has read argv.
704        .build_deferred()?;
705
706    // Auto-migrate + seed on boot so `cargo run -- serve` Just Works
707    // against a fresh database — but only when we're actually starting
708    // the server. Running `cargo run -- makemigrations` or `migrate`
709    // from the CLI used to silently trigger `auto_migrate()` first and
710    // then report "no changes detected" (IMP-1 in bugs/tests/testBugs.md).
711    // The guard reads `std::env::args` before dispatch picks them apart
712    // so it matches whatever subcommand the user actually typed.
713    let argv: Vec<String> = std::env::args().collect();
714    let user_invoked_cli = argv.iter().skip(1).any(|a| !a.starts_with('-'));
715    if !user_invoked_cli {{
716        auto_migrate().await?;
717        // First-run data. `seed::all()` is idempotent — see seed/mod.rs.
718        seed::all().await?;
719    }}
720
721    umbral_cli::dispatch(app).await
722}}
723
724// ---------------------------------------------------------------------------
725// Boot helpers
726// ---------------------------------------------------------------------------
727
728/// Run `makemigrations` + `migrate` on boot. Demo-only convenience.
729async fn auto_migrate() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {{
730    match umbral::migrate::make().await {{
731        Ok(paths) => {{
732            for path in paths {{
733                eprintln!("auto-migrate: wrote {{}}",  path.display());
734            }}
735        }}
736        Err(MigrateError::NoChanges) => {{}}
737        Err(err) => return Err(Box::new(err)),
738    }}
739    let n = umbral::migrate::run().await?;
740    if n > 0 {{
741        eprintln!("auto-migrate: applied {{n}} migration(s)");
742    }}
743    Ok(())
744}}
745"#
746    );
747    write_file(&root, "src/main.rs", &main_rs, &mut files)?;
748
749    // ------------------------------------------------------------------ //
750    // src/views/mod.rs — re-export layer (handlers return ApiError)        //
751    // ------------------------------------------------------------------ //
752    let views_mod_rs = r#"//! HTTP handlers, split by concern — the re-export / discoverability
753//! layer. Open this file and you see the whole web surface in a few
754//! lines: one submodule per resource grouping.
755//!
756//! Submodules:
757//!   - `public` — pages anyone can hit (home, JSON listings).
758//!
759//! Add `pub mod account;` here when auth-gated views land (dashboard,
760//! /me, staff-only pages), then re-export it below so `main.rs` keeps
761//! referencing handlers as `views::public::home` without caring which
762//! file owns each one. This is a recommended convention, not a rule —
763//! the router reads handlers directly, so you're free to restructure.
764
765pub mod public;
766
767// No `internal_error` helper, on purpose.
768//
769// Handlers return `Result<_, umbral::web::ApiError>` and use a bare `?`. ApiError
770// converts from sqlx / WriteError / TemplateError, logs the real cause server-side, and
771// returns an opaque 500 — so a missing table or a SQL fragment never reaches the browser.
772// The `(StatusCode, String)` + `err.to_string()` pattern does the opposite.
773"#;
774    write_file(&root, "src/views/mod.rs", views_mod_rs, &mut files)?;
775
776    // ------------------------------------------------------------------ //
777    // src/views/public.rs — public/unauth handlers                        //
778    // ------------------------------------------------------------------ //
779    let views_public_rs = r#"//! Public storefront views — anyone can hit these, no auth required.
780//!
781//! Every handler returns `Result<_, ApiError>` and lets `?` do the work. `ApiError`
782//! converts from a database error, a `WriteError` and a template error, so there is no
783//! per-handler error helper to write — and a 500 logs the real cause server-side while
784//! the client gets an opaque message. Never hand `err.to_string()` to a browser: that is
785//! how table names and SQL fragments end up on someone else's screen.
786
787use umbral::prelude::*;
788use umbral::templates::context;
789
790use crate::Post;
791use crate::post;
792
793/// Home page. Counts published posts and renders home.html.
794pub async fn home() -> Result<Html<String>, ApiError> {
795    let post_count = Post::objects()
796        .filter(post::PUBLISHED.eq(true))
797        .count()
798        .await?;
799
800    let body = umbral::templates::render("home.html", &context!(post_count))?;
801    Ok(Html(body))
802}
803
804/// JSON list of all posts — demonstrates the ORM QuerySet.
805pub async fn api_list_posts() -> Result<Json<Vec<Post>>, ApiError> {
806    let posts = Post::objects().order_by(post::ID.desc()).fetch().await?;
807    Ok(Json(posts))
808}
809
810/// Dashboard: only reachable when logged in (see the `login_required_html`
811/// layer in `main.rs`). The `LoggedIn<AuthUser>` extractor supplies the
812/// current user — the layer already checked the session, so this is a
813/// cheap field read, not a second DB query.
814pub async fn dashboard(
815    user: umbral_auth::LoggedIn<umbral_auth::AuthUser>,
816) -> Result<Html<String>, ApiError> {
817    // Demonstrates a transaction: fetch the user's post list atomically.
818    let user_id = user.id;
819    let my_posts = umbral::transaction(|tx| {
820        Box::pin(async move {
821            Post::objects()
822                .filter(post::AUTHOR.eq(user_id))
823                .on_tx(tx)
824                .fetch()
825                .await
826        })
827    })
828    .await?;
829
830    let body = umbral::templates::render("dashboard.html", &context!(user, my_posts))?;
831    Ok(Html(body))
832}
833"#;
834    write_file(&root, "src/views/public.rs", views_public_rs, &mut files)?;
835
836    // ------------------------------------------------------------------ //
837    // src/seed/mod.rs — the seed orchestrator                              //
838    // ------------------------------------------------------------------ //
839    let seed_mod_rs = r#"//! Seed orchestrator — the re-export / dependency-order layer. One
840//! file per concern keeps each step small and focused; `all()` pins
841//! the order in which they run.
842//!
843//! Submodules:
844//!   - `credentials` — first-run dev superuser so you can log in to
845//!                     /admin/ without a manual `createsuperuser`.
846//!
847//! Add a `pub mod <concern>;` here for each new seed step, then call it
848//! from `all()` in dependency order (e.g. catalog rows before the orders
849//! that reference them). The order in `all()` doubles as documentation
850//! of which step depends on which.
851
852pub mod credentials;
853
854/// Run every seed step in the right order. Each step is idempotent
855/// (short-circuits on a non-empty table), so calling `all()` on a
856/// partially-seeded DB tops up the missing pieces without re-inserting.
857pub async fn all() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
858    credentials::test_credentials().await?;
859    Ok(())
860}
861"#;
862    write_file(&root, "src/seed/mod.rs", seed_mod_rs, &mut files)?;
863
864    // ------------------------------------------------------------------ //
865    // src/seed/credentials.rs — idempotent dev superuser                  //
866    // ------------------------------------------------------------------ //
867    let seed_credentials_rs = r#"//! First-run convenience: mints a dev superuser `admin` when no users
868//! exist yet — but ONLY in the Dev environment AND only when you opt in
869//! by exporting a password. There is deliberately NO hardcoded default
870//! password: a bare `./app` launch against an empty production database
871//! must never plant a known-credential admin account.
872//!
873//! To auto-seed the dev superuser:
874//!
875//!   UMBRAL_DEV_ADMIN_PASSWORD=your-dev-password cargo run
876//!
877//! Otherwise the first boot prints guidance to run
878//! `cargo run -- createsuperuser` and seeds nothing. Idempotent —
879//! subsequent boots find the user and stay quiet.
880
881use umbral::Environment;
882use umbral_auth::AuthUser;
883
884/// Env var that opts a fresh install into the dev-superuser seed and
885/// supplies its password. Unset => no seed (print guidance instead).
886const DEV_ADMIN_PASSWORD_ENV: &str = "UMBRAL_DEV_ADMIN_PASSWORD";
887
888pub async fn test_credentials() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
889    // Never mint a dev superuser outside the Dev environment — belt and
890    // suspenders on top of the caller only running us on a bare launch.
891    if umbral::settings::get().environment != Environment::Dev {
892        return Ok(());
893    }
894
895    // Idempotent: bail out the moment any user exists.
896    if AuthUser::objects().count().await? > 0 {
897        return Ok(());
898    }
899
900    // Opt-in only: without an explicit password we plant nothing. This
901    // is what keeps a known `admin`/`admin` account off every fresh DB.
902    let password = match std::env::var(DEV_ADMIN_PASSWORD_ENV) {
903        Ok(p) if !p.is_empty() => p,
904        _ => {
905            eprintln!();
906            eprintln!("No users yet, and no dev superuser was seeded. To create one:");
907            eprintln!("  • interactive:  cargo run -- createsuperuser");
908            eprintln!("  • auto on boot: set {DEV_ADMIN_PASSWORD_ENV}=... and restart");
909            eprintln!("                  (Dev environment only; never seeds in Prod)");
910            eprintln!();
911            return Ok(());
912        }
913    };
914
915    umbral_auth::create_superuser("admin", "admin@example.com", &password)
916        .await
917        .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
918
919    eprintln!();
920    eprintln!("======================================================================");
921    eprintln!(" DEV SUPERUSER seeded (Dev environment, {DEV_ADMIN_PASSWORD_ENV} set)");
922    eprintln!("----------------------------------------------------------------------");
923    eprintln!(" Username : admin");
924    eprintln!(" Password : (the value of {DEV_ADMIN_PASSWORD_ENV})");
925    eprintln!(" Log in   : http://127.0.0.1:8000/admin/");
926    eprintln!(" Remove or edit src/seed/credentials.rs before shipping.");
927    eprintln!("======================================================================");
928    eprintln!();
929
930    Ok(())
931}
932"#;
933    write_file(
934        &root,
935        "src/seed/credentials.rs",
936        seed_credentials_rs,
937        &mut files,
938    )?;
939
940    // ------------------------------------------------------------------ //
941    // src/widgets/mod.rs — per-kind re-export layer                       //
942    // ------------------------------------------------------------------ //
943    let widgets_mod_rs = r#"//! Admin dashboard widgets — the re-export / discoverability layer,
944//! grouped by kind so each file stays small and focused on one
945//! rendering shape.
946//!
947//! Submodules:
948//!   - `cards` — KPI tiles + dashboard sections.
949//!
950//! Add `pub mod charts;`, `pub mod tables;`, etc. as your dashboard
951//! grows, then re-export the builders so `main.rs` calls them as
952//! `widgets::cards::overview_section()` without knowing which file owns
953//! each one. A recommended convention — restructure freely.
954
955pub mod cards;
956"#;
957    write_file(&root, "src/widgets/mod.rs", widgets_mod_rs, &mut files)?;
958
959    // ------------------------------------------------------------------ //
960    // src/widgets/cards.rs — one builtin dashboard widget so a fresh      //
961    // admin isn't empty                                                    //
962    // ------------------------------------------------------------------ //
963    let widgets_cards_rs = r#"//! Dashboard widget builders. This starter re-exports one framework
964//! builtin so a fresh `/admin/` dashboard isn't empty; replace it with
965//! your own KPI tiles as the app grows.
966//!
967//! A widget is a `Widget` value handed to `WidgetSection::widget(...)`.
968//! Each section becomes one row of tiles on the admin dashboard. See
969//! `documentation/docs/v0.0.1/admin/` and the `examples/shop/src/widgets`
970//! reference for the data-closure pattern that hits the ORM.
971
972use umbral_admin::WidgetSection;
973
974/// One dashboard section wiring two framework builtins: a model-count
975/// tile and a recent-users list. Mounted from `main.rs` via
976/// `.dashboard_section(widgets::cards::overview_section())`.
977pub fn overview_section() -> WidgetSection {
978    WidgetSection::new("Overview")
979        .subtitle("Framework-wide health + recent activity")
980        .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2))
981        .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2))
982}
983"#;
984    write_file(&root, "src/widgets/cards.rs", widgets_cards_rs, &mut files)?;
985
986    // ------------------------------------------------------------------ //
987    // plugins/ — empty home for local app plugins (umbral startapp)        //
988    // ------------------------------------------------------------------ //
989    write_file(&root, "plugins/.gitkeep", "", &mut files)?;
990    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";
991    write_file(&root, "plugins/README.md", plugins_readme, &mut files)?;
992
993    // ------------------------------------------------------------------ //
994    // umbral.toml                                                           //
995    // ------------------------------------------------------------------ //
996    // A random dev secret, unique per scaffolded project (audit_2 macros-cli #7)
997    // — shared into both umbral.toml and the working .env below so they match.
998    let dev_secret = random_dev_secret_key();
999    let umbral_toml = format!(
1000        r#"# umbral settings for {name}.
1001# Environment variables (UMBRAL_*) override these at runtime.
1002# See umbral::settings for the full schema.
1003
1004database_url = "sqlite://{name}.db?mode=rwc"
1005
1006# Bind address for `cargo run -- serve`.
1007# Override via UMBRAL_BIND_ADDR or the --addr flag.
1008bind_addr = "127.0.0.1:8000"
1009
1010environment = "Dev"
1011
1012# A random dev-only key, unique to this project. CHANGE THIS IN PRODUCTION —
1013# the framework errors at boot if a dev key is used with environment = "Prod".
1014secret_key = "{dev_secret}"
1015"#
1016    );
1017    write_file(&root, "umbral.toml", &umbral_toml, &mut files)?;
1018
1019    // ------------------------------------------------------------------ //
1020    // .env  (working copy — not checked in)                               //
1021    // ------------------------------------------------------------------ //
1022    let dot_env = format!(
1023        r#"# Working .env for {name}. Do not commit this file.
1024# Generate a real secret key: openssl rand -hex 32
1025UMBRAL_DATABASE_URL=sqlite://{name}.db?mode=rwc
1026UMBRAL_BIND_ADDR=127.0.0.1:8000
1027UMBRAL_SECRET_KEY={dev_secret}
1028RUST_LOG=info,umbral=debug
1029"#
1030    );
1031    write_file(&root, ".env", &dot_env, &mut files)?;
1032
1033    // ------------------------------------------------------------------ //
1034    // .env.example                                                         //
1035    // ------------------------------------------------------------------ //
1036    let env_example = r#"# Copy to `.env` and source from your shell, or use a tool like direnv.
1037# Settings here override the umbral.toml values at runtime.
1038#
1039# UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
1040# UMBRAL_DATABASE_URL=sqlite://my.db?mode=rwc
1041# UMBRAL_BIND_ADDR=0.0.0.0:8000
1042# UMBRAL_ENVIRONMENT=prod
1043# RUST_LOG=info,umbral=debug
1044"#;
1045    write_file(&root, ".env.example", env_example, &mut files)?;
1046
1047    // ------------------------------------------------------------------ //
1048    // .gitignore                                                           //
1049    // ------------------------------------------------------------------ //
1050    let gitignore = format!("/target\n/{name}.db*\n.env\nCargo.lock\n");
1051    write_file(&root, ".gitignore", &gitignore, &mut files)?;
1052
1053    // ------------------------------------------------------------------ //
1054    // README.md                                                            //
1055    // ------------------------------------------------------------------ //
1056    let readme = format!(
1057        r#"# {name}
1058
1059Your umbral app.
1060
1061It starts with one model (`Post`), an admin, a JSON API and an OpenAPI browser, so there
1062is something running from the first `cargo run`. All of it is ordinary code in this
1063repository — rename it, gut it, replace it.
1064
1065## What's in the project
1066
1067| File | What it shows |
1068|---|---|
1069| `src/main.rs` | App wiring: models, plugins, routes, auto-migrate |
1070| `Post` model | `ForeignKey<AuthUser>`, ORM QuerySet, `#[derive(Model)]` |
1071| `/` route | Template rendering with context |
1072| `/api/posts` | JSON endpoint via the ORM |
1073| `/dashboard` | `login_required_html("/login")` layer, `LoggedIn<AuthUser>` extractor, transaction |
1074| `RestPlugin` | JSON CRUD at `/api/post/` with query-string filtering (`?published=true`) |
1075| `AdminPlugin` | Auto CRUD UI at `/admin/` |
1076| `OpenApiPlugin` | Swagger UI at `/openapi/` |
1077| `SecurityPlugin` | CSRF middleware + hardening headers, with `/api` exempt for token clients |
1078
1079## Running
1080
1081```bash
1082# First run — a bare `cargo run` (no subcommand) auto-migrates the
1083# database and then starts the server. Passing an explicit subcommand
1084# (like `serve`) SKIPS the auto-migrate, so `serve` alone assumes the
1085# schema already exists.
1086cargo run
1087
1088# Separate steps (production pattern) — migrate explicitly, then serve:
1089cargo run -- migrate
1090cargo run -- serve
1091
1092# Create a superuser to log in to the admin:
1093cargo run -- createsuperuser
1094
1095# Inspect the schema:
1096cargo run -- showmigrations
1097cargo run -- makemigrations
1098```
1099
1100## Styling
1101
1102The pages use Tailwind, compiled to `static/css/app.css` and served by the
1103StoragePlugin at `/static`. That bundle ships **prebuilt**, so this project renders
1104correctly with no `npm install`.
1105
1106You only need Node once you edit a template and reach for a utility class that is not
1107already in the bundle:
1108
1109```bash
1110cd styles
1111npm install
1112npm run build      # or: npm run watch
1113```
1114
1115The palette lives in `styles/input.css` as CSS variables (`--accent` is the violet).
1116Change them there and every page follows. There is deliberately no `cdn.tailwindcss.com`
1117script: it is versionless, it pulls a third party into every page load, and it is the
1118first thing a `default-src 'self'` Content-Security-Policy blocks.
1119
1120## Where to go next
1121
1122- Add a plugin: `umbral startapp posts`
1123- Your first app: {docs}/getting-started/your-first-app
1124- Models & the ORM: {docs}/orm/models
1125- Migrations: {docs}/migrations/managed-migrations
1126- Admin: {docs}/plugins/admin
1127- REST: {docs}/rest/index
1128- Login & signup pages: {docs}/auth/login-and-signup-pages
1129- The Plugin trait: {docs}/plugins/the-plugin-trait
1130"#,
1131        docs = DOCS_URL,
1132    );
1133    write_file(&root, "README.md", &readme, &mut files)?;
1134
1135    // ------------------------------------------------------------------ //
1136    // templates/ + styles/ + static/  — the design system                 //
1137    //                                                                      //
1138    // These live as real files under `crates/umbral-cli/assets/scaffold/`  //
1139    // rather than as string literals, so the templates can be edited (and  //
1140    // the Tailwind bundle actually COMPILED) like the HTML and CSS they    //
1141    // are. `__PROJECT__` / `__INITIAL__` / `__DOCS__` are substituted here.//
1142    // ------------------------------------------------------------------ //
1143    let initial = name
1144        .chars()
1145        .next()
1146        .map(|c| c.to_uppercase().to_string())
1147        .unwrap_or_else(|| "U".to_string());
1148    let fill = |tpl: &str| -> String {
1149        tpl.replace("__PROJECT__", name)
1150            .replace("__INITIAL__", &initial)
1151            .replace("__DOCS__", DOCS_URL)
1152    };
1153
1154    for (path, body) in [
1155        (
1156            "templates/base.html",
1157            include_str!("../assets/scaffold/templates/base.html"),
1158        ),
1159        (
1160            "templates/home.html",
1161            include_str!("../assets/scaffold/templates/home.html"),
1162        ),
1163        (
1164            "templates/dashboard.html",
1165            include_str!("../assets/scaffold/templates/dashboard.html"),
1166        ),
1167        (
1168            "templates/404.html",
1169            include_str!("../assets/scaffold/templates/404.html"),
1170        ),
1171        (
1172            "templates/500.html",
1173            include_str!("../assets/scaffold/templates/500.html"),
1174        ),
1175        (
1176            "styles/input.css",
1177            include_str!("../assets/scaffold/styles/input.css"),
1178        ),
1179        (
1180            "styles/tailwind.config.js",
1181            include_str!("../assets/scaffold/styles/tailwind.config.js"),
1182        ),
1183        (
1184            "styles/package.json",
1185            include_str!("../assets/scaffold/styles/package.json"),
1186        ),
1187        // The COMPILED bundle, shipped prebuilt. A brand-new project renders correctly
1188        // with no npm install — `npm run build` in styles/ is only needed once you edit
1189        // the templates and use a utility class that isn't already in here.
1190        (
1191            "static/css/app.css",
1192            include_str!("../assets/scaffold/static/css/app.css"),
1193        ),
1194    ] {
1195        write_file(&root, path, &fill(body), &mut files)?;
1196    }
1197
1198    let next_steps = vec![
1199        format!("cd {name}"),
1200        "cargo run -- migrate  # apply schema migrations".to_string(),
1201        "cargo run -- serve    # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1202        "cargo run -- createsuperuser  # create an admin login".to_string(),
1203        "umbral startapp <name>          # add another app to this project".to_string(),
1204    ];
1205
1206    Ok(ScaffoldReport {
1207        root,
1208        files,
1209        next_steps,
1210        cargo_toml_registered: None,
1211        // `startproject` has nothing to register itself with — it IS the project.
1212        registered: None,
1213    })
1214}
1215
1216/// Write a new plugin crate at `<project_root>/plugins/<name>/`, using
1217/// the per-concern layout (gaps2 #8):
1218///
1219/// ```text
1220/// plugins/<name>/
1221/// ├── Cargo.toml
1222/// └── src/
1223///     ├── lib.rs     — the `Plugin` impl (name/models/routes/on_ready)
1224///     ├── models.rs  — `#[derive(Model)]` structs
1225///     ├── views.rs   — HTTP handlers
1226///     └── urls.rs    — the URL conf (`router()`): the route table
1227/// ```
1228///
1229/// `lib.rs` declares a `{Name}Plugin` struct whose `routes()` returns
1230/// `urls::router()`. The new crate is auto-registered as a path dep in
1231/// the project's `Cargo.toml` (see [`register_dep_in_cargo_toml`]); the
1232/// user then wires it into their App by adding `.plugin(...)` to the
1233/// builder chain — the next_steps in the returned report spell out the
1234/// exact lines.
1235pub fn scaffold_app(
1236    name: &str,
1237    project_root: &Path,
1238    local_umbral_repo: Option<&Path>,
1239) -> Result<ScaffoldReport, ScaffoldError> {
1240    // The reserved-name check comes FIRST. Some built-in plugin names (`static`)
1241    // are also Rust keywords, so the identifier check would refuse them too —
1242    // but with "invalid name", when the useful thing to say is "that is a
1243    // built-in umbral plugin, and here are the reserved names". Same refusal,
1244    // better message.
1245    //
1246    // Reject names that collide with built-in umbral plugins. Both crates
1247    // would compile, but the user could never register both via
1248    // `.plugin(...)` without aliasing — and the table-name conflicts
1249    // would surface at boot, not at startapp time.
1250    let normalized = name.replace('-', "_");
1251    if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1252        return Err(ScaffoldError::ReservedName(name.to_string()));
1253    }
1254
1255    validate_name(name)?;
1256
1257    let plugins_dir = project_root.join("plugins");
1258    let root = plugins_dir.join(name);
1259    if root.exists() {
1260        return Err(ScaffoldError::AlreadyExists(root));
1261    }
1262
1263    fs::create_dir_all(&root)?;
1264    fs::create_dir_all(root.join("src"))?;
1265
1266    let crate_name = rust_ident(name);
1267    let pascal = pascal_case_from_ident(name);
1268    let mut files = Vec::new();
1269
1270    let version = env!("CARGO_PKG_VERSION");
1271    let cargo_toml = format!(
1272        r#"[package]
1273name = "{name}"
1274version = "0.1.0"
1275edition = "2024"
1276
1277[dependencies]
1278umbral = "{version}"
1279serde = {{ version = "1", features = ["derive"] }}
1280sqlx = {{ version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }}
1281chrono = {{ version = "0.4", features = ["serde"] }}
1282"#
1283    );
1284    let cargo_toml = match local_umbral_repo {
1285        Some(repo) => localize_deps(&cargo_toml, repo),
1286        None => cargo_toml,
1287    };
1288    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1289
1290    let lib_rs = format!(
1291        r#"//! {pascal}Plugin — generated by `umbral startapp {name}`.
1292//!
1293//! A plugin split one file per concern:
1294//!
1295//!   src/
1296//!     lib.rs     — the `Plugin` impl: glues models + routes together (this file)
1297//!     models.rs  — `#[derive(Model)]` structs (this app's tables)
1298//!     views.rs   — HTTP handlers
1299//!     urls.rs    — the URL conf: maps paths to `views::` handlers
1300//!
1301//! Wire this into your App by adding to `src/main.rs`:
1302//!
1303//! ```ignore
1304//! .plugin({crate_name}::{pascal}Plugin::default())
1305//! ```
1306//!
1307//! See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for
1308//! what each `Plugin` method does. This layout is a recommended
1309//! convention — the framework only needs a type that impls `Plugin`.
1310
1311pub mod models;
1312pub mod urls;
1313pub mod views;
1314
1315use umbral::plugin::{{AppContext, Plugin, PluginError}};
1316use umbral::web::Router;
1317
1318#[derive(Debug, Default, Clone)]
1319pub struct {pascal}Plugin;
1320
1321impl Plugin for {pascal}Plugin {{
1322    fn name(&self) -> &'static str {{
1323        "{name}"
1324    }}
1325
1326    fn models(&self) -> Vec<umbral::migrate::ModelMeta> {{
1327        // Register every model the plugin owns so makemigrations
1328        // picks them up. Uncomment + extend once you've defined one
1329        // in src/models.rs.
1330        // vec![umbral::migrate::ModelMeta::for_::<models::Example>()]
1331        Vec::new()
1332    }}
1333
1334    fn routes(&self) -> Router {{
1335        // Routes live in `urls.rs` (this app's URL conf), one place to
1336        // see every path the plugin serves.
1337        urls::router()
1338    }}
1339
1340    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1341        Ok(())
1342    }}
1343}}
1344"#
1345    );
1346    write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1347
1348    // IMP-4 from bugs/tests/testBugs.md: startapp scaffolds a
1349    // `models.rs` stub so the user has an obvious place to declare
1350    // their first `#[derive(Model)]` struct.
1351    let models_rs = format!(
1352        r#"//! Models for the `{name}` plugin.
1353//!
1354//! Declare one `#[derive(umbral::orm::Model)]` struct per database
1355//! table. Once registered via `Plugin::models()` in lib.rs, the
1356//! migration engine picks them up on the next `makemigrations`.
1357//!
1358//! ```ignore
1359//! use chrono::{{DateTime, Utc}};
1360//! use serde::{{Deserialize, Serialize}};
1361//!
1362//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1363//! pub struct Example {{
1364//!     pub id: i64,
1365//!     #[umbral(string, max_length = 200)]
1366//!     pub title: String,
1367//!     #[umbral(noedit)]
1368//!     pub created_at: DateTime<Utc>,
1369//! }}
1370//! ```
1371"#
1372    );
1373    write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1374
1375    // src/views.rs — HTTP handlers for this plugin. One sample `index`
1376    // handler so `urls.rs` has something to route to out of the box.
1377    let views_rs = format!(
1378        r#"//! HTTP handlers for the `{name}` plugin.
1379//!
1380//! Each handler is an axum handler — return anything that implements
1381//! `IntoResponse` (`Html<String>`, `Json<T>`, `&'static str`, a
1382//! `Result<_, (StatusCode, String)>`, …). Read this app's data through
1383//! the ORM (`models::*::objects()`), never raw SQL.
1384//!
1385//! Routes that reach these handlers are declared in `urls.rs`.
1386
1387/// Sample landing handler. `GET /{name}/` hits this; rewire the path in
1388/// `urls.rs`.
1389pub async fn index() -> &'static str {{
1390    "Hello from the {name} plugin"
1391}}
1392"#
1393    );
1394    write_file(&root, "src/views.rs", &views_rs, &mut files)?;
1395
1396    // src/urls.rs — the plugin's URL conf (the route table). One place
1397    // that maps every path to a `views::` handler.
1398    let urls_rs = format!(
1399        r#"//! URL conf for the `{name}` plugin — the route table.
1400//! `router()` returns the axum `Router` that
1401//! `Plugin::routes()` in lib.rs hands back to the framework.
1402//!
1403//! Convention: `/<name>/...` for HTML pages, `/api/<name>/...` for JSON.
1404//! Map each path to a handler in `views.rs` so this file reads as the
1405//! single index of everything the plugin serves.
1406
1407use umbral::web::{{Router, get}};
1408
1409use crate::views;
1410
1411/// Build this plugin's route table. Add one `.route(path, method(handler))`
1412/// line per endpoint.
1413pub fn router() -> Router {{
1414    Router::new().route("/{name}/", get(views::index))
1415}}
1416"#
1417    );
1418    write_file(&root, "src/urls.rs", &urls_rs, &mut files)?;
1419
1420    // Auto-register the new crate as a path dep in the project's Cargo.toml.
1421    // This is a best-effort step: if it fails (e.g. the user ran startapp
1422    // from a directory that isn't a Cargo project), we warn but don't roll
1423    // back the scaffold files already written.
1424    let project_cargo_toml = project_root.join("Cargo.toml");
1425    let cargo_toml_registered = if project_cargo_toml.is_file() {
1426        register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1427    } else {
1428        None
1429    };
1430
1431    let next_steps = vec![
1432        "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1433        format!("    .plugin({crate_name}::{pascal}Plugin::default())"),
1434        "(The plugin crate was auto-added to your project dependencies.)".to_string(),
1435        "Declare your first model in src/models.rs and uncomment the".to_string(),
1436        "    `Plugin::models()` line in src/lib.rs.".to_string(),
1437        "Add handlers in src/views.rs and route them in src/urls.rs.".to_string(),
1438    ];
1439
1440    Ok(ScaffoldReport {
1441        root,
1442        files,
1443        next_steps,
1444        cargo_toml_registered,
1445        registered: None,
1446    })
1447}
1448
1449/// Write a richer plugin scaffold at `<project_root>/plugins/<name>/`
1450/// targeted at *distributable* / reusable plugins (third-party crates
1451/// you'd publish or share across projects). Layout:
1452///
1453/// ```text
1454/// plugins/<name>/
1455/// ├── Cargo.toml         — deps: umbral, serde, sqlx, chrono, async-trait
1456/// ├── README.md          — what this plugin does, how to wire it
1457/// └── src/
1458///     ├── lib.rs         — Plugin trait impl, glues models + routes
1459///     ├── models.rs      — one example Model showing common field types
1460///     │                    (Text + max_length, Choice enum, optional DateTime)
1461///     └── handlers.rs    — one example axum handler using AppContext
1462/// ```
1463///
1464/// Contrast with [`scaffold_app`], which writes a minimal skeleton
1465/// (Cargo.toml + lib.rs with a stub Plugin impl, nothing else). Use
1466/// `startplugin` when you're building a plugin you intend to ship; use
1467/// `startapp` for an internal module that just needs a `Plugin` seam.
1468pub fn scaffold_plugin(
1469    name: &str,
1470    project_root: &Path,
1471    local_umbral_repo: Option<&Path>,
1472) -> Result<ScaffoldReport, ScaffoldError> {
1473    // Reserved first, then the identifier rules — see `scaffold_app`.
1474    let normalized = name.replace('-', "_");
1475    if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1476        return Err(ScaffoldError::ReservedName(name.to_string()));
1477    }
1478
1479    validate_name(name)?;
1480
1481    let plugins_dir = project_root.join("plugins");
1482    let root = plugins_dir.join(name);
1483    if root.exists() {
1484        return Err(ScaffoldError::AlreadyExists(root));
1485    }
1486
1487    fs::create_dir_all(&root)?;
1488    fs::create_dir_all(root.join("src"))?;
1489
1490    let crate_name = rust_ident(name);
1491    let pascal = pascal_case_from_ident(name);
1492    let mut files = Vec::new();
1493
1494    // Cargo.toml — pulls in the deps the example modules use. async-
1495    // trait is here because Plugin trait methods are sync today, but
1496    // the generated handlers.rs example uses an async axum extractor,
1497    // and most plugins grow async work quickly. Cheap to ship now,
1498    // saves the user a Cargo.toml edit later.
1499    let version = env!("CARGO_PKG_VERSION");
1500    let cargo_toml = format!(
1501        r#"[package]
1502name = "{name}"
1503version = "0.1.0"
1504edition = "2024"
1505description = "A {crate_name} plugin for umbral."
1506
1507[dependencies]
1508umbral = "{version}"
1509serde = {{ version = "1", features = ["derive"] }}
1510sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1511chrono = {{ version = "0.4", features = ["serde"] }}
1512async-trait = "0.1"
1513"#
1514    );
1515    let cargo_toml = match local_umbral_repo {
1516        Some(repo) => localize_deps(&cargo_toml, repo),
1517        None => cargo_toml,
1518    };
1519    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1520
1521    // README.md — the user-facing tour. Mirrors the file structure so
1522    // a reader who clones the crate knows where to look first.
1523    let readme = format!(
1524        r#"# {name}
1525
1526A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1527
1528Generated by `umbral startplugin {name}`.
1529
1530## What's inside
1531
1532| File | Purpose |
1533|---|---|
1534| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1535| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1536| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1537
1538## Wiring it in
1539
1540In your project's `Cargo.toml`:
1541
1542```toml
1543[dependencies]
1544{name} = {{ path = "plugins/{name}" }}
1545```
1546
1547In `src/main.rs`:
1548
1549```rust,ignore
1550let app = umbral::App::builder()
1551    .plugin({crate_name}::{pascal}Plugin::default())
1552    // ... your other plugins
1553    .build()?;
1554```
1555
1556Then:
1557
1558```sh
1559cargo run -- makemigrations   # generates 0001_initial.json from your models
1560cargo run -- migrate          # applies the schema
1561cargo run -- serve            # boots the HTTP server
1562```
1563
1564## Next steps
1565
1566- Add your own models in `src/models.rs` (or split into a `models/` module).
1567- Add routes in `routes()` and handlers in `src/handlers.rs`.
1568- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1569- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1570"#
1571    );
1572    write_file(&root, "README.md", &readme, &mut files)?;
1573
1574    // src/lib.rs — Plugin impl that pulls models + routes from the
1575    // sibling modules. `models()` returns the registered model meta;
1576    // `routes()` returns the axum Router with the example handler.
1577    let lib_rs = format!(
1578        r#"//! {pascal}Plugin — a distributable umbral plugin.
1579//!
1580//! Wire this into your App in `src/main.rs`:
1581//!
1582//! ```ignore
1583//! .plugin({crate_name}::{pascal}Plugin::default())
1584//! ```
1585//!
1586//! See `README.md` for the full file tour.
1587
1588pub mod handlers;
1589pub mod models;
1590
1591use async_trait::async_trait;
1592use umbral::migrate::ModelMeta;
1593use umbral::orm::Model;
1594use umbral::plugin::{{AppContext, Plugin, PluginError}};
1595use umbral::web::{{Router, get}};
1596
1597/// The plugin entry point. Register one instance per `App::builder()`.
1598#[derive(Debug, Default, Clone)]
1599pub struct {pascal}Plugin;
1600
1601#[async_trait]
1602impl Plugin for {pascal}Plugin {{
1603    fn name(&self) -> &'static str {{
1604        "{name}"
1605    }}
1606
1607    /// Models the framework's migration engine should track. Each
1608    /// returned [`ModelMeta`] becomes one row in the
1609    /// `umbral_migrations` tracking table once the initial migration
1610    /// applies.
1611    fn models(&self) -> Vec<ModelMeta> {{
1612        vec![models::{pascal}Item::meta()]
1613    }}
1614
1615    /// HTTP routes contributed by this plugin. The base path is
1616    /// up to you — convention is `/<name>/...` for HTML and
1617    /// `/api/<name>/...` for JSON.
1618    fn routes(&self) -> Router {{
1619        Router::new().route("/{name}/hello", get(handlers::hello))
1620    }}
1621
1622    /// One-shot setup after `App::build()` finishes. Use this for
1623    /// seeding default rows, registering signal handlers, or any
1624    /// work that needs the database available. Sync because the
1625    /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1626    /// reach into a runtime via `tokio::runtime::Handle::current()
1627    /// .block_on(...)` if you need to await something here.
1628    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1629        Ok(())
1630    }}
1631}}
1632"#
1633    );
1634    write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1635
1636    // src/models.rs — one Model showing the field types most plugins
1637    // need: a Text with max_length, a Choice enum, an optional
1638    // DateTime. Keeps it small enough to read in one screen.
1639    let models_rs = format!(
1640        r#"//! Example model. Replace or extend with your own.
1641//!
1642//! What this demonstrates:
1643//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1644//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1645//!   `enum` and a Postgres `CHECK (col IN (...))` constraint.
1646//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1647//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1648//!   PUT/PATCH through the REST plugin.
1649
1650use chrono::{{DateTime, Utc}};
1651use serde::{{Deserialize, Serialize}};
1652
1653/// One {crate_name} item. Replace with whatever your plugin actually
1654/// stores.
1655#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1656pub struct {pascal}Item {{
1657    /// Auto-incrementing primary key.
1658    pub id: i64,
1659
1660    /// Display title. Capped at 200 chars; admin renders a single-line
1661    /// input.
1662    #[umbral(string, max_length = 200)]
1663    pub title: String,
1664
1665    /// Lifecycle state. The choices map 1:1 to enum variants; the
1666    /// migration engine emits a CHECK constraint, the admin renders a
1667    /// `<select>`, and the OpenAPI schema gets an `enum` array.
1668    pub status: {pascal}Status,
1669
1670    /// When the item was last published. Read-only on edit forms.
1671    #[umbral(noedit)]
1672    pub published_at: Option<DateTime<Utc>>,
1673}}
1674
1675/// Lifecycle state for [`{pascal}Item`].
1676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
1677#[sqlx(rename_all = "lowercase")]
1678#[serde(rename_all = "lowercase")]
1679pub enum {pascal}Status {{
1680    Draft,
1681    Review,
1682    Published,
1683    Archived,
1684}}
1685"#
1686    );
1687    write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1688
1689    // src/handlers.rs — one axum handler returning JSON. Shows the
1690    // Query extractor + the framework's Json response shape.
1691    let handlers_rs = format!(
1692        r#"//! Example HTTP handlers. Replace or extend with your own.
1693//!
1694//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1695
1696use serde::{{Deserialize, Serialize}};
1697use umbral::web::{{Json, extract::Query}};
1698
1699#[derive(Debug, Deserialize, Default)]
1700pub struct HelloParams {{
1701    /// Who to greet. Defaults to "{name}" when omitted.
1702    #[serde(default)]
1703    pub name: Option<String>,
1704}}
1705
1706#[derive(Debug, Serialize)]
1707pub struct HelloResponse {{
1708    pub greeting: String,
1709}}
1710
1711pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1712    let who = params.name.as_deref().unwrap_or("{name}");
1713    Json(HelloResponse {{
1714        greeting: format!("Hello, {{who}}!"),
1715    }})
1716}}
1717"#
1718    );
1719    write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1720
1721    // Auto-register the new crate as a path dep in the project's Cargo.toml.
1722    let project_cargo_toml = project_root.join("Cargo.toml");
1723    let cargo_toml_registered = if project_cargo_toml.is_file() {
1724        register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1725    } else {
1726        None
1727    };
1728
1729    let next_steps = vec![
1730        "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1731        format!("    .plugin({crate_name}::{pascal}Plugin::default())"),
1732        "Generate + apply the initial migration:".to_string(),
1733        "    cargo run -- makemigrations".to_string(),
1734        "    cargo run -- migrate".to_string(),
1735        format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1736    ];
1737
1738    Ok(ScaffoldReport {
1739        root,
1740        files,
1741        next_steps,
1742        cargo_toml_registered,
1743        registered: None,
1744    })
1745}
1746
1747// ===================================================================== //
1748// startcommand (gaps3 #81)                                              //
1749// ===================================================================== //
1750
1751/// Where a scaffolded management command lives: the project's own binary
1752/// (registered on the App builder via `.commands(commands::all())`) or a
1753/// plugin under `plugins/<name>/` (returned from its `Plugin::commands()`,
1754/// so it travels with the plugin).
1755///
1756/// This is `umbral::codegen::Target` — the same "root or which plugin?" every
1757/// generator asks, including the ones plugins ship (`umbral-rest`'s
1758/// `startpermission` and friends). Two enums saying the same thing is one
1759/// enum too many.
1760pub use umbral::codegen::Target as CommandTarget;
1761
1762/// The marker line the scaffolder inserts new module declarations above.
1763const MODS_MARKER: &str =
1764    "// umbral:startcommand — `umbral startcommand` declares new modules above this line.";
1765/// The marker line the scaffolder inserts new registry entries above.
1766const REGISTRY_MARKER: &str =
1767    "// umbral:startcommand — `umbral startcommand` registers new commands above this line.";
1768
1769/// List the plugins available in this project: every `plugins/<name>/`
1770/// directory that holds a `Cargo.toml`.
1771///
1772/// Reads the disk rather than `main.rs`, so a plugin you scaffolded but
1773/// haven't registered yet is still offered as a home for a command. Shared
1774/// with every other generator via `umbral::codegen`.
1775pub use umbral::codegen::discover_plugins;
1776
1777/// Write a management command and register it.
1778///
1779/// Two targets, one shape. Either way the command lands in a
1780/// `commands/<name>.rs` next to a `commands/mod.rs` whose `all()` function
1781/// is the registry, and the registry is wired into the thing that owns it:
1782///
1783/// ```text
1784/// --in root                        --in <plugin>
1785/// src/                             plugins/<plugin>/src/
1786///   main.rs   .commands(all())       lib.rs   fn commands() -> all()
1787///   commands/                        commands/
1788///     mod.rs  pub fn all()             mod.rs  pub fn all()
1789///     <name>.rs                        <name>.rs
1790/// ```
1791///
1792/// ## Why a hand-maintained `all()` and not real auto-detection
1793///
1794/// Rust has no runtime module reflection: nothing can walk `commands/` at
1795/// startup and find the structs in it. The choices are a build script that
1796/// generates the registry, an inventory-style linker-section crate, or a
1797/// registry function the tool maintains. The registry function wins because
1798/// it stays *readable and editable by hand* — you can see every command the
1799/// app has in one place, reorder them, comment one out — and the scaffolder
1800/// keeps it up to date so the common path costs you nothing. The marker
1801/// comments are how it finds its insertion points; delete them and the tool
1802/// falls back to telling you the two lines to add.
1803///
1804/// Calling this a second time with a different name appends to the existing
1805/// `mod.rs` and touches neither `main.rs` nor the plugin's `lib.rs` again.
1806pub fn scaffold_command(
1807    name: &str,
1808    target: &CommandTarget,
1809    project_root: &Path,
1810) -> Result<ScaffoldReport, ScaffoldError> {
1811    // Reserved first: `migrate` and friends deserve the "that is already an
1812    // umbral command" message rather than a generic identifier complaint.
1813    if reserved_command_names().iter().any(|r| r == name) {
1814        return Err(ScaffoldError::ReservedCommandName(name.to_string()));
1815    }
1816
1817    validate_name(name)?;
1818
1819    let module = rust_ident(name);
1820    let pascal = pascal_case_from_ident(name);
1821    let struct_name = format!("{pascal}Command");
1822
1823    // Resolve the crate the command lands in, and the file that owns its
1824    // registry (main.rs registers via the builder; a plugin via its
1825    // `Plugin::commands()` impl). `resolve_target` is shared with every other
1826    // generator, including the ones plugins ship.
1827    let resolved = umbral::codegen::resolve_target(project_root, target)?;
1828    let crate_root = resolved.crate_root.clone();
1829    let owner_file = resolved.owner_file.clone();
1830
1831    let mut files = Vec::new();
1832
1833    // ---------------------------------------------------------------- //
1834    // src/commands/<name>.rs — the command itself. `write_new_file`     //
1835    // refuses to overwrite, so a re-run can't eat an existing command.  //
1836    // ---------------------------------------------------------------- //
1837    umbral::codegen::write_new_file(
1838        &crate_root,
1839        &format!("src/commands/{module}.rs"),
1840        &render_command_file(name, &struct_name, target),
1841        &mut files,
1842    )?;
1843
1844    // ---------------------------------------------------------------- //
1845    // src/commands/mod.rs — the registry. Created on the first command, //
1846    // appended to on every one after.                                   //
1847    // ---------------------------------------------------------------- //
1848    let mod_rs = crate_root.join("src/commands/mod.rs");
1849    let mut next_steps: Vec<String> = Vec::new();
1850    if mod_rs.is_file() {
1851        let text = fs::read_to_string(&mod_rs)?;
1852        match append_to_registry(&text, &module, &struct_name) {
1853            Some(updated) => {
1854                fs::write(&mod_rs, updated)?;
1855                files.push(PathBuf::from("src/commands/mod.rs"));
1856            }
1857            None => {
1858                // The markers are gone — the user restructured the file. Say so
1859                // and hand back the exact two lines rather than guessing where
1860                // they go and corrupting a file we don't understand.
1861                next_steps.push(
1862                    "src/commands/mod.rs has no `umbral:startcommand` markers — add by hand:"
1863                        .to_string(),
1864                );
1865                next_steps.push(format!("    pub mod {module};"));
1866                next_steps.push(format!(
1867                    "    ...and inside `all()`:  Box::new({module}::{struct_name}),"
1868                ));
1869            }
1870        }
1871    } else {
1872        umbral::codegen::write_new_file(
1873            &crate_root,
1874            "src/commands/mod.rs",
1875            &render_registry_file(&module, &struct_name, target),
1876            &mut files,
1877        )?;
1878    }
1879
1880    // ---------------------------------------------------------------- //
1881    // Register the registry with its owner (once — the second command    //
1882    // reuses the same `all()` call).                                     //
1883    // ---------------------------------------------------------------- //
1884    let owner_text = fs::read_to_string(&owner_file)?;
1885    let wiring = match target {
1886        CommandTarget::Root => wire_registry_into_main(&owner_text),
1887        CommandTarget::Plugin(_) => wire_registry_into_plugin(&owner_text),
1888    };
1889    // `registered` is the truth the CLI prints. A partial edit (we added the
1890    // module but could not find the builder chain) counts as NOT registered:
1891    // the command does not run until the user pastes the remaining line.
1892    let registered = match wiring {
1893        Wiring::Updated { text, steps } => {
1894            fs::write(&owner_file, text)?;
1895            let complete = steps.is_empty();
1896            next_steps.extend(steps);
1897            complete
1898        }
1899        Wiring::AlreadyWired => true,
1900        Wiring::Manual(steps) => {
1901            next_steps.extend(steps);
1902            false
1903        }
1904    };
1905
1906    if registered {
1907        next_steps.push(format!("Run it:  cargo run -- {name} --help"));
1908    } else {
1909        next_steps.push(format!(
1910            "Then run it:  cargo run -- {name} --help   (after the steps above — \
1911             it is NOT registered yet)"
1912        ));
1913    }
1914
1915    Ok(ScaffoldReport {
1916        root: crate_root,
1917        files,
1918        next_steps,
1919        cargo_toml_registered: None,
1920        registered: Some(registered),
1921    })
1922}
1923
1924/// Outcome of registering the `commands::all()` registry with the file
1925/// that owns it (`main.rs` for root, the plugin's `lib.rs` otherwise).
1926///
1927/// `Updated` carries leftover manual steps because the two aren't
1928/// exclusive: we can add the `pub mod commands;` line and still be unable
1929/// to touch a hand-written `fn commands()` we don't own. Discarding the
1930/// half that worked to keep the enum tidy would help nobody.
1931enum Wiring {
1932    /// The file was edited. `text` is the new content; `steps` is anything
1933    /// the edit could NOT do and the user must.
1934    Updated { text: String, steps: Vec<String> },
1935    /// Already registered — a previous `startcommand` did it. Nothing to do,
1936    /// which is exactly what makes the second command free.
1937    AlreadyWired,
1938    /// The file doesn't match the shape we know how to edit. Rather than
1939    /// guess, hand the user the lines to paste.
1940    Manual(Vec<String>),
1941}
1942
1943/// Wire `mod commands;` + `.commands(commands::all())` into a project's
1944/// `main.rs`.
1945///
1946/// The builder call is inserted immediately before `.build()` /
1947/// `.build_deferred()`, which is the one anchor every umbral `main.rs` has
1948/// — the chain ends there by definition.
1949fn wire_registry_into_main(text: &str) -> Wiring {
1950    let already_mod = text.lines().any(|l| l.trim() == "mod commands;");
1951    let already_registered = text.contains(".commands(commands::all())");
1952    if already_mod && already_registered {
1953        return Wiring::AlreadyWired;
1954    }
1955
1956    let mut out = text.to_string();
1957    let mut steps: Vec<String> = Vec::new();
1958
1959    if !already_mod {
1960        // Before the first `mod x;` line, so the table of contents at the top
1961        // of main.rs stays alphabetical (`commands` sorts before `seed`).
1962        match umbral::codegen::declare_module(&out, "mod commands;") {
1963            Some(text) => out = text,
1964            None => steps.push("Add to src/main.rs:  mod commands;".to_string()),
1965        }
1966    }
1967
1968    if !already_registered {
1969        match builder_terminal_line(&out) {
1970            Some(idx) => {
1971                let indent: String = out
1972                    .lines()
1973                    .nth(idx)
1974                    .map(|l| l.chars().take_while(|c| c.is_whitespace()).collect())
1975                    .unwrap_or_default();
1976                let call = format!(
1977                    "{indent}// Project-owned management commands (`umbral startcommand`).\n\
1978                     {indent}.commands(commands::all())"
1979                );
1980                out = insert_line_at_before(&out, idx, &call);
1981            }
1982            None => steps.push(
1983                "Add to the App::builder() chain in src/main.rs:  .commands(commands::all())"
1984                    .to_string(),
1985            ),
1986        }
1987    }
1988
1989    if out == text {
1990        if steps.is_empty() {
1991            Wiring::AlreadyWired
1992        } else {
1993            Wiring::Manual(steps)
1994        }
1995    } else {
1996        // Whatever we DID manage to edit is written, and whatever we could not
1997        // is reported. The old code returned `Manual` from inside the second
1998        // branch and dropped `out` on the floor — so a `mod commands;` line it
1999        // had already inserted vanished, and the steps it printed never
2000        // mentioned it. The user pasted the one line they were given and got
2001        // `failed to resolve: use of undeclared module `commands``.
2002        Wiring::Updated { text: out, steps }
2003    }
2004}
2005
2006/// The line index of the `.build()` / `.build_deferred()` that TERMINATES the
2007/// `App::builder()` chain — the only safe place to hang `.commands(...)`.
2008///
2009/// Anchoring on the first `.build()` in the file is wrong, and not
2010/// hypothetically: a `main.rs` that builds anything else first —
2011/// `reqwest::Client::builder()…​.build()?`, a `tracing` subscriber, a
2012/// `SqlitePoolOptions` — hands us that chain's terminal instead, and we splice
2013/// `.commands(commands::all())` into a type that has no such method. The user's
2014/// main.rs stops compiling, in a place they never touched, and the tool reports
2015/// success.
2016///
2017/// So: find `App::builder()` first, and take the first terminal at or after it.
2018/// No `App::builder()` (a project that wires the app elsewhere) → `None`, and
2019/// the caller prints the line to add by hand rather than guessing.
2020fn builder_terminal_line(text: &str) -> Option<usize> {
2021    let builder_at = text.lines().position(|l| l.contains("App::builder()"))?;
2022    text.lines()
2023        .enumerate()
2024        .skip(builder_at)
2025        .find(|(_, l)| {
2026            let t = l.trim_start();
2027            t.starts_with(".build_deferred()") || t.starts_with(".build()")
2028        })
2029        .map(|(idx, _)| idx)
2030}
2031
2032/// Wire `pub mod commands;` + a `Plugin::commands()` impl into a plugin's
2033/// `lib.rs`.
2034///
2035/// The impl method is inserted at the top of the `impl Plugin for ...`
2036/// block. If the plugin already has a `fn commands`, we don't touch it —
2037/// a hand-written one may return more than the registry, and silently
2038/// rewriting someone's trait impl is exactly the kind of "helpful" edit
2039/// that eats work.
2040fn wire_registry_into_plugin(text: &str) -> Wiring {
2041    let already_mod = text.lines().any(|l| l.trim() == "pub mod commands;");
2042    let has_commands_fn = text.contains("fn commands(");
2043    if already_mod && has_commands_fn {
2044        return Wiring::AlreadyWired;
2045    }
2046
2047    let mut out = text.to_string();
2048    let mut steps: Vec<String> = Vec::new();
2049
2050    if !already_mod {
2051        match umbral::codegen::declare_module(&out, "pub mod commands;") {
2052            Some(text) => out = text,
2053            None => steps.push("Add to src/lib.rs:  pub mod commands;".to_string()),
2054        }
2055    }
2056
2057    if !has_commands_fn {
2058        // The header must OPEN the block on this line. `impl Plugin for X` with
2059        // its `{` on a following line (a `where` clause, or just rustfmt on a
2060        // long header) would otherwise get the method spliced in *before* the
2061        // brace, and the plugin's lib.rs would stop parsing — a syntax error
2062        // inside code the user never touched, which is precisely the "generator
2063        // that guesses at a file it doesn't recognise" this module's docs
2064        // promise not to be.
2065        match out
2066            .lines()
2067            .position(|l| l.starts_with("impl Plugin for ") && l.trim_end().ends_with('{'))
2068        {
2069            Some(idx) => {
2070                let method = "\n    fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {\n        \
2071                     // Every command in `src/commands/` — `umbral startcommand`\n        \
2072                     // appends to the registry in `commands/mod.rs`, so this line\n        \
2073                     // never needs to change again.\n        \
2074                     commands::all()\n    }";
2075                out = insert_line_at(&out, idx, method);
2076            }
2077            None => steps.push(
2078                "Add to your `impl Plugin`:  fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> { commands::all() }"
2079                    .to_string(),
2080            ),
2081        }
2082    } else {
2083        steps.push(
2084            "Your plugin already has a `fn commands()` — make sure it returns \
2085             `commands::all()` (or extends it) so the new command is registered."
2086                .to_string(),
2087        );
2088    }
2089
2090    if out == text {
2091        // Nothing we could edit. Everything is a manual step (or, if there are
2092        // none, it was already wired).
2093        if steps.is_empty() {
2094            Wiring::AlreadyWired
2095        } else {
2096            Wiring::Manual(steps)
2097        }
2098    } else {
2099        Wiring::Updated { text: out, steps }
2100    }
2101}
2102
2103/// Insert `line` immediately after line index `idx` of `text`, preserving the
2104/// file's line endings. Delegates to the shared primitive: the hand-rolled copy
2105/// emitted `\n` unconditionally, so wiring a method into a CRLF `lib.rs`
2106/// rewrote every line of it in the user's next diff.
2107fn insert_line_at(text: &str, idx: usize, line: &str) -> String {
2108    umbral::codegen::insert_line_after(text, idx, line)
2109}
2110
2111/// Append a module declaration + a registry entry to an existing
2112/// `commands/mod.rs`, using the marker comments as insertion points.
2113///
2114/// Returns `None` when a marker is missing — the caller then reports the
2115/// lines to add by hand rather than guessing at a file it doesn't
2116/// recognise.
2117fn append_to_registry(text: &str, module: &str, struct_name: &str) -> Option<String> {
2118    // The two halves are checked INDEPENDENTLY. They can legitimately drift
2119    // apart — delete a command file and re-run, or hand-add the `pub mod` line —
2120    // and the old code took the presence of the module declaration as proof
2121    // that the registry entry existed too. It returned the text unchanged, so
2122    // `all()` never got the command, while the CLI cheerfully printed
2123    // "Registered". `cargo run -- <name>` then answered "unknown command" for a
2124    // command the tool had just claimed to wire up.
2125    let mod_line = format!("pub mod {module};");
2126    let entry = format!("Box::new({module}::{struct_name}),");
2127
2128    let mut out = text.to_string();
2129
2130    if !out.lines().any(|l| l.trim() == mod_line) {
2131        out = umbral::codegen::insert_before_marker(&out, MODS_MARKER, &mod_line)?;
2132    }
2133    if !out.lines().any(|l| l.trim() == entry) {
2134        out = umbral::codegen::insert_before_marker(
2135            &out,
2136            REGISTRY_MARKER,
2137            &format!("        {entry}"),
2138        )?;
2139    }
2140    Some(out)
2141}
2142
2143/// Insert `line` immediately *before* line index `idx` of `text`, preserving
2144/// the file's line endings. Delegates to the shared codegen primitive so
2145/// `startcommand` and a plugin's generator treat a user's file identically.
2146fn insert_line_at_before(text: &str, idx: usize, line: &str) -> String {
2147    umbral::codegen::insert_line_before(text, idx, line)
2148}
2149
2150/// The generated `commands/mod.rs` — the registry.
2151fn render_registry_file(module: &str, struct_name: &str, target: &CommandTarget) -> String {
2152    let (owner, wiring) = match target {
2153        CommandTarget::Root => (
2154            "this project",
2155            "`main.rs` passes `all()` to `App::builder().commands(...)`.",
2156        ),
2157        CommandTarget::Plugin(_) => (
2158            "this plugin",
2159            "`lib.rs` returns `all()` from `Plugin::commands()`.",
2160        ),
2161    };
2162    format!(
2163        r#"//! Management commands owned by {owner} — one file per command,
2164//! and `all()` is the registry that hands them to the framework.
2165//!
2166//! {wiring}
2167//!
2168//! Rust can't discover a module by scanning this directory at runtime, so
2169//! `all()` IS the auto-detection: `umbral startcommand` appends to it for
2170//! you (that's what the marker comments below are for). You can also edit
2171//! it by hand — comment a command out and it stops existing, which is
2172//! harder to do with a magic registry you can't see.
2173
2174use umbral::cli::PluginCommand;
2175
2176pub mod {module};
2177{MODS_MARKER}
2178
2179/// Every command {owner} registers.
2180pub fn all() -> Vec<Box<dyn PluginCommand>> {{
2181    vec![
2182        Box::new({module}::{struct_name}),
2183        {REGISTRY_MARKER}
2184    ]
2185}}
2186"#
2187    )
2188}
2189
2190/// The generated `commands/<name>.rs` — one command, showing the three arg
2191/// shapes clap gives you (positional, named value, flag) and how each is
2192/// read back out of `ArgMatches`.
2193fn render_command_file(name: &str, struct_name: &str, target: &CommandTarget) -> String {
2194    // A plugin's command reaches its own models through `crate::models`;
2195    // a root command reaches the project's through `crate::`.
2196    let orm_note = match target {
2197        CommandTarget::Root => "//     use crate::{Post, post};",
2198        CommandTarget::Plugin(_) => "//     use crate::models::{Post, post};",
2199    };
2200    format!(
2201        r#"//! `{name}` — a management command.
2202//!
2203//! ```bash
2204//! cargo run -- {name} --help                       # what it takes
2205//! cargo run -- {name} hello --limit 5 --dry-run    # a real run
2206//! umbral {name} hello --tag a --tag b              # same thing, via the umbral CLI
2207//! ```
2208//!
2209//! Registered through `commands::all()` in `commands/mod.rs`. It runs against
2210//! a fully-built app: settings loaded, pool open, every model registered — so
2211//! the ORM works ambiently here, with no pool to thread through.
2212
2213use umbral::cli::{{CliError, PluginCommand, clap}};
2214
2215/// The `{name}` command.
2216///
2217/// A unit struct is enough when the command is stateless. It doesn't have to
2218/// be: the trait is object-safe over `&self`, so anything the command needs
2219/// configured (a prefix, a client, a channel) can live on the struct and be
2220/// passed in at registration — which is exactly why this is a trait and not a
2221/// bare `fn` pointer.
2222pub struct {struct_name};
2223
2224#[umbral::async_trait]
2225impl PluginCommand for {struct_name} {{
2226    /// Declare the command: its name, its help, and its arguments.
2227    ///
2228    /// This is plain `clap`, so everything clap can do is available here —
2229    /// value parsing and validation, defaults, conflicts, subcommands of your
2230    /// own. Note the import: `umbral::cli::clap`, the framework's own clap.
2231    /// Add `clap` to your Cargo.toml separately and a major-version bump on
2232    /// either side turns into a type mismatch a page long.
2233    fn command(&self) -> clap::Command {{
2234        clap::Command::new("{name}")
2235            // Shown next to the command in `umbral help`. Write it — a command
2236            // with no `about` lists as a dash and nobody discovers it.
2237            .about("TODO: one line on what {name} does")
2238            .long_about(
2239                "TODO: the longer story, shown on `{name} --help`. What it \
2240                 changes, whether it's safe to re-run, what it needs first.",
2241            )
2242            // POSITIONAL argument — `{name} <slug>`. Required, so clap
2243            // rejects the call with a usage error if it's missing and `run`
2244            // never sees a half-formed invocation.
2245            .arg(
2246                clap::Arg::new("slug")
2247                    .required(true)
2248                    .help("The thing to operate on"),
2249            )
2250            // NAMED argument with a value and a default — `--limit 25` / `-l 25`.
2251            // `value_parser` is what makes it a `u64` on the other side rather
2252            // than a string you'd have to parse (and mis-parse) yourself.
2253            .arg(
2254                clap::Arg::new("limit")
2255                    .long("limit")
2256                    .short('l')
2257                    .value_name("N")
2258                    .value_parser(clap::value_parser!(u64))
2259                    .default_value("25")
2260                    .help("How many rows to touch at most"),
2261            )
2262            // REPEATABLE named argument — `--tag a --tag b` collects both.
2263            // `ArgAction::Append` is the difference between the second `--tag`
2264            // overwriting the first and the two accumulating.
2265            .arg(
2266                clap::Arg::new("tag")
2267                    .long("tag")
2268                    .value_name("TAG")
2269                    .action(clap::ArgAction::Append)
2270                    .help("Filter by tag. Repeat for more than one."),
2271            )
2272            // BOOLEAN flag — `--dry-run`, no value. `SetTrue` is what makes it
2273            // a flag rather than an option that demands a value.
2274            .arg(
2275                clap::Arg::new("dry-run")
2276                    .long("dry-run")
2277                    .action(clap::ArgAction::SetTrue)
2278                    .help("Report what would change without writing anything"),
2279            )
2280    }}
2281
2282    /// Run the command. `matches` is this subcommand's own `ArgMatches` —
2283    /// clap has already validated it against `command()` above, so every
2284    /// `get_one` here is reading a value that exists and typechecked.
2285    async fn run(&self, matches: &clap::ArgMatches) -> Result<(), CliError> {{
2286        let slug = matches
2287            .get_one::<String>("slug")
2288            .expect("clap enforces `required(true)`");
2289        let limit = *matches
2290            .get_one::<u64>("limit")
2291            .expect("clap fills in `default_value`");
2292        let tags: Vec<&String> = matches
2293            .get_many::<String>("tag")
2294            .map(Iterator::collect)
2295            .unwrap_or_default();
2296        let dry_run = matches.get_flag("dry-run");
2297
2298        println!("{name}: slug={{slug}} limit={{limit}} tags={{tags:?}} dry_run={{dry_run}}");
2299
2300        // The app is already built by the time this runs, so the ORM is live:
2301        //
2302        {orm_note}
2303        //
2304        //     let posts = Post::objects()
2305        //         .filter(post::PUBLISHED.eq(true))
2306        //         .limit(limit as i64)
2307        //         .fetch()
2308        //         .await?;
2309        //
2310        //     if dry_run {{
2311        //         println!("would touch {{}} post(s)", posts.len());
2312        //         return Ok(());
2313        //     }}
2314        //
2315        // `?` just works: `CliError` is a boxed error, so every umbral error
2316        // converts into it. Return `Err(...)` and the process exits non-zero,
2317        // which is what a CI step or a cron job is watching for.
2318
2319        Ok(())
2320    }}
2321}}
2322"#
2323    )
2324}
2325
2326/// Write a file under `root` at the given relative path. Records the
2327/// relative path in `files` for the user-facing report.
2328fn write_file(
2329    root: &Path,
2330    rel_path: &str,
2331    contents: &str,
2332    files: &mut Vec<PathBuf>,
2333) -> Result<(), ScaffoldError> {
2334    // `write_new_file` refuses to overwrite. The scaffolders that call this all
2335    // create a fresh directory first, so nothing should be in the way — and if
2336    // something IS, silently clobbering it is the last thing a generator should
2337    // do.
2338    umbral::codegen::write_new_file(root, rel_path, contents, files).map_err(Into::into)
2339}
2340
2341/// Attempt to register `<name> = { path = "plugins/<name>" }` under
2342/// `[dependencies]` in the project's `Cargo.toml`.
2343///
2344/// Returns:
2345/// - `Ok(true)`  — dep was added.
2346/// - `Ok(false)` — dep was already present (idempotent; no duplicate written).
2347/// - `Err(_)`    — the file couldn't be read or written. Callers treat this
2348///   as a soft failure: the scaffold files are already on disk, so we warn
2349///   but don't roll them back.
2350///
2351/// The insertion uses minimal string surgery (find the `[dependencies]`
2352/// header, append one line immediately after it) so comments, ordering,
2353/// and formatting of existing deps are preserved. `toml_edit` is not yet
2354/// a dep of umbral-cli; if it's added later this function is the right
2355/// place to switch to it.
2356pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
2357    // Delegates to `umbral::codegen::ensure_dependency`. The copy that used to
2358    // live here matched `<name> =` on ANY line, so a crate listed under
2359    // `[dev-dependencies]` read as already-present (and the dep was never
2360    // added), and it never recognised the `[dependencies.<name>]` table form
2361    // (so it appended a duplicate key and cargo refused the manifest). Both are
2362    // fixed in the shared primitive, and both were being shipped from here.
2363    umbral::codegen::ensure_dependency(
2364        cargo_toml_path,
2365        name,
2366        &format!("{{ path = \"plugins/{name}\" }}"),
2367    )
2368    .map_err(|e| match e {
2369        umbral::codegen::CodegenError::Io(e) => e,
2370        other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()),
2371    })
2372}
2373#[cfg(test)]
2374mod tests {
2375    use super::*;
2376
2377    #[test]
2378    fn validate_name_accepts_simple_identifiers() {
2379        assert!(validate_name("posts").is_ok());
2380        assert!(validate_name("blog_engine").is_ok());
2381        assert!(validate_name("blog-engine").is_ok());
2382        assert!(validate_name("api2").is_ok());
2383    }
2384
2385    #[test]
2386    fn validate_name_rejects_empty() {
2387        assert!(validate_name("").is_err());
2388    }
2389
2390    #[test]
2391    fn validate_name_rejects_leading_digit() {
2392        assert!(validate_name("2cool").is_err());
2393    }
2394
2395    #[test]
2396    fn validate_name_rejects_special_chars() {
2397        assert!(validate_name("foo bar").is_err());
2398        assert!(validate_name("foo!bar").is_err());
2399        assert!(validate_name("foo/bar").is_err());
2400    }
2401
2402    #[test]
2403    fn pascal_case_handles_kebab_and_snake() {
2404        assert_eq!(pascal_case_from_ident("posts"), "Posts");
2405        assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
2406        assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
2407        assert_eq!(pascal_case_from_ident("api2"), "Api2");
2408    }
2409
2410    #[test]
2411    fn rust_ident_replaces_hyphens() {
2412        assert_eq!(rust_ident("blog-engine"), "blog_engine");
2413        assert_eq!(rust_ident("posts"), "posts");
2414    }
2415
2416    #[test]
2417    fn scaffold_app_rejects_reserved_built_in_plugin_names() {
2418        let tmp = tempfile::tempdir().expect("tempdir");
2419        for name in RESERVED_PLUGIN_NAMES {
2420            let result = scaffold_app(name, tmp.path(), None);
2421            assert!(
2422                matches!(result, Err(ScaffoldError::ReservedName(_))),
2423                "expected ReservedName error for `{name}`, got: {result:?}",
2424            );
2425            assert!(
2426                !tmp.path().join("plugins").join(name).exists(),
2427                "directory must NOT be created when name is reserved: {name}",
2428            );
2429        }
2430    }
2431
2432    #[test]
2433    fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
2434        // `static` is reserved; so is `my-static`-anything? No — only
2435        // exact matches. But hyphens should normalize to underscores so
2436        // someone typing `umbral-storage` or `umbral_storage` doesn't slip
2437        // through. We compare on the underscored form.
2438        let tmp = tempfile::tempdir().expect("tempdir");
2439        // Pure name check: built-in names contain no hyphens today, but
2440        // the normalization defends against future built-ins like
2441        // `slack-bot` versus `slack_bot`.
2442        let result = scaffold_app("auth", tmp.path(), None);
2443        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2444    }
2445
2446    #[test]
2447    fn scaffold_app_message_lists_reserved_names() {
2448        let err = ScaffoldError::ReservedName("auth".to_string());
2449        let msg = format!("{err}");
2450        assert!(msg.contains("`auth`"), "error names the offending input");
2451        assert!(
2452            msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
2453            "error lists the reserved set so the user can pick again: {msg}",
2454        );
2455    }
2456
2457    #[test]
2458    fn scaffold_app_already_exists_message_says_app() {
2459        // Gap 39: the AlreadyExists message used to say "target" which
2460        // didn't tell a user that there's an existing APP. The new copy
2461        // names the app directly.
2462        let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
2463        let msg = format!("{err}");
2464        assert!(msg.contains("app already exists"), "got: {msg}");
2465        assert!(msg.contains("plugins/blog"), "got: {msg}");
2466    }
2467
2468    // ----------------------------------------------------------------- //
2469    // scaffold_plugin (gap #63)                                         //
2470    // ----------------------------------------------------------------- //
2471
2472    #[test]
2473    fn scaffold_plugin_writes_richer_layout() {
2474        let tmp = tempfile::tempdir().expect("tempdir");
2475        let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2476
2477        let root = tmp.path().join("plugins").join("widgets");
2478        assert!(root.is_dir());
2479
2480        // The richer layout: README + lib + models + handlers.
2481        for rel in [
2482            "Cargo.toml",
2483            "README.md",
2484            "src/lib.rs",
2485            "src/models.rs",
2486            "src/handlers.rs",
2487        ] {
2488            assert!(
2489                root.join(rel).exists(),
2490                "missing expected file: {rel}; got {:?}",
2491                report.files,
2492            );
2493        }
2494    }
2495
2496    #[test]
2497    fn scaffold_plugin_lib_rs_references_sibling_modules() {
2498        let tmp = tempfile::tempdir().expect("tempdir");
2499        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2500        let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
2501
2502        assert!(
2503            lib.contains("pub mod handlers;"),
2504            "lib.rs must publish handlers"
2505        );
2506        assert!(
2507            lib.contains("pub mod models;"),
2508            "lib.rs must publish models"
2509        );
2510        assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
2511        assert!(
2512            lib.contains("models::WidgetsItem::meta()"),
2513            "models() should register the example model",
2514        );
2515        assert!(
2516            lib.contains("/widgets/hello"),
2517            "routes() should register the example handler",
2518        );
2519    }
2520
2521    #[test]
2522    fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
2523        let tmp = tempfile::tempdir().expect("tempdir");
2524        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2525        let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
2526
2527        assert!(
2528            models.contains("umbral::orm::Model"),
2529            "model derive must reference the framework's Model trait",
2530        );
2531        assert!(
2532            models.contains("max_length = 200"),
2533            "example model should demonstrate max_length",
2534        );
2535        assert!(
2536            models.contains("WidgetsStatus"),
2537            "example model should declare a Choice enum",
2538        );
2539        assert!(
2540            models.contains("noedit"),
2541            "example model should show the noedit attribute",
2542        );
2543    }
2544
2545    #[test]
2546    fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
2547        let tmp = tempfile::tempdir().expect("tempdir");
2548        for name in RESERVED_PLUGIN_NAMES {
2549            let result = scaffold_plugin(name, tmp.path(), None);
2550            assert!(
2551                matches!(result, Err(ScaffoldError::ReservedName(_))),
2552                "expected ReservedName error for `{name}`, got: {result:?}",
2553            );
2554        }
2555    }
2556
2557    #[test]
2558    fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
2559        let tmp = tempfile::tempdir().expect("tempdir");
2560        scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
2561        let result = scaffold_plugin("widgets", tmp.path(), None);
2562        assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
2563    }
2564
2565    // ----------------------------------------------------------------- //
2566    // scaffold_project per-concern layout (gaps2 #8) + SecurityPlugin    //
2567    // default (gaps2 #25)                                                //
2568    // ----------------------------------------------------------------- //
2569
2570    #[test]
2571    fn scaffold_project_writes_per_concern_tree() {
2572        let tmp = tempfile::tempdir().expect("tempdir");
2573        let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2574
2575        let root = tmp.path().join("blog");
2576        assert!(root.is_dir());
2577
2578        // The per-concern tree: views/, seed/, widgets/, plugins/.
2579        for rel in [
2580            "src/main.rs",
2581            "src/views/mod.rs",
2582            "src/views/public.rs",
2583            "src/seed/mod.rs",
2584            "src/seed/credentials.rs",
2585            "src/widgets/mod.rs",
2586            "src/widgets/cards.rs",
2587            "plugins/.gitkeep",
2588            "plugins/README.md",
2589        ] {
2590            assert!(
2591                root.join(rel).exists(),
2592                "missing expected file: {rel}; got {:?}",
2593                report.files,
2594            );
2595        }
2596    }
2597
2598    #[test]
2599    fn scaffold_project_mod_files_carry_orchestrator_markers() {
2600        let tmp = tempfile::tempdir().expect("tempdir");
2601        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2602        let root = tmp.path().join("blog");
2603
2604        let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
2605        assert!(
2606            views_mod.contains("re-export"),
2607            "views/mod.rs should describe itself as the re-export layer",
2608        );
2609        // gaps3 #57. The scaffold used to GENERATE a `fn internal_error` helper into every
2610        // new app — and that helper hands `err.to_string()` to the browser, so a missing
2611        // table or a SQL fragment is printed to whoever asked for the page. The scaffold
2612        // is the first umbral code a developer ever reads; it was teaching the leak.
2613        //
2614        // This assertion is deliberately inverted from what it used to be.
2615        assert!(
2616            !views_mod.contains("fn internal_error"),
2617            "the scaffold must NOT generate an internal_error helper — handlers return \
2618             ApiError, which logs the cause and keeps it off the wire",
2619        );
2620        let views_public = fs::read_to_string(root.join("src/views/public.rs")).unwrap();
2621        assert!(
2622            views_public.contains("Result<Html<String>, ApiError>")
2623                && !views_public.contains("map_err(internal_error)"),
2624            "generated handlers must return ApiError and use a bare `?`",
2625        );
2626
2627        let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
2628        assert!(
2629            seed_mod.contains("pub async fn all()"),
2630            "seed/mod.rs must declare the all() orchestrator",
2631        );
2632        assert!(
2633            seed_mod.contains("credentials::test_credentials()"),
2634            "seed::all() must call the credentials step",
2635        );
2636        assert!(
2637            seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
2638            "seed/mod.rs should explain it pins dependency order",
2639        );
2640
2641        let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
2642        assert!(
2643            credentials.contains("fn test_credentials"),
2644            "credentials.rs must define the test_credentials seed",
2645        );
2646        assert!(
2647            credentials.contains("count().await? > 0"),
2648            "test_credentials must be idempotent (short-circuit on existing users)",
2649        );
2650
2651        let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
2652        assert!(
2653            widgets_mod.contains("pub mod cards;"),
2654            "widgets/mod.rs must publish the cards submodule",
2655        );
2656
2657        let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
2658        assert!(
2659            cards.contains("builtin_total_models_widget")
2660                || cards.contains("builtin_recent_users_widget"),
2661            "cards.rs should re-export a builtin widget so the dashboard isn't empty",
2662        );
2663    }
2664
2665    #[test]
2666    fn scaffold_project_main_declares_modules_and_mounts_security() {
2667        let tmp = tempfile::tempdir().expect("tempdir");
2668        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2669        let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
2670
2671        // The table-of-contents module declarations.
2672        assert!(
2673            main.contains("mod views;"),
2674            "main.rs must declare mod views"
2675        );
2676        assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
2677        assert!(
2678            main.contains("mod widgets;"),
2679            "main.rs must declare mod widgets",
2680        );
2681
2682        // Routes reference the per-concern handlers.
2683        assert!(
2684            main.contains("views::public::home"),
2685            "route table should wire views::public::home",
2686        );
2687        // Boot runs the seed orchestrator.
2688        assert!(
2689            main.contains("seed::all().await"),
2690            "boot should run seed::all()",
2691        );
2692
2693        // SecurityPlugin mounted by default (gaps2 #25).
2694        assert!(
2695            main.contains("SecurityPlugin"),
2696            "SecurityPlugin must be mounted by default",
2697        );
2698    }
2699
2700    #[test]
2701    fn scaffold_project_creates_empty_plugins_dir() {
2702        let tmp = tempfile::tempdir().expect("tempdir");
2703        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2704        let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
2705        assert!(
2706            readme.contains("umbral startapp"),
2707            "plugins/README.md should point at `umbral startapp`",
2708        );
2709    }
2710
2711    // ----------------------------------------------------------------- //
2712    // scaffold_app per-concern plugin layout (gaps2 #8)                  //
2713    // ----------------------------------------------------------------- //
2714
2715    #[test]
2716    fn scaffold_app_writes_per_concern_plugin_layout() {
2717        let tmp = tempfile::tempdir().expect("tempdir");
2718        let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2719
2720        let root = tmp.path().join("plugins").join("posts");
2721        assert!(root.is_dir());
2722
2723        for rel in [
2724            "Cargo.toml",
2725            "src/lib.rs",
2726            "src/models.rs",
2727            "src/views.rs",
2728            "src/urls.rs",
2729        ] {
2730            assert!(
2731                root.join(rel).exists(),
2732                "missing expected file: {rel}; got {:?}",
2733                report.files,
2734            );
2735        }
2736    }
2737
2738    #[test]
2739    fn scaffold_app_lib_wires_urls_and_views() {
2740        let tmp = tempfile::tempdir().expect("tempdir");
2741        scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2742        let root = tmp.path().join("plugins/posts");
2743
2744        let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2745        assert!(
2746            lib.contains("pub mod models;"),
2747            "lib.rs must publish models"
2748        );
2749        assert!(lib.contains("pub mod views;"), "lib.rs must publish views");
2750        assert!(lib.contains("pub mod urls;"), "lib.rs must publish urls");
2751        assert!(
2752            lib.contains("urls::router()"),
2753            "routes() must return urls::router()",
2754        );
2755        assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2756
2757        let urls = fs::read_to_string(root.join("src/urls.rs")).unwrap();
2758        assert!(
2759            urls.contains("pub fn router() -> Router"),
2760            "urls.rs must expose a router() returning a Router",
2761        );
2762        assert!(
2763            urls.contains("views::index"),
2764            "urls.rs route table should map to a views:: handler",
2765        );
2766
2767        let views = fs::read_to_string(root.join("src/views.rs")).unwrap();
2768        assert!(
2769            views.contains("pub async fn index"),
2770            "views.rs should ship a sample index handler",
2771        );
2772    }
2773
2774    #[test]
2775    fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2776        let tmp = tempfile::tempdir().expect("tempdir");
2777        // Fixture project Cargo.toml with a [dependencies] section.
2778        let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2779        fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2780
2781        let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2782        assert_eq!(
2783            report.cargo_toml_registered,
2784            Some(true),
2785            "the path dep should have been added",
2786        );
2787
2788        let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2789        assert!(
2790            cargo.contains("posts = { path = \"plugins/posts\" }"),
2791            "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2792        );
2793
2794        // Idempotent: a second run reports `false` (already present).
2795        // (Different name would re-add; same name short-circuits.)
2796        let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2797        assert!(!second, "re-registering the same dep must be a no-op");
2798    }
2799
2800    #[test]
2801    fn scaffold_app_still_rejects_reserved_names() {
2802        let tmp = tempfile::tempdir().expect("tempdir");
2803        let result = scaffold_app("auth", tmp.path(), None);
2804        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2805    }
2806
2807    #[test]
2808    fn scaffold_plugin_validates_name_like_startapp() {
2809        let tmp = tempfile::tempdir().expect("tempdir");
2810        assert!(matches!(
2811            scaffold_plugin("2cool", tmp.path(), None),
2812            Err(ScaffoldError::InvalidName(_))
2813        ));
2814        assert!(matches!(
2815            scaffold_plugin("foo bar", tmp.path(), None),
2816            Err(ScaffoldError::InvalidName(_))
2817        ));
2818    }
2819
2820    // ----------------------------------------------------------------- //
2821    // startcommand (gaps3 #81)                                           //
2822    // ----------------------------------------------------------------- //
2823
2824    /// A real scaffolded project to run `startcommand` against — the same
2825    /// `main.rs` a user gets from `umbral startproject`, so the wiring
2826    /// surgery is exercised against the file it actually has to edit, not a
2827    /// fixture written to make the test pass.
2828    fn project(tmp: &tempfile::TempDir) -> PathBuf {
2829        scaffold_project("demo", tmp.path(), None).expect("scaffold_project");
2830        tmp.path().join("demo")
2831    }
2832
2833    fn read(root: &Path, rel: &str) -> String {
2834        fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
2835    }
2836
2837    #[test]
2838    fn startcommand_root_writes_the_command_and_wires_main() {
2839        let tmp = tempfile::tempdir().expect("tempdir");
2840        let root = project(&tmp);
2841
2842        let report =
2843            scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("scaffold");
2844        assert!(
2845            report
2846                .files
2847                .contains(&PathBuf::from("src/commands/backfill_slugs.rs"))
2848        );
2849        assert!(report.files.contains(&PathBuf::from("src/commands/mod.rs")));
2850
2851        // The command file: right struct, right trait, framework's clap.
2852        let cmd = read(&root, "src/commands/backfill_slugs.rs");
2853        assert!(cmd.contains("pub struct BackfillSlugsCommand;"), "{cmd}");
2854        assert!(
2855            cmd.contains("impl PluginCommand for BackfillSlugsCommand"),
2856            "{cmd}"
2857        );
2858        assert!(
2859            cmd.contains("use umbral::cli::{CliError, PluginCommand, clap};"),
2860            "the generated file must import the framework's clap, not its own: {cmd}"
2861        );
2862        assert!(
2863            cmd.contains(r#"clap::Command::new("backfill_slugs")"#),
2864            "{cmd}"
2865        );
2866
2867        // The registry.
2868        let registry = read(&root, "src/commands/mod.rs");
2869        assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2870        assert!(
2871            registry.contains("Box::new(backfill_slugs::BackfillSlugsCommand),"),
2872            "{registry}"
2873        );
2874
2875        // The wiring: main.rs declares the module AND registers the registry.
2876        let main_rs = read(&root, "src/main.rs");
2877        assert!(
2878            main_rs.contains("mod commands;"),
2879            "main.rs never declared the module: {main_rs}"
2880        );
2881        assert!(
2882            main_rs.contains(".commands(commands::all())"),
2883            "main.rs never registered the command registry: {main_rs}"
2884        );
2885        // ...and it goes INSIDE the builder chain, before the terminal build.
2886        let reg = main_rs.find(".commands(commands::all())").unwrap();
2887        let build = main_rs.find(".build_deferred()").unwrap();
2888        assert!(
2889            reg < build,
2890            "`.commands(...)` landed after `.build_deferred()`, which doesn't compile"
2891        );
2892    }
2893
2894    /// The whole reason `all()` exists: the SECOND command is free. It
2895    /// appends to the registry and touches `main.rs` exactly zero more
2896    /// times — no duplicate `mod commands;`, no second `.commands(...)`
2897    /// call (which wouldn't compile as a duplicate... it would silently
2898    /// register the same list twice).
2899    #[test]
2900    fn startcommand_second_command_appends_and_leaves_main_alone() {
2901        let tmp = tempfile::tempdir().expect("tempdir");
2902        let root = project(&tmp);
2903
2904        scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("first");
2905        let main_after_first = read(&root, "src/main.rs");
2906        scaffold_command("import-prices", &CommandTarget::Root, &root).expect("second");
2907        let main_after_second = read(&root, "src/main.rs");
2908
2909        assert_eq!(
2910            main_after_first, main_after_second,
2911            "the second startcommand edited main.rs again"
2912        );
2913
2914        let registry = read(&root, "src/commands/mod.rs");
2915        assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2916        // A hyphenated command name becomes a snake_case module and a
2917        // PascalCase struct, while the CLI name keeps its hyphen.
2918        assert!(registry.contains("pub mod import_prices;"), "{registry}");
2919        assert!(
2920            registry.contains("Box::new(import_prices::ImportPricesCommand),"),
2921            "{registry}"
2922        );
2923        let cmd = read(&root, "src/commands/import_prices.rs");
2924        assert!(
2925            cmd.contains(r#"clap::Command::new("import-prices")"#),
2926            "the clap name should be what the user typed, hyphens and all: {cmd}"
2927        );
2928
2929        assert_eq!(
2930            main_after_second
2931                .matches(".commands(commands::all())")
2932                .count(),
2933            1,
2934            "main.rs registered the registry twice"
2935        );
2936        assert_eq!(
2937            main_after_second.matches("\nmod commands;").count(),
2938            1,
2939            "main.rs declared `mod commands;` twice"
2940        );
2941    }
2942
2943    #[test]
2944    fn startcommand_plugin_writes_the_command_and_wires_the_plugin() {
2945        let tmp = tempfile::tempdir().expect("tempdir");
2946        let root = project(&tmp);
2947        scaffold_app("blog", &root, None).expect("scaffold_app");
2948
2949        scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
2950            .expect("scaffold");
2951
2952        let plugin_root = root.join("plugins/blog");
2953        let registry = read(&plugin_root, "src/commands/mod.rs");
2954        assert!(registry.contains("pub mod reindex;"), "{registry}");
2955        assert!(
2956            registry.contains("Box::new(reindex::ReindexCommand),"),
2957            "{registry}"
2958        );
2959
2960        let lib_rs = read(&plugin_root, "src/lib.rs");
2961        assert!(lib_rs.contains("pub mod commands;"), "{lib_rs}");
2962        assert!(
2963            lib_rs.contains("fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>>"),
2964            "the plugin never got a `Plugin::commands()` impl: {lib_rs}"
2965        );
2966        assert!(
2967            lib_rs.contains("commands::all()"),
2968            "the impl doesn't return the registry: {lib_rs}"
2969        );
2970        // The method has to land INSIDE the impl block, not after it.
2971        let impl_start = lib_rs.find("impl Plugin for BlogPlugin {").unwrap();
2972        let method = lib_rs.find("fn commands(&self)").unwrap();
2973        assert!(method > impl_start, "the method landed outside the impl");
2974    }
2975
2976    /// `umbral startcommand move` used to sail through validation and write
2977    /// `pub mod move;` into the registry — a syntax error in a file the user
2978    /// never touched. `scaffold_command` was still calling a private copy of
2979    /// the name rules that predated the keyword guard, so the codegen test
2980    /// asserting the correct behaviour passed while the CLI shipped the wrong
2981    /// one. Found by the pre-0.0.10 review sweep.
2982    #[test]
2983    fn startcommand_rejects_a_rust_keyword_as_a_command_name() {
2984        let tmp = tempfile::tempdir().expect("tempdir");
2985        let root = project(&tmp);
2986        for kw in ["move", "type", "match"] {
2987            assert!(
2988                matches!(
2989                    scaffold_command(kw, &CommandTarget::Root, &root),
2990                    Err(ScaffoldError::InvalidName(_))
2991                ),
2992                "`{kw}` is a Rust keyword — `pub mod {kw};` does not parse"
2993            );
2994        }
2995    }
2996
2997    /// A command name that's already a framework built-in would SHADOW it:
2998    /// dispatch tries app/plugin commands before the built-in clap parser.
2999    /// `migrate` would stop migrating, silently. Reject it where the fix is
3000    /// free.
3001    #[test]
3002    fn startcommand_rejects_a_builtin_command_name() {
3003        let tmp = tempfile::tempdir().expect("tempdir");
3004        let root = project(&tmp);
3005        for taken in ["migrate", "serve", "makemigrations", "dev"] {
3006            assert!(
3007                matches!(
3008                    scaffold_command(taken, &CommandTarget::Root, &root),
3009                    Err(ScaffoldError::ReservedCommandName(_))
3010                ),
3011                "`{taken}` is a built-in and must be rejected"
3012            );
3013        }
3014    }
3015
3016    /// Same shadowing hazard, but for a command a built-in *plugin* ships.
3017    /// These can't be read off a clap parser (they only exist on a built
3018    /// App), so they're listed — and the list has to be honoured.
3019    #[test]
3020    fn startcommand_rejects_a_builtin_plugin_command_name() {
3021        let tmp = tempfile::tempdir().expect("tempdir");
3022        let root = project(&tmp);
3023        assert!(matches!(
3024            scaffold_command("createsuperuser", &CommandTarget::Root, &root),
3025            Err(ScaffoldError::ReservedCommandName(_))
3026        ));
3027        assert!(matches!(
3028            scaffold_command("tasks-worker", &CommandTarget::Root, &root),
3029            Err(ScaffoldError::ReservedCommandName(_))
3030        ));
3031    }
3032
3033    /// The reserved set is derived from the clap parser, so a subcommand
3034    /// added to `Command` in lib.rs reserves its own name with no second
3035    /// list to remember to update.
3036    #[test]
3037    fn reserved_command_names_are_read_off_the_real_parser() {
3038        let names = reserved_command_names();
3039        for expected in ["migrate", "serve", "typegen", "squashmigrations", "help"] {
3040            assert!(
3041                names.iter().any(|n| n == expected),
3042                "`{expected}` missing from the reserved set: {names:?}"
3043            );
3044        }
3045    }
3046
3047    #[test]
3048    fn startcommand_rejects_an_unknown_plugin_and_lists_the_real_ones() {
3049        let tmp = tempfile::tempdir().expect("tempdir");
3050        let root = project(&tmp);
3051        scaffold_app("blog", &root, None).expect("scaffold_app");
3052
3053        let err = scaffold_command("reindex", &CommandTarget::Plugin("blgo".into()), &root)
3054            .expect_err("a typo'd plugin name must not scaffold anything");
3055        match err {
3056            ScaffoldError::NoSuchPlugin { asked, available } => {
3057                assert_eq!(asked, "blgo");
3058                assert_eq!(available, vec!["blog".to_string()]);
3059            }
3060            other => panic!("expected NoSuchPlugin, got {other:?}"),
3061        }
3062    }
3063
3064    #[test]
3065    fn startcommand_refuses_to_overwrite_an_existing_command() {
3066        let tmp = tempfile::tempdir().expect("tempdir");
3067        let root = project(&tmp);
3068        scaffold_command("reindex", &CommandTarget::Root, &root).expect("first");
3069        assert!(matches!(
3070            scaffold_command("reindex", &CommandTarget::Root, &root),
3071            Err(ScaffoldError::AlreadyExists(_))
3072        ));
3073    }
3074
3075    #[test]
3076    fn startcommand_outside_a_project_says_so() {
3077        let tmp = tempfile::tempdir().expect("tempdir");
3078        assert!(matches!(
3079            scaffold_command("reindex", &CommandTarget::Root, tmp.path()),
3080            Err(ScaffoldError::NotAProject(_))
3081        ));
3082    }
3083
3084    #[test]
3085    fn discover_plugins_lists_plugin_crates_only() {
3086        let tmp = tempfile::tempdir().expect("tempdir");
3087        let root = project(&tmp);
3088        // A fresh project has an empty `plugins/` (a .gitkeep + README, no crates).
3089        assert!(discover_plugins(&root).is_empty());
3090
3091        scaffold_app("blog", &root, None).expect("scaffold_app");
3092        scaffold_app("shop", &root, None).expect("scaffold_app");
3093        // A stray directory with no Cargo.toml isn't a plugin and must not be
3094        // offered as a home for a command.
3095        fs::create_dir_all(root.join("plugins/notacrate")).unwrap();
3096
3097        assert_eq!(
3098            discover_plugins(&root),
3099            vec!["blog".to_string(), "shop".to_string()]
3100        );
3101    }
3102
3103    // ----------------------------------------------------------------- //
3104    // Regressions found by the pre-0.0.10 review sweep                    //
3105    // ----------------------------------------------------------------- //
3106
3107    /// The `.build()` anchor must belong to the **App** chain. A main.rs that
3108    /// builds anything else first (an HTTP client, a subscriber, a pool) used
3109    /// to capture the insertion: `.commands(commands::all())` was spliced into
3110    /// `reqwest::Client::builder()`, which has no such method. The user's
3111    /// main.rs stopped compiling — in code they never wrote — and the tool
3112    /// printed "Registered".
3113    #[test]
3114    fn startcommand_does_not_splice_into_someone_elses_builder_chain() {
3115        let tmp = tempfile::tempdir().expect("tempdir");
3116        let root = project(&tmp);
3117
3118        let main_rs = root.join("src/main.rs");
3119        let original = read(&root, "src/main.rs");
3120        // A second builder chain, ABOVE the App's, whose terminal `.build()?`
3121        // is the first one in the file.
3122        let with_client = original.replace(
3123            "    let settings = Settings::from_env()?;",
3124            "    let client = reqwest::Client::builder()\n\
3125             \x20       .timeout(Duration::from_secs(5))\n\
3126             \x20       .build()?;\n\n\
3127             \x20   let settings = Settings::from_env()?;",
3128        );
3129        assert_ne!(with_client, original, "fixture did not apply");
3130        fs::write(&main_rs, &with_client).unwrap();
3131
3132        scaffold_command("import_prices", &CommandTarget::Root, &root).expect("scaffold");
3133
3134        let after = read(&root, "src/main.rs");
3135        let commands_at = after.find(".commands(commands::all())").expect("wired");
3136        let client_build_at = after.find(".build()?;").expect("client chain still there");
3137        let app_builder_at = after.find("App::builder()").expect("app chain still there");
3138
3139        assert!(
3140            commands_at > client_build_at,
3141            "`.commands(...)` was spliced into the reqwest chain:\n{after}"
3142        );
3143        assert!(
3144            commands_at > app_builder_at,
3145            "`.commands(...)` landed outside the App::builder() chain:\n{after}"
3146        );
3147    }
3148
3149    /// No `App::builder()` to anchor on → decline, and say so. The command file
3150    /// is still written; the user is told the two lines to add.
3151    #[test]
3152    fn startcommand_declines_when_there_is_no_app_builder_chain() {
3153        let tmp = tempfile::tempdir().expect("tempdir");
3154        let root = project(&tmp);
3155        fs::write(
3156            root.join("src/main.rs"),
3157            "mod seed;\n\nfn main() {\n    println!(\"no app here\");\n}\n",
3158        )
3159        .unwrap();
3160
3161        let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
3162
3163        assert_eq!(
3164            report.registered,
3165            Some(false),
3166            "the tool must not claim a registration it could not perform"
3167        );
3168        let steps = report.next_steps.join("\n");
3169        assert!(steps.contains(".commands(commands::all())"), "{steps}");
3170        assert!(root.join("src/commands/backfill.rs").is_file());
3171    }
3172
3173    /// A partially-wired file must keep the edit it DID make, and the steps must
3174    /// name what it could not. The old code inserted `mod commands;` into a
3175    /// local copy, then returned `Manual` and threw the copy away — while
3176    /// printing only the `.commands(...)` line. The user pasted it and got
3177    /// `failed to resolve: use of undeclared module `commands``.
3178    #[test]
3179    fn startcommand_keeps_the_module_declaration_it_managed_to_add() {
3180        let tmp = tempfile::tempdir().expect("tempdir");
3181        let root = project(&tmp);
3182        // Has a `mod x;` line (so the module CAN be declared) but no App chain
3183        // (so the builder call cannot be inserted).
3184        fs::write(
3185            root.join("src/main.rs"),
3186            "mod seed;\n\nfn main() {\n    println!(\"no app\");\n}\n",
3187        )
3188        .unwrap();
3189
3190        let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
3191
3192        let after = read(&root, "src/main.rs");
3193        assert!(
3194            after.contains("mod commands;"),
3195            "the module declaration the tool made was thrown away: {after}"
3196        );
3197        assert_eq!(report.registered, Some(false));
3198        assert!(
3199            report
3200                .next_steps
3201                .join("\n")
3202                .contains(".commands(commands::all())"),
3203            "the user was not told the one step that remained"
3204        );
3205    }
3206
3207    /// A multi-line `impl Plugin for X` header (a `where` clause, or rustfmt
3208    /// wrapping a long one) must not get the method spliced in before its `{`.
3209    #[test]
3210    fn startcommand_declines_a_plugin_impl_whose_brace_is_on_the_next_line() {
3211        let tmp = tempfile::tempdir().expect("tempdir");
3212        let root = project(&tmp);
3213        scaffold_app("blog", &root, None).expect("scaffold_app");
3214
3215        let lib_rs = root.join("plugins/blog/src/lib.rs");
3216        fs::write(
3217            &lib_rs,
3218            "pub mod models;\n\npub struct BlogPlugin;\n\n\
3219             impl Plugin for BlogPlugin\nwhere\n    Self: Send,\n{\n    \
3220             fn name(&self) -> &'static str {\n        \"blog\"\n    }\n}\n",
3221        )
3222        .unwrap();
3223
3224        let report = scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
3225            .expect("scaffold");
3226
3227        let after = read(&root, "plugins/blog/src/lib.rs");
3228        // The impl header is untouched: no method between it and its `where`.
3229        assert!(
3230            after.contains("impl Plugin for BlogPlugin\nwhere\n    Self: Send,\n{"),
3231            "the generator spliced a method into a multi-line impl header:\n{after}"
3232        );
3233        assert_eq!(report.registered, Some(false));
3234        assert!(
3235            report.next_steps.join("\n").contains("fn commands"),
3236            "the user was not told to add the method by hand"
3237        );
3238    }
3239
3240    /// The module declaration and the `all()` entry are checked INDEPENDENTLY.
3241    /// The old early-return took "`pub mod x;` is present" as proof the registry
3242    /// entry was too, skipped it, and still reported success — so `all()` never
3243    /// returned the command and `cargo run -- x` said "unknown command".
3244    #[test]
3245    fn startcommand_repairs_a_registry_missing_only_its_entry() {
3246        let tmp = tempfile::tempdir().expect("tempdir");
3247        let root = project(&tmp);
3248        scaffold_command("backfill", &CommandTarget::Root, &root).expect("first");
3249
3250        // Simulate the drift: the module is declared, the entry is gone.
3251        let mod_rs = root.join("src/commands/mod.rs");
3252        let text = read(&root, "src/commands/mod.rs")
3253            .lines()
3254            .filter(|l| !l.contains("Box::new(backfill::BackfillCommand)"))
3255            .collect::<Vec<_>>()
3256            .join("\n");
3257        fs::write(&mod_rs, format!("{text}\n")).unwrap();
3258        fs::remove_file(root.join("src/commands/backfill.rs")).unwrap();
3259
3260        let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("re-run");
3261
3262        let registry = read(&root, "src/commands/mod.rs");
3263        assert_eq!(
3264            registry.matches("pub mod backfill;").count(),
3265            1,
3266            "duplicate module declaration:\n{registry}"
3267        );
3268        assert!(
3269            registry.contains("Box::new(backfill::BackfillCommand),"),
3270            "the registry entry was never restored, but the command reports as \
3271             registered:\n{registry}"
3272        );
3273        assert_eq!(report.registered, Some(true));
3274    }
3275
3276    /// When a user has restructured `commands/mod.rs` past recognition, the
3277    /// tool must not "helpfully" rewrite a file it doesn't understand. It
3278    /// writes the command and hands back the two lines to add.
3279    #[test]
3280    fn startcommand_reports_manual_steps_when_the_registry_markers_are_gone() {
3281        let tmp = tempfile::tempdir().expect("tempdir");
3282        let root = project(&tmp);
3283        scaffold_command("first", &CommandTarget::Root, &root).expect("first");
3284
3285        let mod_rs = root.join("src/commands/mod.rs");
3286        let mangled = read(&root, "src/commands/mod.rs")
3287            .lines()
3288            .filter(|l| !l.trim().starts_with("// umbral:startcommand"))
3289            .collect::<Vec<_>>()
3290            .join("\n");
3291        fs::write(&mod_rs, &mangled).unwrap();
3292
3293        let report = scaffold_command("second", &CommandTarget::Root, &root).expect("second");
3294
3295        // The registry was NOT touched...
3296        assert_eq!(read(&root, "src/commands/mod.rs"), mangled);
3297        // ...and the user was told exactly what to add.
3298        let steps = report.next_steps.join("\n");
3299        assert!(steps.contains("pub mod second;"), "{steps}");
3300        assert!(steps.contains("Box::new(second::SecondCommand)"), "{steps}");
3301    }
3302}