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