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        // The umbral mark (umbra crescent), served at /static/favicon.svg and
1221        // referenced from base.html — every scaffolded project gets a favicon.
1222        (
1223            "static/favicon.svg",
1224            include_str!("../assets/scaffold/static/favicon.svg"),
1225        ),
1226    ] {
1227        write_file(&root, path, &fill(body), &mut files)?;
1228    }
1229
1230    let next_steps = vec![
1231        format!("cd {name}"),
1232        "cargo run -- migrate  # apply schema migrations".to_string(),
1233        "cargo run -- serve    # boot the HTTP server on http://127.0.0.1:8000".to_string(),
1234        "cargo run -- createsuperuser  # create an admin login".to_string(),
1235        "umbral startplugin <name>       # add a plugin to this project".to_string(),
1236    ];
1237
1238    Ok(ScaffoldReport {
1239        root,
1240        files,
1241        next_steps,
1242        cargo_toml_registered: None,
1243        // `startproject` has nothing to register itself with — it IS the project.
1244        registered: None,
1245    })
1246}
1247
1248/// Deprecated alias for [`scaffold_plugin`]. Everything the framework
1249/// generates under `plugins/` is a *plugin* — there is no separate "app"
1250/// contract — so the old minimal `startapp` writer folds into
1251/// `startplugin` / [`scaffold_plugin`], leaving one generator to maintain.
1252/// Kept as a forwarding shim so existing API callers keep working; the CLI
1253/// `startapp` command forwards here and prints a deprecation note. (Not
1254/// `#[deprecated]` at the Rust level — that would warn on every internal
1255/// test call site; the user-facing deprecation lives on the CLI command.)
1256pub fn scaffold_app(
1257    name: &str,
1258    project_root: &Path,
1259    local_umbral_repo: Option<&Path>,
1260) -> Result<ScaffoldReport, ScaffoldError> {
1261    scaffold_plugin(name, project_root, local_umbral_repo)
1262}
1263
1264/// Write a richer plugin scaffold at `<project_root>/plugins/<name>/`
1265/// targeted at *distributable* / reusable plugins (third-party crates
1266/// you'd publish or share across projects). Layout:
1267///
1268/// ```text
1269/// plugins/<name>/
1270/// ├── Cargo.toml         — deps: umbral, serde, sqlx, chrono, async-trait
1271/// ├── README.md          — what this plugin does, how to wire it
1272/// └── src/
1273///     ├── lib.rs         — Plugin trait impl, glues models + routes
1274///     ├── models.rs      — one example Model showing common field types
1275///     │                    (Text + max_length, Choice enum, optional DateTime)
1276///     └── handlers.rs    — one example axum handler using AppContext
1277/// ```
1278///
1279/// This is the one plugin scaffolder. `startapp` / [`scaffold_app`] are a
1280/// deprecated alias that forward here — everything generated under
1281/// `plugins/` is a plugin, so there is no separate "app" template.
1282pub fn scaffold_plugin(
1283    name: &str,
1284    project_root: &Path,
1285    local_umbral_repo: Option<&Path>,
1286) -> Result<ScaffoldReport, ScaffoldError> {
1287    // Reserved first, then the identifier rules — see `scaffold_app`.
1288    let normalized = name.replace('-', "_");
1289    if RESERVED_PLUGIN_NAMES.contains(&normalized.as_str()) {
1290        return Err(ScaffoldError::ReservedName(name.to_string()));
1291    }
1292
1293    validate_name(name)?;
1294
1295    let plugins_dir = project_root.join("plugins");
1296    let root = plugins_dir.join(name);
1297    if root.exists() {
1298        return Err(ScaffoldError::AlreadyExists(root));
1299    }
1300
1301    fs::create_dir_all(&root)?;
1302    fs::create_dir_all(root.join("src"))?;
1303
1304    let crate_name = rust_ident(name);
1305    let pascal = pascal_case_from_ident(name);
1306    let mut files = Vec::new();
1307
1308    // Cargo.toml — pulls in the deps the example modules use. async-
1309    // trait is here because Plugin trait methods are sync today, but
1310    // the generated handlers.rs example uses an async axum extractor,
1311    // and most plugins grow async work quickly. Cheap to ship now,
1312    // saves the user a Cargo.toml edit later.
1313    let version = env!("CARGO_PKG_VERSION");
1314    let cargo_toml = format!(
1315        r#"[package]
1316name = "{name}"
1317version = "0.1.0"
1318edition = "2024"
1319description = "A {crate_name} plugin for umbral."
1320
1321[dependencies]
1322umbral = "{version}"
1323serde = {{ version = "1", features = ["derive"] }}
1324sqlx = {{ version = "0.8", default-features = false, features = ["macros", "runtime-tokio"] }}
1325chrono = {{ version = "0.4", features = ["serde"] }}
1326async-trait = "0.1"
1327"#
1328    );
1329    let cargo_toml = match local_umbral_repo {
1330        Some(repo) => localize_deps(&cargo_toml, repo),
1331        None => cargo_toml,
1332    };
1333    write_file(&root, "Cargo.toml", &cargo_toml, &mut files)?;
1334
1335    // README.md — the user-facing tour. Mirrors the file structure so
1336    // a reader who clones the crate knows where to look first.
1337    let readme = format!(
1338        r#"# {name}
1339
1340A {crate_name} plugin for [umbral](https://github.com/dalmasonto/umbral).
1341
1342Generated by `umbral startplugin {name}`.
1343
1344## What's inside
1345
1346| File | Purpose |
1347|---|---|
1348| `src/lib.rs` | `{pascal}Plugin` struct + `impl Plugin` (registers models, routes, lifecycle hooks). |
1349| `src/models.rs` | One example model showing common field types (`#[umbral(...)]` attributes for `max_length`, `choices`, FK, defaults). |
1350| `src/handlers.rs` | One example axum handler showing how to read query params and return JSON. |
1351
1352## Wiring it in
1353
1354In your project's `Cargo.toml`:
1355
1356```toml
1357[dependencies]
1358{name} = {{ path = "plugins/{name}" }}
1359```
1360
1361In `src/main.rs`:
1362
1363```rust,ignore
1364let app = umbral::App::builder()
1365    .plugin({crate_name}::{pascal}Plugin::default())
1366    // ... your other plugins
1367    .build_deferred()?;   // build_deferred + dispatch: lets `dispatch` fire
1368
1369umbral_cli::dispatch(app).await   // on_ready AFTER a management command runs
1370```
1371
1372Then:
1373
1374```sh
1375cargo run -- makemigrations   # generates 0001_initial.json from your models
1376cargo run -- migrate          # applies the schema
1377cargo run -- serve            # boots the HTTP server
1378```
1379
1380## Next steps
1381
1382- Add your own models in `src/models.rs` (or split into a `models/` module).
1383- Add routes in `routes()` and handlers in `src/handlers.rs`.
1384- Use `on_ready(&AppContext)` for one-shot setup work (seed default rows, register signals).
1385- See `documentation/docs/v0.0.1/plugins/the-plugin-trait.mdx` for the full trait surface.
1386"#
1387    );
1388    write_file(&root, "README.md", &readme, &mut files)?;
1389
1390    // src/lib.rs — Plugin impl that pulls models + routes from the
1391    // sibling modules. `models()` returns the registered model meta;
1392    // `routes()` returns the axum Router with the example handler.
1393    let lib_rs = format!(
1394        r#"//! {pascal}Plugin — a distributable umbral plugin.
1395//!
1396//! Wire this into your App in `src/main.rs`:
1397//!
1398//! ```ignore
1399//! .plugin({crate_name}::{pascal}Plugin::default())
1400//! ```
1401//!
1402//! See `README.md` for the full file tour.
1403
1404pub mod handlers;
1405pub mod models;
1406
1407use async_trait::async_trait;
1408use umbral::migrate::ModelMeta;
1409use umbral::plugin::{{AppContext, Plugin, PluginError}};
1410use umbral::web::{{Router, get}};
1411
1412/// The plugin entry point. Register one instance per `App::builder()`.
1413#[derive(Debug, Default, Clone)]
1414pub struct {pascal}Plugin;
1415
1416#[async_trait]
1417impl Plugin for {pascal}Plugin {{
1418    fn name(&self) -> &'static str {{
1419        "{name}"
1420    }}
1421
1422    /// Models the framework's migration engine should track. Each
1423    /// returned [`ModelMeta`] becomes one row in the
1424    /// `umbral_migrations` tracking table once the initial migration
1425    /// applies.
1426    fn models(&self) -> Vec<ModelMeta> {{
1427        // One entry per model the plugin owns. `umbral::discovered_models!()`
1428        // finds every #[derive(Model)] in this crate automatically if you'd
1429        // rather not maintain the list by hand.
1430        vec![ModelMeta::for_::<models::{pascal}Item>()]
1431    }}
1432
1433    /// HTTP routes contributed by this plugin. The base path is
1434    /// up to you — convention is `/<name>/...` for HTML and
1435    /// `/api/<name>/...` for JSON.
1436    fn routes(&self) -> Router {{
1437        Router::new().route("/{name}/hello", get(handlers::hello))
1438    }}
1439
1440    /// One-shot setup after `App::build()` finishes. Use this for
1441    /// seeding default rows, registering signal handlers, or any
1442    /// work that needs the database available. Sync because the
1443    /// `Plugin` trait signature is sync (BUG-3 in bugs/tests/testBugs.md);
1444    /// reach into a runtime via `tokio::runtime::Handle::current()
1445    /// .block_on(...)` if you need to await something here.
1446    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {{
1447        Ok(())
1448    }}
1449}}
1450"#
1451    );
1452    write_file(&root, "src/lib.rs", &lib_rs, &mut files)?;
1453
1454    // src/models.rs — one Model showing the field types most plugins
1455    // need: a Text with max_length, a Choice enum, an optional
1456    // DateTime. Keeps it small enough to read in one screen.
1457    let models_rs = format!(
1458        r#"//! Example model. Replace or extend with your own.
1459//!
1460//! What this demonstrates:
1461//! - `#[umbral(max_length = 200)]` — DDL `VARCHAR(200)` + admin form hint.
1462//! - `#[umbral(choices)]` on an enum — closed-set column with OpenAPI
1463//!   `enum` and a Postgres `CHECK (col IN (...))` constraint.
1464//! - `Option<DateTime<Utc>>` — nullable timestamptz column.
1465//! - `#[umbral(noedit)]` — read-only on admin forms; not editable via
1466//!   PUT/PATCH through the REST plugin.
1467
1468use chrono::{{DateTime, Utc}};
1469use serde::{{Deserialize, Serialize}};
1470
1471/// One {crate_name} item. Replace with whatever your plugin actually
1472/// stores.
1473#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
1474pub struct {pascal}Item {{
1475    /// Auto-incrementing primary key.
1476    pub id: i64,
1477
1478    /// Display title. Capped at 200 chars; admin renders a single-line
1479    /// input.
1480    #[umbral(string, max_length = 200)]
1481    pub title: String,
1482
1483    /// Lifecycle state. `#[umbral(choices)]` maps the column 1:1 to the
1484    /// enum variants: the migration engine emits a CHECK constraint, the
1485    /// admin renders a `<select>`, and the OpenAPI schema gets an `enum`.
1486    #[umbral(choices)]
1487    pub status: {pascal}Status,
1488
1489    /// When the item was last published. Read-only on edit forms.
1490    #[umbral(noedit)]
1491    pub published_at: Option<DateTime<Utc>>,
1492}}
1493
1494/// Lifecycle state for [`{pascal}Item`]. The `Choices` derive teaches the
1495/// ORM the closed set; `rename_all` controls how variants serialize to the
1496/// stored string (`Draft` → `"draft"`).
1497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, umbral::orm::Choices)]
1498#[choices(rename_all = "lowercase")]
1499pub enum {pascal}Status {{
1500    Draft,
1501    Review,
1502    Published,
1503    Archived,
1504}}
1505"#
1506    );
1507    write_file(&root, "src/models.rs", &models_rs, &mut files)?;
1508
1509    // src/handlers.rs — one axum handler returning JSON. Shows the
1510    // Query extractor + the framework's Json response shape.
1511    let handlers_rs = format!(
1512        r#"//! Example HTTP handlers. Replace or extend with your own.
1513//!
1514//! `GET /{name}/hello?name=world` returns `{{"greeting": "Hello, world!"}}`.
1515
1516use serde::{{Deserialize, Serialize}};
1517use umbral::web::{{Json, Query}};
1518
1519#[derive(Debug, Deserialize, Default)]
1520pub struct HelloParams {{
1521    /// Who to greet. Defaults to "{name}" when omitted.
1522    #[serde(default)]
1523    pub name: Option<String>,
1524}}
1525
1526#[derive(Debug, Serialize)]
1527pub struct HelloResponse {{
1528    pub greeting: String,
1529}}
1530
1531pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {{
1532    let who = params.name.as_deref().unwrap_or("{name}");
1533    Json(HelloResponse {{
1534        greeting: format!("Hello, {{who}}!"),
1535    }})
1536}}
1537"#
1538    );
1539    write_file(&root, "src/handlers.rs", &handlers_rs, &mut files)?;
1540
1541    // Auto-register the new crate as a path dep in the project's Cargo.toml.
1542    let project_cargo_toml = project_root.join("Cargo.toml");
1543    let cargo_toml_registered = if project_cargo_toml.is_file() {
1544        register_dep_in_cargo_toml(&project_cargo_toml, name).ok()
1545    } else {
1546        None
1547    };
1548
1549    let next_steps = vec![
1550        "Wire the plugin into your App::builder chain in src/main.rs:".to_string(),
1551        format!("    .plugin({crate_name}::{pascal}Plugin::default())"),
1552        "Generate + apply the initial migration:".to_string(),
1553        "    cargo run -- makemigrations".to_string(),
1554        "    cargo run -- migrate".to_string(),
1555        format!("Then visit http://127.0.0.1:8000/{name}/hello?name=you"),
1556    ];
1557
1558    Ok(ScaffoldReport {
1559        root,
1560        files,
1561        next_steps,
1562        cargo_toml_registered,
1563        registered: None,
1564    })
1565}
1566
1567// ===================================================================== //
1568// startcommand (gaps3 #81)                                              //
1569// ===================================================================== //
1570
1571/// Where a scaffolded management command lives: the project's own binary
1572/// (registered on the App builder via `.commands(commands::all())`) or a
1573/// plugin under `plugins/<name>/` (returned from its `Plugin::commands()`,
1574/// so it travels with the plugin).
1575///
1576/// This is `umbral::codegen::Target` — the same "root or which plugin?" every
1577/// generator asks, including the ones plugins ship (`umbral-rest`'s
1578/// `startpermission` and friends). Two enums saying the same thing is one
1579/// enum too many.
1580pub use umbral::codegen::Target as CommandTarget;
1581
1582/// The marker line the scaffolder inserts new module declarations above.
1583const MODS_MARKER: &str =
1584    "// umbral:startcommand — `umbral startcommand` declares new modules above this line.";
1585/// The marker line the scaffolder inserts new registry entries above.
1586const REGISTRY_MARKER: &str =
1587    "// umbral:startcommand — `umbral startcommand` registers new commands above this line.";
1588
1589/// List the plugins available in this project: every `plugins/<name>/`
1590/// directory that holds a `Cargo.toml`.
1591///
1592/// Reads the disk rather than `main.rs`, so a plugin you scaffolded but
1593/// haven't registered yet is still offered as a home for a command. Shared
1594/// with every other generator via `umbral::codegen`.
1595pub use umbral::codegen::discover_plugins;
1596
1597/// Write a management command and register it.
1598///
1599/// Two targets, one shape. Either way the command lands in a
1600/// `commands/<name>.rs` next to a `commands/mod.rs` whose `all()` function
1601/// is the registry, and the registry is wired into the thing that owns it:
1602///
1603/// ```text
1604/// --in root                        --in <plugin>
1605/// src/                             plugins/<plugin>/src/
1606///   main.rs   .commands(all())       lib.rs   fn commands() -> all()
1607///   commands/                        commands/
1608///     mod.rs  pub fn all()             mod.rs  pub fn all()
1609///     <name>.rs                        <name>.rs
1610/// ```
1611///
1612/// ## Why a hand-maintained `all()` and not real auto-detection
1613///
1614/// Rust has no runtime module reflection: nothing can walk `commands/` at
1615/// startup and find the structs in it. The choices are a build script that
1616/// generates the registry, an inventory-style linker-section crate, or a
1617/// registry function the tool maintains. The registry function wins because
1618/// it stays *readable and editable by hand* — you can see every command the
1619/// app has in one place, reorder them, comment one out — and the scaffolder
1620/// keeps it up to date so the common path costs you nothing. The marker
1621/// comments are how it finds its insertion points; delete them and the tool
1622/// falls back to telling you the two lines to add.
1623///
1624/// Calling this a second time with a different name appends to the existing
1625/// `mod.rs` and touches neither `main.rs` nor the plugin's `lib.rs` again.
1626pub fn scaffold_command(
1627    name: &str,
1628    target: &CommandTarget,
1629    project_root: &Path,
1630) -> Result<ScaffoldReport, ScaffoldError> {
1631    // Reserved first: `migrate` and friends deserve the "that is already an
1632    // umbral command" message rather than a generic identifier complaint.
1633    if reserved_command_names().iter().any(|r| r == name) {
1634        return Err(ScaffoldError::ReservedCommandName(name.to_string()));
1635    }
1636
1637    validate_name(name)?;
1638
1639    let module = rust_ident(name);
1640    let pascal = pascal_case_from_ident(name);
1641    let struct_name = format!("{pascal}Command");
1642
1643    // Resolve the crate the command lands in, and the file that owns its
1644    // registry (main.rs registers via the builder; a plugin via its
1645    // `Plugin::commands()` impl). `resolve_target` is shared with every other
1646    // generator, including the ones plugins ship.
1647    let resolved = umbral::codegen::resolve_target(project_root, target)?;
1648    let crate_root = resolved.crate_root.clone();
1649    let owner_file = resolved.owner_file.clone();
1650
1651    let mut files = Vec::new();
1652
1653    // ---------------------------------------------------------------- //
1654    // src/commands/<name>.rs — the command itself. `write_new_file`     //
1655    // refuses to overwrite, so a re-run can't eat an existing command.  //
1656    // ---------------------------------------------------------------- //
1657    umbral::codegen::write_new_file(
1658        &crate_root,
1659        &format!("src/commands/{module}.rs"),
1660        &render_command_file(name, &struct_name, target),
1661        &mut files,
1662    )?;
1663
1664    // ---------------------------------------------------------------- //
1665    // src/commands/mod.rs — the registry. Created on the first command, //
1666    // appended to on every one after.                                   //
1667    // ---------------------------------------------------------------- //
1668    let mod_rs = crate_root.join("src/commands/mod.rs");
1669    let mut next_steps: Vec<String> = Vec::new();
1670    if mod_rs.is_file() {
1671        let text = fs::read_to_string(&mod_rs)?;
1672        match append_to_registry(&text, &module, &struct_name) {
1673            Some(updated) => {
1674                fs::write(&mod_rs, updated)?;
1675                files.push(PathBuf::from("src/commands/mod.rs"));
1676            }
1677            None => {
1678                // The markers are gone — the user restructured the file. Say so
1679                // and hand back the exact two lines rather than guessing where
1680                // they go and corrupting a file we don't understand.
1681                next_steps.push(
1682                    "src/commands/mod.rs has no `umbral:startcommand` markers — add by hand:"
1683                        .to_string(),
1684                );
1685                next_steps.push(format!("    pub mod {module};"));
1686                next_steps.push(format!(
1687                    "    ...and inside `all()`:  Box::new({module}::{struct_name}),"
1688                ));
1689            }
1690        }
1691    } else {
1692        umbral::codegen::write_new_file(
1693            &crate_root,
1694            "src/commands/mod.rs",
1695            &render_registry_file(&module, &struct_name, target),
1696            &mut files,
1697        )?;
1698    }
1699
1700    // ---------------------------------------------------------------- //
1701    // Register the registry with its owner (once — the second command    //
1702    // reuses the same `all()` call).                                     //
1703    // ---------------------------------------------------------------- //
1704    let owner_text = fs::read_to_string(&owner_file)?;
1705    let wiring = match target {
1706        CommandTarget::Root => wire_registry_into_main(&owner_text),
1707        CommandTarget::Plugin(_) => wire_registry_into_plugin(&owner_text),
1708    };
1709    // `registered` is the truth the CLI prints. A partial edit (we added the
1710    // module but could not find the builder chain) counts as NOT registered:
1711    // the command does not run until the user pastes the remaining line.
1712    let registered = match wiring {
1713        Wiring::Updated { text, steps } => {
1714            fs::write(&owner_file, text)?;
1715            let complete = steps.is_empty();
1716            next_steps.extend(steps);
1717            complete
1718        }
1719        Wiring::AlreadyWired => true,
1720        Wiring::Manual(steps) => {
1721            next_steps.extend(steps);
1722            false
1723        }
1724    };
1725
1726    if registered {
1727        next_steps.push(format!("Run it:  cargo run -- {name} --help"));
1728    } else {
1729        next_steps.push(format!(
1730            "Then run it:  cargo run -- {name} --help   (after the steps above — \
1731             it is NOT registered yet)"
1732        ));
1733    }
1734
1735    Ok(ScaffoldReport {
1736        root: crate_root,
1737        files,
1738        next_steps,
1739        cargo_toml_registered: None,
1740        registered: Some(registered),
1741    })
1742}
1743
1744/// Outcome of registering the `commands::all()` registry with the file
1745/// that owns it (`main.rs` for root, the plugin's `lib.rs` otherwise).
1746///
1747/// `Updated` carries leftover manual steps because the two aren't
1748/// exclusive: we can add the `pub mod commands;` line and still be unable
1749/// to touch a hand-written `fn commands()` we don't own. Discarding the
1750/// half that worked to keep the enum tidy would help nobody.
1751enum Wiring {
1752    /// The file was edited. `text` is the new content; `steps` is anything
1753    /// the edit could NOT do and the user must.
1754    Updated { text: String, steps: Vec<String> },
1755    /// Already registered — a previous `startcommand` did it. Nothing to do,
1756    /// which is exactly what makes the second command free.
1757    AlreadyWired,
1758    /// The file doesn't match the shape we know how to edit. Rather than
1759    /// guess, hand the user the lines to paste.
1760    Manual(Vec<String>),
1761}
1762
1763/// Wire `mod commands;` + `.commands(commands::all())` into a project's
1764/// `main.rs`.
1765///
1766/// The builder call is inserted immediately before `.build()` /
1767/// `.build_deferred()`, which is the one anchor every umbral `main.rs` has
1768/// — the chain ends there by definition.
1769fn wire_registry_into_main(text: &str) -> Wiring {
1770    let already_mod = text.lines().any(|l| l.trim() == "mod commands;");
1771    let already_registered = text.contains(".commands(commands::all())");
1772    if already_mod && already_registered {
1773        return Wiring::AlreadyWired;
1774    }
1775
1776    let mut out = text.to_string();
1777    let mut steps: Vec<String> = Vec::new();
1778
1779    if !already_mod {
1780        // Before the first `mod x;` line, so the table of contents at the top
1781        // of main.rs stays alphabetical (`commands` sorts before `seed`).
1782        match umbral::codegen::declare_module(&out, "mod commands;") {
1783            Some(text) => out = text,
1784            None => steps.push("Add to src/main.rs:  mod commands;".to_string()),
1785        }
1786    }
1787
1788    if !already_registered {
1789        match builder_terminal_line(&out) {
1790            Some(idx) => {
1791                let indent: String = out
1792                    .lines()
1793                    .nth(idx)
1794                    .map(|l| l.chars().take_while(|c| c.is_whitespace()).collect())
1795                    .unwrap_or_default();
1796                let call = format!(
1797                    "{indent}// Project-owned management commands (`umbral startcommand`).\n\
1798                     {indent}.commands(commands::all())"
1799                );
1800                out = insert_line_at_before(&out, idx, &call);
1801            }
1802            None => steps.push(
1803                "Add to the App::builder() chain in src/main.rs:  .commands(commands::all())"
1804                    .to_string(),
1805            ),
1806        }
1807    }
1808
1809    if out == text {
1810        if steps.is_empty() {
1811            Wiring::AlreadyWired
1812        } else {
1813            Wiring::Manual(steps)
1814        }
1815    } else {
1816        // Whatever we DID manage to edit is written, and whatever we could not
1817        // is reported. The old code returned `Manual` from inside the second
1818        // branch and dropped `out` on the floor — so a `mod commands;` line it
1819        // had already inserted vanished, and the steps it printed never
1820        // mentioned it. The user pasted the one line they were given and got
1821        // `failed to resolve: use of undeclared module `commands``.
1822        Wiring::Updated { text: out, steps }
1823    }
1824}
1825
1826/// The line index of the `.build()` / `.build_deferred()` that TERMINATES the
1827/// `App::builder()` chain — the only safe place to hang `.commands(...)`.
1828///
1829/// Anchoring on the first `.build()` in the file is wrong, and not
1830/// hypothetically: a `main.rs` that builds anything else first —
1831/// `reqwest::Client::builder()…​.build()?`, a `tracing` subscriber, a
1832/// `SqlitePoolOptions` — hands us that chain's terminal instead, and we splice
1833/// `.commands(commands::all())` into a type that has no such method. The user's
1834/// main.rs stops compiling, in a place they never touched, and the tool reports
1835/// success.
1836///
1837/// So: find `App::builder()` first, and take the first terminal at or after it.
1838/// No `App::builder()` (a project that wires the app elsewhere) → `None`, and
1839/// the caller prints the line to add by hand rather than guessing.
1840fn builder_terminal_line(text: &str) -> Option<usize> {
1841    let builder_at = text.lines().position(|l| l.contains("App::builder()"))?;
1842    text.lines()
1843        .enumerate()
1844        .skip(builder_at)
1845        .find(|(_, l)| {
1846            let t = l.trim_start();
1847            t.starts_with(".build_deferred()") || t.starts_with(".build()")
1848        })
1849        .map(|(idx, _)| idx)
1850}
1851
1852/// Wire `pub mod commands;` + a `Plugin::commands()` impl into a plugin's
1853/// `lib.rs`.
1854///
1855/// The impl method is inserted at the top of the `impl Plugin for ...`
1856/// block. If the plugin already has a `fn commands`, we don't touch it —
1857/// a hand-written one may return more than the registry, and silently
1858/// rewriting someone's trait impl is exactly the kind of "helpful" edit
1859/// that eats work.
1860fn wire_registry_into_plugin(text: &str) -> Wiring {
1861    let already_mod = text.lines().any(|l| l.trim() == "pub mod commands;");
1862    let has_commands_fn = text.contains("fn commands(");
1863    if already_mod && has_commands_fn {
1864        return Wiring::AlreadyWired;
1865    }
1866
1867    let mut out = text.to_string();
1868    let mut steps: Vec<String> = Vec::new();
1869
1870    if !already_mod {
1871        match umbral::codegen::declare_module(&out, "pub mod commands;") {
1872            Some(text) => out = text,
1873            None => steps.push("Add to src/lib.rs:  pub mod commands;".to_string()),
1874        }
1875    }
1876
1877    if !has_commands_fn {
1878        // The header must OPEN the block on this line. `impl Plugin for X` with
1879        // its `{` on a following line (a `where` clause, or just rustfmt on a
1880        // long header) would otherwise get the method spliced in *before* the
1881        // brace, and the plugin's lib.rs would stop parsing — a syntax error
1882        // inside code the user never touched, which is precisely the "generator
1883        // that guesses at a file it doesn't recognise" this module's docs
1884        // promise not to be.
1885        match out
1886            .lines()
1887            .position(|l| l.starts_with("impl Plugin for ") && l.trim_end().ends_with('{'))
1888        {
1889            Some(idx) => {
1890                let method = "\n    fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {\n        \
1891                     // Every command in `src/commands/` — `umbral startcommand`\n        \
1892                     // appends to the registry in `commands/mod.rs`, so this line\n        \
1893                     // never needs to change again.\n        \
1894                     commands::all()\n    }";
1895                out = insert_line_at(&out, idx, method);
1896            }
1897            None => steps.push(
1898                "Add to your `impl Plugin`:  fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> { commands::all() }"
1899                    .to_string(),
1900            ),
1901        }
1902    } else {
1903        steps.push(
1904            "Your plugin already has a `fn commands()` — make sure it returns \
1905             `commands::all()` (or extends it) so the new command is registered."
1906                .to_string(),
1907        );
1908    }
1909
1910    if out == text {
1911        // Nothing we could edit. Everything is a manual step (or, if there are
1912        // none, it was already wired).
1913        if steps.is_empty() {
1914            Wiring::AlreadyWired
1915        } else {
1916            Wiring::Manual(steps)
1917        }
1918    } else {
1919        Wiring::Updated { text: out, steps }
1920    }
1921}
1922
1923/// Insert `line` immediately after line index `idx` of `text`, preserving the
1924/// file's line endings. Delegates to the shared primitive: the hand-rolled copy
1925/// emitted `\n` unconditionally, so wiring a method into a CRLF `lib.rs`
1926/// rewrote every line of it in the user's next diff.
1927fn insert_line_at(text: &str, idx: usize, line: &str) -> String {
1928    umbral::codegen::insert_line_after(text, idx, line)
1929}
1930
1931/// Append a module declaration + a registry entry to an existing
1932/// `commands/mod.rs`, using the marker comments as insertion points.
1933///
1934/// Returns `None` when a marker is missing — the caller then reports the
1935/// lines to add by hand rather than guessing at a file it doesn't
1936/// recognise.
1937fn append_to_registry(text: &str, module: &str, struct_name: &str) -> Option<String> {
1938    // The two halves are checked INDEPENDENTLY. They can legitimately drift
1939    // apart — delete a command file and re-run, or hand-add the `pub mod` line —
1940    // and the old code took the presence of the module declaration as proof
1941    // that the registry entry existed too. It returned the text unchanged, so
1942    // `all()` never got the command, while the CLI cheerfully printed
1943    // "Registered". `cargo run -- <name>` then answered "unknown command" for a
1944    // command the tool had just claimed to wire up.
1945    let mod_line = format!("pub mod {module};");
1946    let entry = format!("Box::new({module}::{struct_name}),");
1947
1948    let mut out = text.to_string();
1949
1950    if !out.lines().any(|l| l.trim() == mod_line) {
1951        out = umbral::codegen::insert_before_marker(&out, MODS_MARKER, &mod_line)?;
1952    }
1953    if !out.lines().any(|l| l.trim() == entry) {
1954        out = umbral::codegen::insert_before_marker(
1955            &out,
1956            REGISTRY_MARKER,
1957            &format!("        {entry}"),
1958        )?;
1959    }
1960    Some(out)
1961}
1962
1963/// Insert `line` immediately *before* line index `idx` of `text`, preserving
1964/// the file's line endings. Delegates to the shared codegen primitive so
1965/// `startcommand` and a plugin's generator treat a user's file identically.
1966fn insert_line_at_before(text: &str, idx: usize, line: &str) -> String {
1967    umbral::codegen::insert_line_before(text, idx, line)
1968}
1969
1970/// The generated `commands/mod.rs` — the registry.
1971fn render_registry_file(module: &str, struct_name: &str, target: &CommandTarget) -> String {
1972    let (owner, wiring) = match target {
1973        CommandTarget::Root => (
1974            "this project",
1975            "`main.rs` passes `all()` to `App::builder().commands(...)`.",
1976        ),
1977        CommandTarget::Plugin(_) => (
1978            "this plugin",
1979            "`lib.rs` returns `all()` from `Plugin::commands()`.",
1980        ),
1981    };
1982    format!(
1983        r#"//! Management commands owned by {owner} — one file per command,
1984//! and `all()` is the registry that hands them to the framework.
1985//!
1986//! {wiring}
1987//!
1988//! Rust can't discover a module by scanning this directory at runtime, so
1989//! `all()` IS the auto-detection: `umbral startcommand` appends to it for
1990//! you (that's what the marker comments below are for). You can also edit
1991//! it by hand — comment a command out and it stops existing, which is
1992//! harder to do with a magic registry you can't see.
1993
1994use umbral::cli::PluginCommand;
1995
1996pub mod {module};
1997{MODS_MARKER}
1998
1999/// Every command {owner} registers.
2000pub fn all() -> Vec<Box<dyn PluginCommand>> {{
2001    vec![
2002        Box::new({module}::{struct_name}),
2003        {REGISTRY_MARKER}
2004    ]
2005}}
2006"#
2007    )
2008}
2009
2010/// The generated `commands/<name>.rs` — one command, showing the three arg
2011/// shapes clap gives you (positional, named value, flag) and how each is
2012/// read back out of `ArgMatches`.
2013fn render_command_file(name: &str, struct_name: &str, target: &CommandTarget) -> String {
2014    // A plugin's command reaches its own models through `crate::models`;
2015    // a root command reaches the project's through `crate::`.
2016    let orm_note = match target {
2017        CommandTarget::Root => "//     use crate::{Post, post};",
2018        CommandTarget::Plugin(_) => "//     use crate::models::{Post, post};",
2019    };
2020    format!(
2021        r#"//! `{name}` — a management command.
2022//!
2023//! ```bash
2024//! cargo run -- {name} --help                       # what it takes
2025//! cargo run -- {name} hello --limit 5 --dry-run    # a real run
2026//! umbral {name} hello --tag a --tag b              # same thing, via the umbral CLI
2027//! ```
2028//!
2029//! Registered through `commands::all()` in `commands/mod.rs`. It runs against
2030//! a fully-built app: settings loaded, pool open, every model registered — so
2031//! the ORM works ambiently here, with no pool to thread through.
2032
2033use umbral::cli::{{CliError, PluginCommand, clap}};
2034
2035/// The `{name}` command.
2036///
2037/// A unit struct is enough when the command is stateless. It doesn't have to
2038/// be: the trait is object-safe over `&self`, so anything the command needs
2039/// configured (a prefix, a client, a channel) can live on the struct and be
2040/// passed in at registration — which is exactly why this is a trait and not a
2041/// bare `fn` pointer.
2042pub struct {struct_name};
2043
2044#[umbral::async_trait]
2045impl PluginCommand for {struct_name} {{
2046    /// Declare the command: its name, its help, and its arguments.
2047    ///
2048    /// This is plain `clap`, so everything clap can do is available here —
2049    /// value parsing and validation, defaults, conflicts, subcommands of your
2050    /// own. Note the import: `umbral::cli::clap`, the framework's own clap.
2051    /// Add `clap` to your Cargo.toml separately and a major-version bump on
2052    /// either side turns into a type mismatch a page long.
2053    fn command(&self) -> clap::Command {{
2054        clap::Command::new("{name}")
2055            // Shown next to the command in `umbral help`. Write it — a command
2056            // with no `about` lists as a dash and nobody discovers it.
2057            .about("TODO: one line on what {name} does")
2058            .long_about(
2059                "TODO: the longer story, shown on `{name} --help`. What it \
2060                 changes, whether it's safe to re-run, what it needs first.",
2061            )
2062            // POSITIONAL argument — `{name} <slug>`. Required, so clap
2063            // rejects the call with a usage error if it's missing and `run`
2064            // never sees a half-formed invocation.
2065            .arg(
2066                clap::Arg::new("slug")
2067                    .required(true)
2068                    .help("The thing to operate on"),
2069            )
2070            // NAMED argument with a value and a default — `--limit 25` / `-l 25`.
2071            // `value_parser` is what makes it a `u64` on the other side rather
2072            // than a string you'd have to parse (and mis-parse) yourself.
2073            .arg(
2074                clap::Arg::new("limit")
2075                    .long("limit")
2076                    .short('l')
2077                    .value_name("N")
2078                    .value_parser(clap::value_parser!(u64))
2079                    .default_value("25")
2080                    .help("How many rows to touch at most"),
2081            )
2082            // REPEATABLE named argument — `--tag a --tag b` collects both.
2083            // `ArgAction::Append` is the difference between the second `--tag`
2084            // overwriting the first and the two accumulating.
2085            .arg(
2086                clap::Arg::new("tag")
2087                    .long("tag")
2088                    .value_name("TAG")
2089                    .action(clap::ArgAction::Append)
2090                    .help("Filter by tag. Repeat for more than one."),
2091            )
2092            // BOOLEAN flag — `--dry-run`, no value. `SetTrue` is what makes it
2093            // a flag rather than an option that demands a value.
2094            .arg(
2095                clap::Arg::new("dry-run")
2096                    .long("dry-run")
2097                    .action(clap::ArgAction::SetTrue)
2098                    .help("Report what would change without writing anything"),
2099            )
2100    }}
2101
2102    /// Run the command. `matches` is this subcommand's own `ArgMatches` —
2103    /// clap has already validated it against `command()` above, so every
2104    /// `get_one` here is reading a value that exists and typechecked.
2105    async fn run(&self, matches: &clap::ArgMatches) -> Result<(), CliError> {{
2106        let slug = matches
2107            .get_one::<String>("slug")
2108            .expect("clap enforces `required(true)`");
2109        let limit = *matches
2110            .get_one::<u64>("limit")
2111            .expect("clap fills in `default_value`");
2112        let tags: Vec<&String> = matches
2113            .get_many::<String>("tag")
2114            .map(Iterator::collect)
2115            .unwrap_or_default();
2116        let dry_run = matches.get_flag("dry-run");
2117
2118        println!("{name}: slug={{slug}} limit={{limit}} tags={{tags:?}} dry_run={{dry_run}}");
2119
2120        // The app is already built by the time this runs, so the ORM is live:
2121        //
2122        {orm_note}
2123        //
2124        //     let posts = Post::objects()
2125        //         .filter(post::PUBLISHED.eq(true))
2126        //         .limit(limit as i64)
2127        //         .fetch()
2128        //         .await?;
2129        //
2130        //     if dry_run {{
2131        //         println!("would touch {{}} post(s)", posts.len());
2132        //         return Ok(());
2133        //     }}
2134        //
2135        // `?` just works: `CliError` is a boxed error, so every umbral error
2136        // converts into it. Return `Err(...)` and the process exits non-zero,
2137        // which is what a CI step or a cron job is watching for.
2138
2139        Ok(())
2140    }}
2141}}
2142"#
2143    )
2144}
2145
2146/// Write a file under `root` at the given relative path. Records the
2147/// relative path in `files` for the user-facing report.
2148fn write_file(
2149    root: &Path,
2150    rel_path: &str,
2151    contents: &str,
2152    files: &mut Vec<PathBuf>,
2153) -> Result<(), ScaffoldError> {
2154    // `write_new_file` refuses to overwrite. The scaffolders that call this all
2155    // create a fresh directory first, so nothing should be in the way — and if
2156    // something IS, silently clobbering it is the last thing a generator should
2157    // do.
2158    umbral::codegen::write_new_file(root, rel_path, contents, files).map_err(Into::into)
2159}
2160
2161/// Attempt to register `<name> = { path = "plugins/<name>" }` under
2162/// `[dependencies]` in the project's `Cargo.toml`.
2163///
2164/// Returns:
2165/// - `Ok(true)`  — dep was added.
2166/// - `Ok(false)` — dep was already present (idempotent; no duplicate written).
2167/// - `Err(_)`    — the file couldn't be read or written. Callers treat this
2168///   as a soft failure: the scaffold files are already on disk, so we warn
2169///   but don't roll them back.
2170///
2171/// The insertion uses minimal string surgery (find the `[dependencies]`
2172/// header, append one line immediately after it) so comments, ordering,
2173/// and formatting of existing deps are preserved. `toml_edit` is not yet
2174/// a dep of umbral-cli; if it's added later this function is the right
2175/// place to switch to it.
2176pub fn register_dep_in_cargo_toml(cargo_toml_path: &Path, name: &str) -> io::Result<bool> {
2177    // Delegates to `umbral::codegen::ensure_dependency`. The copy that used to
2178    // live here matched `<name> =` on ANY line, so a crate listed under
2179    // `[dev-dependencies]` read as already-present (and the dep was never
2180    // added), and it never recognised the `[dependencies.<name>]` table form
2181    // (so it appended a duplicate key and cargo refused the manifest). Both are
2182    // fixed in the shared primitive, and both were being shipped from here.
2183    umbral::codegen::ensure_dependency(
2184        cargo_toml_path,
2185        name,
2186        &format!("{{ path = \"plugins/{name}\" }}"),
2187    )
2188    .map_err(|e| match e {
2189        umbral::codegen::CodegenError::Io(e) => e,
2190        other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()),
2191    })
2192}
2193#[cfg(test)]
2194mod tests {
2195    use super::*;
2196
2197    #[test]
2198    fn validate_name_accepts_simple_identifiers() {
2199        assert!(validate_name("posts").is_ok());
2200        assert!(validate_name("blog_engine").is_ok());
2201        assert!(validate_name("blog-engine").is_ok());
2202        assert!(validate_name("api2").is_ok());
2203    }
2204
2205    #[test]
2206    fn validate_name_rejects_empty() {
2207        assert!(validate_name("").is_err());
2208    }
2209
2210    #[test]
2211    fn validate_name_rejects_leading_digit() {
2212        assert!(validate_name("2cool").is_err());
2213    }
2214
2215    #[test]
2216    fn validate_name_rejects_special_chars() {
2217        assert!(validate_name("foo bar").is_err());
2218        assert!(validate_name("foo!bar").is_err());
2219        assert!(validate_name("foo/bar").is_err());
2220    }
2221
2222    #[test]
2223    fn pascal_case_handles_kebab_and_snake() {
2224        assert_eq!(pascal_case_from_ident("posts"), "Posts");
2225        assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
2226        assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
2227        assert_eq!(pascal_case_from_ident("api2"), "Api2");
2228    }
2229
2230    #[test]
2231    fn rust_ident_replaces_hyphens() {
2232        assert_eq!(rust_ident("blog-engine"), "blog_engine");
2233        assert_eq!(rust_ident("posts"), "posts");
2234    }
2235
2236    #[test]
2237    fn scaffold_app_rejects_reserved_built_in_plugin_names() {
2238        let tmp = tempfile::tempdir().expect("tempdir");
2239        for name in RESERVED_PLUGIN_NAMES {
2240            let result = scaffold_app(name, tmp.path(), None);
2241            assert!(
2242                matches!(result, Err(ScaffoldError::ReservedName(_))),
2243                "expected ReservedName error for `{name}`, got: {result:?}",
2244            );
2245            assert!(
2246                !tmp.path().join("plugins").join(name).exists(),
2247                "directory must NOT be created when name is reserved: {name}",
2248            );
2249        }
2250    }
2251
2252    #[test]
2253    fn scaffold_app_rejects_reserved_name_with_hyphen_variant() {
2254        // `static` is reserved; so is `my-static`-anything? No — only
2255        // exact matches. But hyphens should normalize to underscores so
2256        // someone typing `umbral-storage` or `umbral_storage` doesn't slip
2257        // through. We compare on the underscored form.
2258        let tmp = tempfile::tempdir().expect("tempdir");
2259        // Pure name check: built-in names contain no hyphens today, but
2260        // the normalization defends against future built-ins like
2261        // `slack-bot` versus `slack_bot`.
2262        let result = scaffold_app("auth", tmp.path(), None);
2263        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2264    }
2265
2266    #[test]
2267    fn scaffold_app_message_lists_reserved_names() {
2268        let err = ScaffoldError::ReservedName("auth".to_string());
2269        let msg = format!("{err}");
2270        assert!(msg.contains("`auth`"), "error names the offending input");
2271        assert!(
2272            msg.contains("admin") && msg.contains("sessions") && msg.contains("permissions"),
2273            "error lists the reserved set so the user can pick again: {msg}",
2274        );
2275    }
2276
2277    #[test]
2278    fn scaffold_app_already_exists_message_says_app() {
2279        // Gap 39: the AlreadyExists message used to say "target" which
2280        // didn't tell a user that there's an existing APP. The new copy
2281        // names the app directly.
2282        let err = ScaffoldError::AlreadyExists(PathBuf::from("plugins/blog"));
2283        let msg = format!("{err}");
2284        assert!(msg.contains("app already exists"), "got: {msg}");
2285        assert!(msg.contains("plugins/blog"), "got: {msg}");
2286    }
2287
2288    // ----------------------------------------------------------------- //
2289    // scaffold_plugin (gap #63)                                         //
2290    // ----------------------------------------------------------------- //
2291
2292    #[test]
2293    fn scaffold_plugin_writes_richer_layout() {
2294        let tmp = tempfile::tempdir().expect("tempdir");
2295        let report = scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2296
2297        let root = tmp.path().join("plugins").join("widgets");
2298        assert!(root.is_dir());
2299
2300        // The richer layout: README + lib + models + handlers.
2301        for rel in [
2302            "Cargo.toml",
2303            "README.md",
2304            "src/lib.rs",
2305            "src/models.rs",
2306            "src/handlers.rs",
2307        ] {
2308            assert!(
2309                root.join(rel).exists(),
2310                "missing expected file: {rel}; got {:?}",
2311                report.files,
2312            );
2313        }
2314    }
2315
2316    #[test]
2317    fn scaffold_plugin_lib_rs_references_sibling_modules() {
2318        let tmp = tempfile::tempdir().expect("tempdir");
2319        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2320        let lib = fs::read_to_string(tmp.path().join("plugins/widgets/src/lib.rs")).unwrap();
2321
2322        assert!(
2323            lib.contains("pub mod handlers;"),
2324            "lib.rs must publish handlers"
2325        );
2326        assert!(
2327            lib.contains("pub mod models;"),
2328            "lib.rs must publish models"
2329        );
2330        assert!(lib.contains("WidgetsPlugin"), "PascalCase plugin name");
2331        assert!(
2332            lib.contains("ModelMeta::for_::<models::WidgetsItem>()"),
2333            "models() should register the example model",
2334        );
2335        assert!(
2336            lib.contains("/widgets/hello"),
2337            "routes() should register the example handler",
2338        );
2339    }
2340
2341    #[test]
2342    fn scaffold_plugin_models_rs_uses_real_umbral_attributes() {
2343        let tmp = tempfile::tempdir().expect("tempdir");
2344        scaffold_plugin("widgets", tmp.path(), None).expect("scaffold ok");
2345        let models = fs::read_to_string(tmp.path().join("plugins/widgets/src/models.rs")).unwrap();
2346
2347        assert!(
2348            models.contains("umbral::orm::Model"),
2349            "model derive must reference the framework's Model trait",
2350        );
2351        assert!(
2352            models.contains("max_length = 200"),
2353            "example model should demonstrate max_length",
2354        );
2355        assert!(
2356            models.contains("WidgetsStatus"),
2357            "example model should declare a Choice enum",
2358        );
2359        // The choices field MUST carry `#[umbral(choices)]` and the enum
2360        // MUST use the `Choices` derive — a bare `sqlx::Type` enum made the
2361        // generated model fail to compile ("M3 doesn't support this field
2362        // type"). Pin both so that regression can't recur.
2363        assert!(
2364            models.contains("#[umbral(choices)]"),
2365            "the status field needs #[umbral(choices)] or the model won't compile",
2366        );
2367        assert!(
2368            models.contains("Choices"),
2369            "the enum needs the Choices derive, not a bare sqlx::Type",
2370        );
2371        assert!(
2372            models.contains("noedit"),
2373            "example model should show the noedit attribute",
2374        );
2375    }
2376
2377    #[test]
2378    fn scaffold_plugin_rejects_reserved_built_in_plugin_names() {
2379        let tmp = tempfile::tempdir().expect("tempdir");
2380        for name in RESERVED_PLUGIN_NAMES {
2381            let result = scaffold_plugin(name, tmp.path(), None);
2382            assert!(
2383                matches!(result, Err(ScaffoldError::ReservedName(_))),
2384                "expected ReservedName error for `{name}`, got: {result:?}",
2385            );
2386        }
2387    }
2388
2389    #[test]
2390    fn scaffold_plugin_refuses_to_overwrite_existing_directory() {
2391        let tmp = tempfile::tempdir().expect("tempdir");
2392        scaffold_plugin("widgets", tmp.path(), None).expect("first scaffold ok");
2393        let result = scaffold_plugin("widgets", tmp.path(), None);
2394        assert!(matches!(result, Err(ScaffoldError::AlreadyExists(_))));
2395    }
2396
2397    // ----------------------------------------------------------------- //
2398    // scaffold_project per-concern layout (gaps2 #8) + SecurityPlugin    //
2399    // default (gaps2 #25)                                                //
2400    // ----------------------------------------------------------------- //
2401
2402    #[test]
2403    fn scaffold_project_writes_per_concern_tree() {
2404        let tmp = tempfile::tempdir().expect("tempdir");
2405        let report = scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2406
2407        let root = tmp.path().join("blog");
2408        assert!(root.is_dir());
2409
2410        // The per-concern tree: views/, seed/, widgets/, plugins/.
2411        for rel in [
2412            "src/main.rs",
2413            "src/views/mod.rs",
2414            "src/views/public.rs",
2415            "src/seed/mod.rs",
2416            "src/seed/credentials.rs",
2417            "src/widgets/mod.rs",
2418            "src/widgets/cards.rs",
2419            "plugins/.gitkeep",
2420            "plugins/README.md",
2421        ] {
2422            assert!(
2423                root.join(rel).exists(),
2424                "missing expected file: {rel}; got {:?}",
2425                report.files,
2426            );
2427        }
2428    }
2429
2430    #[test]
2431    fn scaffold_project_mod_files_carry_orchestrator_markers() {
2432        let tmp = tempfile::tempdir().expect("tempdir");
2433        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2434        let root = tmp.path().join("blog");
2435
2436        let views_mod = fs::read_to_string(root.join("src/views/mod.rs")).unwrap();
2437        assert!(
2438            views_mod.contains("re-export"),
2439            "views/mod.rs should describe itself as the re-export layer",
2440        );
2441        // gaps3 #57. The scaffold used to GENERATE a `fn internal_error` helper into every
2442        // new app — and that helper hands `err.to_string()` to the browser, so a missing
2443        // table or a SQL fragment is printed to whoever asked for the page. The scaffold
2444        // is the first umbral code a developer ever reads; it was teaching the leak.
2445        //
2446        // This assertion is deliberately inverted from what it used to be.
2447        assert!(
2448            !views_mod.contains("fn internal_error"),
2449            "the scaffold must NOT generate an internal_error helper — handlers return \
2450             ApiError, which logs the cause and keeps it off the wire",
2451        );
2452        let views_public = fs::read_to_string(root.join("src/views/public.rs")).unwrap();
2453        assert!(
2454            views_public.contains("Result<Html<String>, ApiError>")
2455                && !views_public.contains("map_err(internal_error)"),
2456            "generated handlers must return ApiError and use a bare `?`",
2457        );
2458
2459        let seed_mod = fs::read_to_string(root.join("src/seed/mod.rs")).unwrap();
2460        assert!(
2461            seed_mod.contains("pub async fn all()"),
2462            "seed/mod.rs must declare the all() orchestrator",
2463        );
2464        assert!(
2465            seed_mod.contains("credentials::test_credentials()"),
2466            "seed::all() must call the credentials step",
2467        );
2468        assert!(
2469            seed_mod.contains("dependency order") || seed_mod.contains("order in which"),
2470            "seed/mod.rs should explain it pins dependency order",
2471        );
2472
2473        let credentials = fs::read_to_string(root.join("src/seed/credentials.rs")).unwrap();
2474        assert!(
2475            credentials.contains("fn test_credentials"),
2476            "credentials.rs must define the test_credentials seed",
2477        );
2478        assert!(
2479            credentials.contains("count().await? > 0"),
2480            "test_credentials must be idempotent (short-circuit on existing users)",
2481        );
2482
2483        let widgets_mod = fs::read_to_string(root.join("src/widgets/mod.rs")).unwrap();
2484        assert!(
2485            widgets_mod.contains("pub mod cards;"),
2486            "widgets/mod.rs must publish the cards submodule",
2487        );
2488
2489        let cards = fs::read_to_string(root.join("src/widgets/cards.rs")).unwrap();
2490        assert!(
2491            cards.contains("builtin_total_models_widget")
2492                || cards.contains("builtin_recent_users_widget"),
2493            "cards.rs should re-export a builtin widget so the dashboard isn't empty",
2494        );
2495    }
2496
2497    #[test]
2498    fn scaffold_project_main_declares_modules_and_mounts_security() {
2499        let tmp = tempfile::tempdir().expect("tempdir");
2500        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2501        let main = fs::read_to_string(tmp.path().join("blog/src/main.rs")).unwrap();
2502
2503        // The table-of-contents module declarations.
2504        assert!(
2505            main.contains("mod views;"),
2506            "main.rs must declare mod views"
2507        );
2508        assert!(main.contains("mod seed;"), "main.rs must declare mod seed");
2509        assert!(
2510            main.contains("mod widgets;"),
2511            "main.rs must declare mod widgets",
2512        );
2513
2514        // Routes reference the per-concern handlers.
2515        assert!(
2516            main.contains("views::public::home"),
2517            "route table should wire views::public::home",
2518        );
2519        // Boot seeds via the framework seam (gaps4 #47): serve-only,
2520        // after migrations, idempotent.
2521        assert!(
2522            main.contains(".seed_on_serve(seed::all)"),
2523            "boot should seed via .seed_on_serve(seed::all)",
2524        );
2525
2526        // SecurityPlugin mounted by default (gaps2 #25).
2527        assert!(
2528            main.contains("SecurityPlugin"),
2529            "SecurityPlugin must be mounted by default",
2530        );
2531    }
2532
2533    #[test]
2534    fn scaffold_project_creates_empty_plugins_dir() {
2535        let tmp = tempfile::tempdir().expect("tempdir");
2536        scaffold_project("blog", tmp.path(), None).expect("scaffold ok");
2537        let readme = fs::read_to_string(tmp.path().join("blog/plugins/README.md")).unwrap();
2538        assert!(
2539            readme.contains("umbral startplugin"),
2540            "plugins/README.md should point at the canonical `umbral startplugin`",
2541        );
2542    }
2543
2544    // ----------------------------------------------------------------- //
2545    // scaffold_app is now a deprecated alias forwarding to scaffold_plugin //
2546    // ----------------------------------------------------------------- //
2547
2548    #[test]
2549    fn scaffold_app_forwards_to_the_plugin_generator() {
2550        let tmp = tempfile::tempdir().expect("tempdir");
2551        scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2552        let root = tmp.path().join("plugins/posts");
2553
2554        // The plugin layout (not the old plain views.rs/urls.rs one).
2555        for rel in [
2556            "Cargo.toml",
2557            "README.md",
2558            "src/lib.rs",
2559            "src/models.rs",
2560            "src/handlers.rs",
2561        ] {
2562            assert!(root.join(rel).exists(), "missing expected file: {rel}");
2563        }
2564        let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2565        assert!(lib.contains("pub mod models;"), "lib.rs publishes models");
2566        assert!(
2567            lib.contains("pub mod handlers;"),
2568            "lib.rs publishes handlers"
2569        );
2570        assert!(lib.contains("PostsPlugin"), "PascalCase plugin name");
2571    }
2572
2573    #[test]
2574    fn scaffold_app_auto_registers_path_dep_in_project_cargo() {
2575        let tmp = tempfile::tempdir().expect("tempdir");
2576        // Fixture project Cargo.toml with a [dependencies] section.
2577        let project_cargo = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = \"1\"\n";
2578        fs::write(tmp.path().join("Cargo.toml"), project_cargo).unwrap();
2579
2580        let report = scaffold_app("posts", tmp.path(), None).expect("scaffold ok");
2581        assert_eq!(
2582            report.cargo_toml_registered,
2583            Some(true),
2584            "the path dep should have been added",
2585        );
2586
2587        let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
2588        assert!(
2589            cargo.contains("posts = { path = \"plugins/posts\" }"),
2590            "project Cargo.toml must gain the plugin path dep; got:\n{cargo}",
2591        );
2592
2593        // Idempotent: a second run reports `false` (already present).
2594        // (Different name would re-add; same name short-circuits.)
2595        let second = register_dep_in_cargo_toml(&tmp.path().join("Cargo.toml"), "posts").unwrap();
2596        assert!(!second, "re-registering the same dep must be a no-op");
2597    }
2598
2599    #[test]
2600    fn scaffold_app_still_rejects_reserved_names() {
2601        let tmp = tempfile::tempdir().expect("tempdir");
2602        let result = scaffold_app("auth", tmp.path(), None);
2603        assert!(matches!(result, Err(ScaffoldError::ReservedName(_))));
2604    }
2605
2606    #[test]
2607    fn scaffold_plugin_validates_name_like_startapp() {
2608        let tmp = tempfile::tempdir().expect("tempdir");
2609        assert!(matches!(
2610            scaffold_plugin("2cool", tmp.path(), None),
2611            Err(ScaffoldError::InvalidName(_))
2612        ));
2613        assert!(matches!(
2614            scaffold_plugin("foo bar", tmp.path(), None),
2615            Err(ScaffoldError::InvalidName(_))
2616        ));
2617    }
2618
2619    // ----------------------------------------------------------------- //
2620    // startcommand (gaps3 #81)                                           //
2621    // ----------------------------------------------------------------- //
2622
2623    /// A real scaffolded project to run `startcommand` against — the same
2624    /// `main.rs` a user gets from `umbral startproject`, so the wiring
2625    /// surgery is exercised against the file it actually has to edit, not a
2626    /// fixture written to make the test pass.
2627    fn project(tmp: &tempfile::TempDir) -> PathBuf {
2628        scaffold_project("demo", tmp.path(), None).expect("scaffold_project");
2629        tmp.path().join("demo")
2630    }
2631
2632    fn read(root: &Path, rel: &str) -> String {
2633        fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
2634    }
2635
2636    #[test]
2637    fn startcommand_root_writes_the_command_and_wires_main() {
2638        let tmp = tempfile::tempdir().expect("tempdir");
2639        let root = project(&tmp);
2640
2641        let report =
2642            scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("scaffold");
2643        assert!(
2644            report
2645                .files
2646                .contains(&PathBuf::from("src/commands/backfill_slugs.rs"))
2647        );
2648        assert!(report.files.contains(&PathBuf::from("src/commands/mod.rs")));
2649
2650        // The command file: right struct, right trait, framework's clap.
2651        let cmd = read(&root, "src/commands/backfill_slugs.rs");
2652        assert!(cmd.contains("pub struct BackfillSlugsCommand;"), "{cmd}");
2653        assert!(
2654            cmd.contains("impl PluginCommand for BackfillSlugsCommand"),
2655            "{cmd}"
2656        );
2657        assert!(
2658            cmd.contains("use umbral::cli::{CliError, PluginCommand, clap};"),
2659            "the generated file must import the framework's clap, not its own: {cmd}"
2660        );
2661        assert!(
2662            cmd.contains(r#"clap::Command::new("backfill_slugs")"#),
2663            "{cmd}"
2664        );
2665
2666        // The registry.
2667        let registry = read(&root, "src/commands/mod.rs");
2668        assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2669        assert!(
2670            registry.contains("Box::new(backfill_slugs::BackfillSlugsCommand),"),
2671            "{registry}"
2672        );
2673
2674        // The wiring: main.rs declares the module AND registers the registry.
2675        let main_rs = read(&root, "src/main.rs");
2676        assert!(
2677            main_rs.contains("mod commands;"),
2678            "main.rs never declared the module: {main_rs}"
2679        );
2680        assert!(
2681            main_rs.contains(".commands(commands::all())"),
2682            "main.rs never registered the command registry: {main_rs}"
2683        );
2684        // ...and it goes INSIDE the builder chain, before the terminal build.
2685        let reg = main_rs.find(".commands(commands::all())").unwrap();
2686        let build = main_rs.find(".build_deferred()").unwrap();
2687        assert!(
2688            reg < build,
2689            "`.commands(...)` landed after `.build_deferred()`, which doesn't compile"
2690        );
2691    }
2692
2693    /// The whole reason `all()` exists: the SECOND command is free. It
2694    /// appends to the registry and touches `main.rs` exactly zero more
2695    /// times — no duplicate `mod commands;`, no second `.commands(...)`
2696    /// call (which wouldn't compile as a duplicate... it would silently
2697    /// register the same list twice).
2698    #[test]
2699    fn startcommand_second_command_appends_and_leaves_main_alone() {
2700        let tmp = tempfile::tempdir().expect("tempdir");
2701        let root = project(&tmp);
2702
2703        scaffold_command("backfill_slugs", &CommandTarget::Root, &root).expect("first");
2704        let main_after_first = read(&root, "src/main.rs");
2705        scaffold_command("import-prices", &CommandTarget::Root, &root).expect("second");
2706        let main_after_second = read(&root, "src/main.rs");
2707
2708        assert_eq!(
2709            main_after_first, main_after_second,
2710            "the second startcommand edited main.rs again"
2711        );
2712
2713        let registry = read(&root, "src/commands/mod.rs");
2714        assert!(registry.contains("pub mod backfill_slugs;"), "{registry}");
2715        // A hyphenated command name becomes a snake_case module and a
2716        // PascalCase struct, while the CLI name keeps its hyphen.
2717        assert!(registry.contains("pub mod import_prices;"), "{registry}");
2718        assert!(
2719            registry.contains("Box::new(import_prices::ImportPricesCommand),"),
2720            "{registry}"
2721        );
2722        let cmd = read(&root, "src/commands/import_prices.rs");
2723        assert!(
2724            cmd.contains(r#"clap::Command::new("import-prices")"#),
2725            "the clap name should be what the user typed, hyphens and all: {cmd}"
2726        );
2727
2728        assert_eq!(
2729            main_after_second
2730                .matches(".commands(commands::all())")
2731                .count(),
2732            1,
2733            "main.rs registered the registry twice"
2734        );
2735        assert_eq!(
2736            main_after_second.matches("\nmod commands;").count(),
2737            1,
2738            "main.rs declared `mod commands;` twice"
2739        );
2740    }
2741
2742    #[test]
2743    fn startcommand_plugin_writes_the_command_and_wires_the_plugin() {
2744        let tmp = tempfile::tempdir().expect("tempdir");
2745        let root = project(&tmp);
2746        scaffold_app("blog", &root, None).expect("scaffold_app");
2747
2748        scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
2749            .expect("scaffold");
2750
2751        let plugin_root = root.join("plugins/blog");
2752        let registry = read(&plugin_root, "src/commands/mod.rs");
2753        assert!(registry.contains("pub mod reindex;"), "{registry}");
2754        assert!(
2755            registry.contains("Box::new(reindex::ReindexCommand),"),
2756            "{registry}"
2757        );
2758
2759        let lib_rs = read(&plugin_root, "src/lib.rs");
2760        assert!(lib_rs.contains("pub mod commands;"), "{lib_rs}");
2761        assert!(
2762            lib_rs.contains("fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>>"),
2763            "the plugin never got a `Plugin::commands()` impl: {lib_rs}"
2764        );
2765        assert!(
2766            lib_rs.contains("commands::all()"),
2767            "the impl doesn't return the registry: {lib_rs}"
2768        );
2769        // The method has to land INSIDE the impl block, not after it.
2770        let impl_start = lib_rs.find("impl Plugin for BlogPlugin {").unwrap();
2771        let method = lib_rs.find("fn commands(&self)").unwrap();
2772        assert!(method > impl_start, "the method landed outside the impl");
2773    }
2774
2775    /// `umbral startcommand move` used to sail through validation and write
2776    /// `pub mod move;` into the registry — a syntax error in a file the user
2777    /// never touched. `scaffold_command` was still calling a private copy of
2778    /// the name rules that predated the keyword guard, so the codegen test
2779    /// asserting the correct behaviour passed while the CLI shipped the wrong
2780    /// one. Found by the pre-0.0.10 review sweep.
2781    #[test]
2782    fn startcommand_rejects_a_rust_keyword_as_a_command_name() {
2783        let tmp = tempfile::tempdir().expect("tempdir");
2784        let root = project(&tmp);
2785        for kw in ["move", "type", "match"] {
2786            assert!(
2787                matches!(
2788                    scaffold_command(kw, &CommandTarget::Root, &root),
2789                    Err(ScaffoldError::InvalidName(_))
2790                ),
2791                "`{kw}` is a Rust keyword — `pub mod {kw};` does not parse"
2792            );
2793        }
2794    }
2795
2796    /// A command name that's already a framework built-in would SHADOW it:
2797    /// dispatch tries app/plugin commands before the built-in clap parser.
2798    /// `migrate` would stop migrating, silently. Reject it where the fix is
2799    /// free.
2800    #[test]
2801    fn startcommand_rejects_a_builtin_command_name() {
2802        let tmp = tempfile::tempdir().expect("tempdir");
2803        let root = project(&tmp);
2804        for taken in ["migrate", "serve", "makemigrations", "dev"] {
2805            assert!(
2806                matches!(
2807                    scaffold_command(taken, &CommandTarget::Root, &root),
2808                    Err(ScaffoldError::ReservedCommandName(_))
2809                ),
2810                "`{taken}` is a built-in and must be rejected"
2811            );
2812        }
2813    }
2814
2815    /// Same shadowing hazard, but for a command a built-in *plugin* ships.
2816    /// These can't be read off a clap parser (they only exist on a built
2817    /// App), so they're listed — and the list has to be honoured.
2818    #[test]
2819    fn startcommand_rejects_a_builtin_plugin_command_name() {
2820        let tmp = tempfile::tempdir().expect("tempdir");
2821        let root = project(&tmp);
2822        assert!(matches!(
2823            scaffold_command("createsuperuser", &CommandTarget::Root, &root),
2824            Err(ScaffoldError::ReservedCommandName(_))
2825        ));
2826        assert!(matches!(
2827            scaffold_command("tasks-worker", &CommandTarget::Root, &root),
2828            Err(ScaffoldError::ReservedCommandName(_))
2829        ));
2830    }
2831
2832    /// The reserved set is derived from the clap parser, so a subcommand
2833    /// added to `Command` in lib.rs reserves its own name with no second
2834    /// list to remember to update.
2835    #[test]
2836    fn reserved_command_names_are_read_off_the_real_parser() {
2837        let names = reserved_command_names();
2838        for expected in ["migrate", "serve", "typegen", "squashmigrations", "help"] {
2839            assert!(
2840                names.iter().any(|n| n == expected),
2841                "`{expected}` missing from the reserved set: {names:?}"
2842            );
2843        }
2844    }
2845
2846    #[test]
2847    fn startcommand_rejects_an_unknown_plugin_and_lists_the_real_ones() {
2848        let tmp = tempfile::tempdir().expect("tempdir");
2849        let root = project(&tmp);
2850        scaffold_app("blog", &root, None).expect("scaffold_app");
2851
2852        let err = scaffold_command("reindex", &CommandTarget::Plugin("blgo".into()), &root)
2853            .expect_err("a typo'd plugin name must not scaffold anything");
2854        match err {
2855            ScaffoldError::NoSuchPlugin { asked, available } => {
2856                assert_eq!(asked, "blgo");
2857                assert_eq!(available, vec!["blog".to_string()]);
2858            }
2859            other => panic!("expected NoSuchPlugin, got {other:?}"),
2860        }
2861    }
2862
2863    #[test]
2864    fn startcommand_refuses_to_overwrite_an_existing_command() {
2865        let tmp = tempfile::tempdir().expect("tempdir");
2866        let root = project(&tmp);
2867        scaffold_command("reindex", &CommandTarget::Root, &root).expect("first");
2868        assert!(matches!(
2869            scaffold_command("reindex", &CommandTarget::Root, &root),
2870            Err(ScaffoldError::AlreadyExists(_))
2871        ));
2872    }
2873
2874    #[test]
2875    fn startcommand_outside_a_project_says_so() {
2876        let tmp = tempfile::tempdir().expect("tempdir");
2877        assert!(matches!(
2878            scaffold_command("reindex", &CommandTarget::Root, tmp.path()),
2879            Err(ScaffoldError::NotAProject(_))
2880        ));
2881    }
2882
2883    #[test]
2884    fn discover_plugins_lists_plugin_crates_only() {
2885        let tmp = tempfile::tempdir().expect("tempdir");
2886        let root = project(&tmp);
2887        // A fresh project has an empty `plugins/` (a .gitkeep + README, no crates).
2888        assert!(discover_plugins(&root).is_empty());
2889
2890        scaffold_app("blog", &root, None).expect("scaffold_app");
2891        scaffold_app("shop", &root, None).expect("scaffold_app");
2892        // A stray directory with no Cargo.toml isn't a plugin and must not be
2893        // offered as a home for a command.
2894        fs::create_dir_all(root.join("plugins/notacrate")).unwrap();
2895
2896        assert_eq!(
2897            discover_plugins(&root),
2898            vec!["blog".to_string(), "shop".to_string()]
2899        );
2900    }
2901
2902    // ----------------------------------------------------------------- //
2903    // Regressions found by the pre-0.0.10 review sweep                    //
2904    // ----------------------------------------------------------------- //
2905
2906    /// The `.build()` anchor must belong to the **App** chain. A main.rs that
2907    /// builds anything else first (an HTTP client, a subscriber, a pool) used
2908    /// to capture the insertion: `.commands(commands::all())` was spliced into
2909    /// `reqwest::Client::builder()`, which has no such method. The user's
2910    /// main.rs stopped compiling — in code they never wrote — and the tool
2911    /// printed "Registered".
2912    #[test]
2913    fn startcommand_does_not_splice_into_someone_elses_builder_chain() {
2914        let tmp = tempfile::tempdir().expect("tempdir");
2915        let root = project(&tmp);
2916
2917        let main_rs = root.join("src/main.rs");
2918        let original = read(&root, "src/main.rs");
2919        // A second builder chain, ABOVE the App's, whose terminal `.build()?`
2920        // is the first one in the file.
2921        let with_client = original.replace(
2922            "    let settings = Settings::from_env()?;",
2923            "    let client = reqwest::Client::builder()\n\
2924             \x20       .timeout(Duration::from_secs(5))\n\
2925             \x20       .build()?;\n\n\
2926             \x20   let settings = Settings::from_env()?;",
2927        );
2928        assert_ne!(with_client, original, "fixture did not apply");
2929        fs::write(&main_rs, &with_client).unwrap();
2930
2931        scaffold_command("import_prices", &CommandTarget::Root, &root).expect("scaffold");
2932
2933        let after = read(&root, "src/main.rs");
2934        let commands_at = after.find(".commands(commands::all())").expect("wired");
2935        let client_build_at = after.find(".build()?;").expect("client chain still there");
2936        let app_builder_at = after.find("App::builder()").expect("app chain still there");
2937
2938        assert!(
2939            commands_at > client_build_at,
2940            "`.commands(...)` was spliced into the reqwest chain:\n{after}"
2941        );
2942        assert!(
2943            commands_at > app_builder_at,
2944            "`.commands(...)` landed outside the App::builder() chain:\n{after}"
2945        );
2946    }
2947
2948    /// No `App::builder()` to anchor on → decline, and say so. The command file
2949    /// is still written; the user is told the two lines to add.
2950    #[test]
2951    fn startcommand_declines_when_there_is_no_app_builder_chain() {
2952        let tmp = tempfile::tempdir().expect("tempdir");
2953        let root = project(&tmp);
2954        fs::write(
2955            root.join("src/main.rs"),
2956            "mod seed;\n\nfn main() {\n    println!(\"no app here\");\n}\n",
2957        )
2958        .unwrap();
2959
2960        let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
2961
2962        assert_eq!(
2963            report.registered,
2964            Some(false),
2965            "the tool must not claim a registration it could not perform"
2966        );
2967        let steps = report.next_steps.join("\n");
2968        assert!(steps.contains(".commands(commands::all())"), "{steps}");
2969        assert!(root.join("src/commands/backfill.rs").is_file());
2970    }
2971
2972    /// A partially-wired file must keep the edit it DID make, and the steps must
2973    /// name what it could not. The old code inserted `mod commands;` into a
2974    /// local copy, then returned `Manual` and threw the copy away — while
2975    /// printing only the `.commands(...)` line. The user pasted it and got
2976    /// `failed to resolve: use of undeclared module `commands``.
2977    #[test]
2978    fn startcommand_keeps_the_module_declaration_it_managed_to_add() {
2979        let tmp = tempfile::tempdir().expect("tempdir");
2980        let root = project(&tmp);
2981        // Has a `mod x;` line (so the module CAN be declared) but no App chain
2982        // (so the builder call cannot be inserted).
2983        fs::write(
2984            root.join("src/main.rs"),
2985            "mod seed;\n\nfn main() {\n    println!(\"no app\");\n}\n",
2986        )
2987        .unwrap();
2988
2989        let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("scaffold");
2990
2991        let after = read(&root, "src/main.rs");
2992        assert!(
2993            after.contains("mod commands;"),
2994            "the module declaration the tool made was thrown away: {after}"
2995        );
2996        assert_eq!(report.registered, Some(false));
2997        assert!(
2998            report
2999                .next_steps
3000                .join("\n")
3001                .contains(".commands(commands::all())"),
3002            "the user was not told the one step that remained"
3003        );
3004    }
3005
3006    /// A multi-line `impl Plugin for X` header (a `where` clause, or rustfmt
3007    /// wrapping a long one) must not get the method spliced in before its `{`.
3008    #[test]
3009    fn startcommand_declines_a_plugin_impl_whose_brace_is_on_the_next_line() {
3010        let tmp = tempfile::tempdir().expect("tempdir");
3011        let root = project(&tmp);
3012        scaffold_app("blog", &root, None).expect("scaffold_app");
3013
3014        let lib_rs = root.join("plugins/blog/src/lib.rs");
3015        fs::write(
3016            &lib_rs,
3017            "pub mod models;\n\npub struct BlogPlugin;\n\n\
3018             impl Plugin for BlogPlugin\nwhere\n    Self: Send,\n{\n    \
3019             fn name(&self) -> &'static str {\n        \"blog\"\n    }\n}\n",
3020        )
3021        .unwrap();
3022
3023        let report = scaffold_command("reindex", &CommandTarget::Plugin("blog".to_string()), &root)
3024            .expect("scaffold");
3025
3026        let after = read(&root, "plugins/blog/src/lib.rs");
3027        // The impl header is untouched: no method between it and its `where`.
3028        assert!(
3029            after.contains("impl Plugin for BlogPlugin\nwhere\n    Self: Send,\n{"),
3030            "the generator spliced a method into a multi-line impl header:\n{after}"
3031        );
3032        assert_eq!(report.registered, Some(false));
3033        assert!(
3034            report.next_steps.join("\n").contains("fn commands"),
3035            "the user was not told to add the method by hand"
3036        );
3037    }
3038
3039    /// The module declaration and the `all()` entry are checked INDEPENDENTLY.
3040    /// The old early-return took "`pub mod x;` is present" as proof the registry
3041    /// entry was too, skipped it, and still reported success — so `all()` never
3042    /// returned the command and `cargo run -- x` said "unknown command".
3043    #[test]
3044    fn startcommand_repairs_a_registry_missing_only_its_entry() {
3045        let tmp = tempfile::tempdir().expect("tempdir");
3046        let root = project(&tmp);
3047        scaffold_command("backfill", &CommandTarget::Root, &root).expect("first");
3048
3049        // Simulate the drift: the module is declared, the entry is gone.
3050        let mod_rs = root.join("src/commands/mod.rs");
3051        let text = read(&root, "src/commands/mod.rs")
3052            .lines()
3053            .filter(|l| !l.contains("Box::new(backfill::BackfillCommand)"))
3054            .collect::<Vec<_>>()
3055            .join("\n");
3056        fs::write(&mod_rs, format!("{text}\n")).unwrap();
3057        fs::remove_file(root.join("src/commands/backfill.rs")).unwrap();
3058
3059        let report = scaffold_command("backfill", &CommandTarget::Root, &root).expect("re-run");
3060
3061        let registry = read(&root, "src/commands/mod.rs");
3062        assert_eq!(
3063            registry.matches("pub mod backfill;").count(),
3064            1,
3065            "duplicate module declaration:\n{registry}"
3066        );
3067        assert!(
3068            registry.contains("Box::new(backfill::BackfillCommand),"),
3069            "the registry entry was never restored, but the command reports as \
3070             registered:\n{registry}"
3071        );
3072        assert_eq!(report.registered, Some(true));
3073    }
3074
3075    /// When a user has restructured `commands/mod.rs` past recognition, the
3076    /// tool must not "helpfully" rewrite a file it doesn't understand. It
3077    /// writes the command and hands back the two lines to add.
3078    #[test]
3079    fn startcommand_reports_manual_steps_when_the_registry_markers_are_gone() {
3080        let tmp = tempfile::tempdir().expect("tempdir");
3081        let root = project(&tmp);
3082        scaffold_command("first", &CommandTarget::Root, &root).expect("first");
3083
3084        let mod_rs = root.join("src/commands/mod.rs");
3085        let mangled = read(&root, "src/commands/mod.rs")
3086            .lines()
3087            .filter(|l| !l.trim().starts_with("// umbral:startcommand"))
3088            .collect::<Vec<_>>()
3089            .join("\n");
3090        fs::write(&mod_rs, &mangled).unwrap();
3091
3092        let report = scaffold_command("second", &CommandTarget::Root, &root).expect("second");
3093
3094        // The registry was NOT touched...
3095        assert_eq!(read(&root, "src/commands/mod.rs"), mangled);
3096        // ...and the user was told exactly what to add.
3097        let steps = report.next_steps.join("\n");
3098        assert!(steps.contains("pub mod second;"), "{steps}");
3099        assert!(steps.contains("Box::new(second::SecondCommand)"), "{steps}");
3100    }
3101}