Skip to main content

umbral_cli/
lib.rs

1//! Library surface for user binaries to host umbral's management
2//! subcommands.
3//!
4//! umbral-cli ships as two artefacts. The library (this crate) exposes
5//! [`dispatch`] — the entry point user binaries call to gain the
6//! `serve` / `migrate` / `makemigrations` / `inspectdb` /
7//! `dumpdata` / `loaddata` subcommands. The binary (`umbral`) ships as
8//! the global scaffolding tool installed via `cargo install
9//! umbral-cli`, and handles `startproject` / `startapp` from outside
10//! any project.
11//!
12//! ## Quickstart
13//!
14//! In your project's `src/main.rs`:
15//!
16//! ```ignore
17//! use umbral::prelude::*;
18//!
19//! #[tokio::main]
20//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
21//!     tracing_subscriber::fmt::init();
22//!
23//!     let settings = Settings::from_env()?;
24//!     let pool = umbral::db::connect(&settings.database_url).await?;
25//!
26//!     let app = App::builder()
27//!         .settings(settings)
28//!         .database("default", pool)
29//!         .model::<Article>()
30//!         .build_deferred()?;
31//!
32//!     umbral_cli::dispatch(app).await
33//! }
34//! ```
35//!
36//! Then:
37//!
38//! ```bash
39//! cargo run -- migrate
40//! cargo run -- serve
41//! cargo run -- makemigrations
42//! ```
43//!
44//! The subcommands run against the published ambient state (pool,
45//! model registry) that the builder set up, so they see every model
46//! and plugin the user wired into the builder.
47//!
48//! Note `build_deferred()`, not `build()`. It wires everything but leaves each
49//! plugin's `on_ready` hook unfired, so [`dispatch`] can fire it once it knows
50//! what argv asked for — never for `migrate`, which exists precisely because the
51//! tables those hooks want to seed do not exist yet (gaps3 #41).
52
53use std::net::SocketAddr;
54use std::path::PathBuf;
55
56use clap::{CommandFactory, Parser, Subcommand};
57use umbral::App;
58use umbral::inspect::{InspectError, InspectOptions};
59use umbral::migrate::MigrateError;
60
61pub mod scaffold;
62
63/// Build the `cargo` argv for forwarding a `umbral <cmd> [args...]`
64/// invocation to the current project's binary (`cargo run -- <cmd> [args...]`).
65///
66/// The global `umbral` scaffolding binary forwards every non-scaffolding
67/// subcommand here so `umbral dev` behaves as `cargo run -- dev`. The
68/// caller runs `cargo` with these args.
69pub fn cargo_run_forward_args(forwarded: &[String]) -> Vec<String> {
70    let mut argv = vec!["run".to_string(), "--".to_string()];
71    argv.extend(forwarded.iter().cloned());
72    argv
73}
74
75/// Whether `start` (or any ancestor) contains a `Cargo.toml` — i.e. we're
76/// inside a Cargo project `cargo run` could build. Mirrors how `cargo`
77/// itself finds the manifest by walking up from the working directory, so
78/// `umbral <cmd>` works from a subdirectory just like `cargo run` does.
79pub fn in_cargo_project(start: &std::path::Path) -> bool {
80    start
81        .ancestors()
82        .any(|dir| dir.join("Cargo.toml").is_file())
83}
84
85#[derive(Debug, Parser)]
86#[command(
87    name = "umbral",
88    about = "umbral management commands. Run from your project's binary.",
89    disable_help_subcommand = true
90)]
91struct Cli {
92    #[command(subcommand)]
93    command: Option<Command>,
94}
95
96#[derive(Debug, Subcommand)]
97enum Command {
98    /// Boot the HTTP server on `settings.bind_addr`. Default
99    /// subcommand when none is given. Override the bind address with
100    /// `--addr` or `UMBRAL_BIND_ADDR`.
101    Serve {
102        /// Override `settings.bind_addr`. Format: `host:port`
103        /// (e.g. `127.0.0.1:3000`).
104        #[arg(long)]
105        addr: Option<String>,
106    },
107    /// Diff registered models against the latest snapshot and write a
108    /// new migration file per plugin with changes.
109    Makemigrations {
110        /// Write an EMPTY migration for `<plugin>` (current snapshot, no
111        /// operations) instead of auto-detecting a schema diff. The stub
112        /// for a hand-authored data migration: open the file and add a
113        /// `RunSql { sql, reverse_sql }` op. Because it carries no schema
114        /// change, it never disturbs the model-snapshot chain.
115        #[arg(long, value_name = "PLUGIN")]
116        empty: Option<String>,
117    },
118    /// Apply every pending migration against the ambient pool.
119    Migrate {
120        /// Mark a specific migration as applied in the tracking table
121        /// WITHOUT running its SQL. Recovery path when the schema
122        /// already exists (e.g. migrated outside umbral). Format:
123        /// `<plugin>/<migration_name>` (e.g. `app/0001_create_post`).
124        #[arg(long, value_name = "PLUGIN/NAME")]
125        fake: Option<String>,
126        /// For each plugin, if the first migration's tables already
127        /// exist in the database, mark it applied without running SQL.
128        /// Use when adopting a database bootstrapped outside umbral.
129        #[arg(long, default_value_t = false)]
130        fake_initial: bool,
131        /// Proceed even if some applied migrations are missing from
132        /// disk. Logs a warning for each missing file and applies the
133        /// genuinely-pending ones. Without this flag, `migrate` errors
134        /// on drift.
135        #[arg(long, default_value_t = false)]
136        allow_drift: bool,
137        /// Allow destructive operations (DROP TABLE / DROP COLUMN / DROP M2M)
138        /// to be applied. Without this flag, `migrate` REFUSES to run when any
139        /// pending migration would drop a table or column and destroy its rows —
140        /// the guard against one missing `.model::<T>()` registration silently
141        /// dropping a production table (audit_2 core-migrate #6).
142        #[arg(long, default_value_t = false)]
143        allow_destructive: bool,
144        /// Allow migrating an IN-MEMORY database (gaps3 #61).
145        ///
146        /// `migrate` normally refuses, because `sqlite::memory:` is the DEFAULT
147        /// `database_url`: an app whose config never loaded migrates a database that
148        /// evaporates on exit while the command reports "Applied N migration(s)". Success
149        /// against nothing is worse than an error — the operator will trust it.
150        ///
151        /// Ephemeral migrates are legitimate in tests and CI. This flag is how you say so
152        /// out loud.
153        #[arg(long, default_value_t = false)]
154        allow_in_memory: bool,
155    },
156    /// List applied vs pending migrations per plugin.
157    ///
158    /// Markers: [X] applied, [ ] pending, [!] applied-but-missing-on-disk,
159    /// [?] on-disk-but-out-of-order.
160    Showmigrations,
161    /// Classify pending migrations for zero-downtime (blue-green) safety.
162    ///
163    /// Walks every operation in every pending migration and tags it
164    /// SAFE / WARNING / UNSAFE, with an expand-contract note on each
165    /// non-safe op. Exits non-zero when any UNSAFE op is found (or any
166    /// WARNING under `--strict`), so it drops into a CI gate before deploy.
167    /// Read-only — applies nothing.
168    Checkmigrations {
169        /// Also exit non-zero when a WARNING-tier op is present, not just
170        /// UNSAFE. Use in CI when even a column rename must be reviewed.
171        #[arg(long, default_value_t = false)]
172        strict: bool,
173    },
174    /// Generate TypeScript types for every registered model.
175    ///
176    /// The frontend stops hand-maintaining a copy of your schema: an FK
177    /// types as the target's primary key, `Option<T>` as `T | null`, and
178    /// `#[umbral(choices)]` as a string-literal union, so a typo'd status
179    /// fails at `tsc` instead of in production.
180    ///
181    /// Writes to stdout unless `--out` names a file.
182    Typegen {
183        /// File the generated TypeScript is written to. Omit for stdout.
184        #[arg(long)]
185        out: Option<PathBuf>,
186        /// Don't write. Exit non-zero if `--out` differs from what the
187        /// models would generate now. A CI gate against a checked-in
188        /// types file drifting from the schema.
189        #[arg(long, default_value_t = false, requires = "out")]
190        check: bool,
191    },
192    /// Introspect the ambient database into a `models.rs` plus an
193    /// initial migration. Used to onboard an existing schema.
194    Inspectdb {
195        /// Directory the generated files are written under.
196        #[arg(long)]
197        output: PathBuf,
198        /// Record `0001_initial` in `umbral_migrations` after writing
199        /// it, so the next `migrate` is a no-op against the
200        /// already-populated database.
201        #[arg(long, default_value_t = false)]
202        mark_applied: bool,
203    },
204    /// Dump every registered model's rows to JSON. The upgrade-safety
205    /// snapshot.
206    Dumpdata {
207        /// Where the JSON envelope is written.
208        #[arg(long)]
209        output: PathBuf,
210    },
211    /// Load a `dumpdata` JSON envelope into the schema. `migrate`
212    /// first so the schema exists.
213    Loaddata {
214        /// Path to the JSON envelope.
215        input: PathBuf,
216    },
217    /// Import a CSV file into one table's rows. The header row names the
218    /// columns; each cell is coerced to its column type and inserted
219    /// through the same validated write path as a REST POST (validators,
220    /// `auto_now`, `slug_from`, FK-existence all apply). Best-effort: a
221    /// bad row is reported by line number and skipped, not fatal. The
222    /// inverse of the REST list endpoint's `?format=csv` export.
223    Importcsv {
224        /// Target table name (e.g. `blog_post`).
225        table: String,
226        /// Path to the CSV file. Must have a header row.
227        input: PathBuf,
228    },
229    /// Dev-loop runner: watches `src/` and re-runs `cargo run` on
230    /// change. Wraps `cargo-watch`; if not installed, prints the
231    /// install hint and exits. Templates hot-reload in-process when
232    /// `settings.environment == Dev`, so editing an `.html` file
233    /// doesn't need a restart at all.
234    Dev {
235        /// Watch additional paths beyond the default (`src/`,
236        /// `Cargo.toml`). Repeatable.
237        #[arg(long, short = 'w')]
238        watch: Vec<String>,
239        /// Pass-through args to `cargo run`. After `--`, e.g.
240        /// `umbral dev -- migrate` re-runs `cargo run -- migrate`
241        /// on every change.
242        #[arg(last = true)]
243        run_args: Vec<String>,
244    },
245    /// Generate a fresh X25519 keypair for `Masked<T>` field encryption
246    /// and print the two env-var lines (`UMBRAL_MASK_PUBLIC_KEY` /
247    /// `UMBRAL_MASK_PRIVATE_KEY`) needed to configure it.
248    Maskkeygen,
249    /// Collapse a plugin's whole migration history into one optimized squash
250    /// file, non-destructively (the originals stay on disk). Applying the
251    /// squash on a fresh DB builds the schema in one shot; on a DB that already
252    /// ran the originals it records without re-running. Once every deploy has
253    /// migrated past the squash, delete the now-redundant original files.
254    Squashmigrations {
255        /// The plugin whose migrations to squash (e.g. `blog`, `auth`).
256        plugin: String,
257    },
258}
259
260/// Parse argv and run the requested management subcommand against the
261/// passed-in App. The user binary's `main.rs` calls this after
262/// wiring its App — see the module-level docs for the pattern.
263///
264/// # Build the app with [`AppBuilder::build_deferred`]
265///
266/// ```rust,ignore
267/// let app = App::builder()
268///     .settings(settings)
269///     .database("default", pool)
270///     .plugin(AuthPlugin::default())
271///     .build_deferred()?;          // wire, but don't fire `on_ready` yet
272///
273/// umbral_cli::dispatch(app).await  // fires it iff argv warrants it
274/// ```
275///
276/// `on_ready` is where plugins seed content, backfill rows, and create the
277/// standard permissions — all of which need a migrated schema. `dispatch` is the
278/// first place that knows what argv asked for, so it is the only place that can
279/// decide whether the app is really "ready": it fires the hooks for `serve`
280/// (after any auto-migrate) and for every command that runs against live data,
281/// and skips them for the schema commands. See [`command_needs_ready`].
282///
283/// `App::build()` still fires `on_ready` itself, which is right for a test or an
284/// embedder holding an `App` directly. Handing *that* app to `dispatch` leaves
285/// the hooks already fired, which is the gaps3 #41 bug: `migrate` against a fresh
286/// database ran every seed before the first table existed. `dispatch` warns when
287/// it sees that combination.
288pub async fn dispatch(app: App) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
289    let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
290    dispatch_with_argv(app, argv).await
291}
292
293/// The first non-flag token after the program name: the subcommand, or `None`
294/// for a bare `umbral` (which defaults to `serve`) or a flag-only invocation
295/// like `umbral --version`.
296fn subcommand_name(argv: &[std::ffi::OsString]) -> Option<String> {
297    argv.iter()
298        .skip(1)
299        .find(|a| !a.to_string_lossy().starts_with('-'))
300        .map(|a| a.to_string_lossy().into_owned())
301}
302
303/// Whether this subcommand runs against a *live* application, and so should
304/// fire every plugin's `on_ready` before it runs (gaps3 #41).
305///
306/// The `false` arm is the interesting one. Three groups:
307///
308/// - **Schema commands.** `migrate` and friends exist to bring the database up
309///   to the models. Firing hooks that write rows first is backwards: on a fresh
310///   database they run before a single table exists.
311/// - **Offline utilities.** `typegen` reads the model registry, `maskkeygen`
312///   generates a key, `dev` re-execs the binary under a file watcher (the child
313///   process fires its own hooks). None of them touch application rows.
314/// - **`serve`**, and the bare `umbral` that defaults to it. Handled separately
315///   so the hooks fire *after* `auto_migrate_on_serve` has applied migrations,
316///   not before. [`umbral_core::app::App::serve`] calls `ready()` itself.
317///
318/// Everything else — `dumpdata`, `loaddata`, `importcsv`, and every
319/// plugin-contributed command (`createsuperuser`, `worker`, an app's own
320/// `seed_orm_data`) — runs against a database that is expected to be migrated
321/// already, so the hooks fire first, exactly as they did before the split.
322fn command_needs_ready(subcommand: Option<&str>) -> bool {
323    match subcommand {
324        // Bare `umbral` / `umbral --addr …` defaults to serve.
325        None => false,
326        Some(
327            "serve" | "migrate" | "makemigrations" | "showmigrations" | "checkmigrations"
328            | "squashmigrations" | "inspectdb" | "typegen" | "gen-client" | "maskkeygen" | "dev"
329            | "help",
330        ) => false,
331        Some(_) => true,
332    }
333}
334
335/// Same as [`dispatch`] but argv is passed explicitly instead of read
336/// from the process. Lets tests exercise the routing without spawning
337/// a subprocess. User code should call [`dispatch`] (which reads
338/// `std::env::args_os()` and delegates here).
339///
340/// The dispatch order is the same as [`dispatch`]: plugin-contributed
341/// commands first via [`umbral_core::cli::dispatch`], then the built-in
342/// subcommand set (`serve` / `migrate` / etc.).
343pub async fn dispatch_with_argv(
344    app: App,
345    argv: Vec<std::ffi::OsString>,
346) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
347    // Step 0: intercept the unified-help requests before any per-command
348    // clap parser sees argv. `umbral help`, `umbral --help`, and `umbral -h`
349    // all print the merged catalog of built-in + plugin commands and exit
350    // clean. This is gaps2 #54: the user gets one list of everything they
351    // can run, not a per-layer clap help that omits the other layer's
352    // commands. (A bare `umbral` keeps its documented serve default.)
353    if wants_top_level_help(&argv) {
354        print!("{}", render_full_help(&app));
355        return Ok(());
356    }
357
358    // Step 0.5: decide whether this command runs against a live application.
359    // If it does, fire every plugin's `on_ready` before either dispatch layer
360    // runs. If it doesn't — a schema command, an offline utility — the hooks
361    // must not run at all: they seed content into tables `migrate` has not
362    // created yet (gaps3 #41). `serve` is deferred rather than skipped; it fires
363    // them from `App::serve`, after `auto_migrate_on_serve` has applied
364    // migrations. `App::ready` is idempotent, so this is a no-op if the caller
365    // used `App::build()`.
366    let subcommand = subcommand_name(&argv);
367    if command_needs_ready(subcommand.as_deref()) {
368        app.ready()?;
369    } else if app.ready_already_fired() && !matches!(subcommand.as_deref(), None | Some("serve")) {
370        // The caller built with `App::build()`, so the hooks fired before argv
371        // was ever read — the exact shape of gaps3 #41. Nothing we can do about
372        // it here (they've already run), but say so at the moment it bites.
373        eprintln!(
374            "warning: plugin `on_ready` hooks already fired before `{}` ran. They seed \n\
375             content and backfill rows, which is wrong for a schema command against a \n\
376             fresh database. In main.rs, build with `.build_deferred()?` instead of \n\
377             `.build()?` and let `dispatch` decide when the app is ready.",
378            subcommand.as_deref().unwrap_or("<none>"),
379        );
380    }
381
382    // Step 1: try plugin-contributed subcommands first. Each registered
383    // plugin's `commands()` is queried; if argv matches one of them
384    // (e.g. `createsuperuser` from `umbral-auth`, `worker` from
385    // `umbral-tasks`), that command's `run` fires and we return. If no
386    // plugin command matches argv, fall through to the built-in
387    // subcommand set below.
388    if !app.plugins().is_empty() {
389        match umbral_core::cli::dispatch(app.plugins(), argv.clone()).await {
390            Ok(umbral_core::cli::DispatchOutcome::Matched(_)) => return Ok(()),
391            Ok(umbral_core::cli::DispatchOutcome::Help(msg)) => {
392                // A plugin command's --help was requested (e.g.
393                // `umbral createsuperuser --help`). That's command-specific
394                // help, not the top-level catalog, so print clap's
395                // rendered body verbatim and exit clean.
396                print!("{msg}");
397                return Ok(());
398            }
399            Ok(umbral_core::cli::DispatchOutcome::Unmatched) => {
400                // Fall through to the built-in subcommands.
401            }
402            Err(e) => return Err(e),
403        }
404    }
405
406    // Step 2: built-in subcommands. clap parses argv against the fixed
407    // `Command` enum. If argv has a token that's neither a built-in
408    // subcommand nor a plugin command, clap surfaces a usage error here.
409    let cli = match Cli::try_parse_from(&argv) {
410        Ok(c) => c,
411        Err(e) => {
412            use clap::error::ErrorKind;
413            match e.kind() {
414                // Unknown subcommand / stray arg. The token is neither a
415                // plugin command (Step 1 ruled that out) nor a built-in.
416                // Print our unified `error: unknown command` + the full
417                // catalog so the user sees what IS available, then exit
418                // non-zero. Routing through `render_full_help` instead of
419                // clap's default keeps plugin commands in the listing.
420                ErrorKind::InvalidSubcommand
421                | ErrorKind::UnknownArgument
422                | ErrorKind::InvalidValue => {
423                    let bad = unknown_token(&argv);
424                    eprint!("{}", render_unknown(&app, bad.as_deref()));
425                    std::process::exit(2);
426                }
427                _ => {
428                    // Genuine clap output (a subcommand's own --help, a
429                    // missing-required-arg usage error, --version, …).
430                    // Let clap render it as before.
431                    e.print()?;
432                    std::process::exit(if e.use_stderr() { 2 } else { 0 });
433                }
434            }
435        }
436    };
437    match cli.command.unwrap_or(Command::Serve { addr: None }) {
438        Command::Serve { addr } => serve(app, addr).await,
439        Command::Makemigrations { empty } => makemigrations(empty).await,
440        Command::Migrate {
441            fake,
442            fake_initial,
443            allow_drift,
444            allow_destructive,
445            allow_in_memory,
446        } => {
447            migrate(
448                fake,
449                fake_initial,
450                allow_drift,
451                allow_destructive,
452                allow_in_memory,
453            )
454            .await
455        }
456        Command::Showmigrations => showmigrations().await,
457        Command::Checkmigrations { strict } => checkmigrations(strict).await,
458        Command::Typegen { out, check } => typegen(out, check),
459        Command::Inspectdb {
460            output,
461            mark_applied,
462        } => inspectdb(output, mark_applied).await,
463        Command::Dumpdata { output } => dumpdata(output).await,
464        Command::Loaddata { input } => loaddata(input).await,
465        Command::Importcsv { table, input } => importcsv(table, input).await,
466        Command::Dev { watch, run_args } => dev(watch, run_args).await,
467        Command::Maskkeygen => maskkeygen(),
468        Command::Squashmigrations { plugin } => squashmigrations(plugin).await,
469    }
470}
471
472/// gaps2 #100 — collapse `<plugin>`'s migration history into a single optimized
473/// squash file. Non-destructive: originals stay on disk so older deploys keep
474/// working, and the runner treats the squash and its originals as mutually
475/// exclusive. Prints what was written and the next step.
476async fn squashmigrations(plugin: String) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
477    let out = umbral::migrate::squash_in(
478        std::path::Path::new(umbral::migrate::MIGRATIONS_DIR),
479        &plugin,
480    )?;
481    println!(
482        "Squashed {} migrations for `{plugin}` into {}",
483        out.replaced.len(),
484        out.id
485    );
486    println!("  wrote {}", out.path.display());
487    println!("  replaces: {}", out.replaced.join(", "));
488    println!(
489        "\nThe originals are kept on disk (non-destructive). `migrate` now applies the squash on \n\
490         a fresh database and record-only on databases that already ran the originals. Once EVERY \n\
491         deploy has migrated past this squash, delete the {} original file(s) it replaces.",
492        out.replaced.len()
493    );
494    Ok(())
495}
496
497/// The built-in commands that need NO project — no `App`, database, settings,
498/// or compiled models — and can therefore run standalone. Every OTHER command
499/// (`serve`, `migrate`, `makemigrations`, `seed_data`, …) needs the project's
500/// compiled `App`, so the global `umbral` binary forwards it to
501/// `cargo run -- <cmd>` instead.
502///
503/// Keep this in sync with [`try_run_standalone`]. It's a list, not a special
504/// case: add a project-independent utility here and both the global binary and
505/// `cargo run -- <cmd>` pick it up.
506pub const STANDALONE_COMMANDS: &[&str] = &["maskkeygen"];
507
508/// If `argv` names a [project-independent](STANDALONE_COMMANDS) built-in, run it
509/// and return `Some(result)`. Return `None` otherwise, so the caller (the global
510/// `umbral` binary) forwards the command to the project via `cargo run`.
511///
512/// This is what lets `umbral maskkeygen` work anywhere — including outside a
513/// project — without a build, while `umbral migrate` / `umbral seed_data` still
514/// forward to the compiled project that actually owns those commands.
515pub fn try_run_standalone(
516    argv: &[String],
517) -> Option<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
518    match argv.first().map(String::as_str) {
519        Some("maskkeygen") => Some(maskkeygen()),
520        _ => None,
521    }
522}
523
524/// Generate a fresh `Masked<T>` field-encryption keypair and print the
525/// two env-var lines. The public key encrypts (every tier that writes
526/// masked data needs it); the private key decrypts (`reveal()`) and
527/// crypto-shreds on deletion.
528fn maskkeygen() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
529    let (public, secret) = umbral_core::orm::MaskKeyring::generate();
530    println!("# Masked<T> field-encryption keypair — add to your environment / .env:");
531    println!("#   UMBRAL_MASK_PUBLIC_KEY encrypts; UMBRAL_MASK_PRIVATE_KEY decrypts (reveal()).");
532    println!(
533        "#   Keep the PRIVATE key secret. Destroying it crypto-shreds every masked column\n\
534         #   (a fast bulk \"right to be forgotten\")."
535    );
536    println!(
537        "#   WARNING: the private key is printed below to STDOUT. Capture it straight into a\n\
538         #   secret store (Vault, cloud secret manager, a sealed CI variable) and keep it out\n\
539         #   of shell history, terminal scrollback, CI job logs, and any committed .env."
540    );
541    println!("UMBRAL_MASK_PUBLIC_KEY={public}");
542    println!("UMBRAL_MASK_PRIVATE_KEY={secret}");
543    Ok(())
544}
545
546/// True when argv is asking for the top-level command catalog: the
547/// `help` pseudo-subcommand, or a top-level `--help` / `-h`. A `--help`
548/// that follows a subcommand (e.g. `migrate --help`) is NOT top-level —
549/// that's command-specific help and is left to clap, so we only treat
550/// the FIRST post-argv0 token.
551///
552/// A bare `umbral` (no subcommand) is deliberately NOT intercepted: it
553/// keeps its documented default of booting the server (`Serve`), which
554/// the example apps rely on via a plain `cargo run`.
555fn wants_top_level_help(argv: &[std::ffi::OsString]) -> bool {
556    match argv.get(1) {
557        None => false,
558        Some(first) => first == "help" || first == "--help" || first == "-h",
559    }
560}
561
562/// The first non-flag token after argv0 — the subcommand the user
563/// tried to run. Used to name the offending command in the
564/// `error: unknown command \`<x>\`` line.
565fn unknown_token(argv: &[std::ffi::OsString]) -> Option<String> {
566    argv.iter()
567        .skip(1)
568        .find(|a| !a.to_string_lossy().starts_with('-'))
569        .map(|a| a.to_string_lossy().into_owned())
570}
571
572/// Build the merged `(name, about)` catalog: every built-in subcommand
573/// (read off the derived clap `Command` via `CommandFactory`) followed
574/// by every plugin-contributed command. Built-ins are placed first so
575/// they win a name clash in [`umbral_core::cli::render_help`]'s dedup.
576fn full_catalog(app: &App) -> Vec<(String, Option<String>)> {
577    let mut catalog: Vec<(String, Option<String>)> = Vec::new();
578    let root = <Cli as CommandFactory>::command();
579    for sub in root.get_subcommands() {
580        catalog.push((
581            sub.get_name().to_string(),
582            sub.get_about().map(|s| s.to_string()),
583        ));
584    }
585    catalog.extend(umbral_core::cli::command_catalog(app.plugins()));
586    catalog
587}
588
589/// Render the full help screen (built-ins + plugin commands), for
590/// `umbral help` / `umbral --help` / bare `umbral`. Prints to stdout.
591fn render_full_help(app: &App) -> String {
592    umbral_core::cli::render_help(&full_catalog(app))
593}
594
595/// Render the unknown-command screen: an `error: unknown command` line
596/// (naming the bad token if known) followed by the full catalog so the
597/// user sees what they CAN run. Printed to stderr; the caller exits
598/// non-zero.
599fn render_unknown(app: &App, bad: Option<&str>) -> String {
600    let mut s = String::new();
601    match bad {
602        Some(b) => s.push_str(&format!("error: unknown command `{b}`\n\n")),
603        None => s.push_str("error: unknown command\n\n"),
604    }
605    s.push_str(&render_full_help(app));
606    s
607}
608
609/// `umbral dev` — wraps `cargo-watch` to re-run `cargo run` on source
610/// changes. If `cargo-watch` isn't installed, prints the install hint
611/// and exits non-zero so the user notices.
612///
613/// Template edits don't need this command — they hot-reload in-process
614/// when `settings.environment == Dev` (see `umbral-core/src/templates.rs`).
615/// `dev` exists for the Rust-source case where the binary needs a
616/// rebuild + restart.
617async fn dev(
618    extra_watches: Vec<String>,
619    run_args: Vec<String>,
620) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
621    // Probe for cargo-watch up front so the failure message is clear.
622    let probe = std::process::Command::new("cargo")
623        .args(["watch", "--version"])
624        .stdout(std::process::Stdio::null())
625        .stderr(std::process::Stdio::null())
626        .status();
627    if probe.is_err() || probe.as_ref().map(|s| !s.success()).unwrap_or(true) {
628        eprintln!(
629            "umbral dev: `cargo-watch` is not installed.\n\n\
630             Install with:\n\n\
631             \x20\x20\x20\x20cargo install cargo-watch\n\n\
632             Then re-run `cargo run -- dev`.\n\n\
633             Workaround without cargo-watch: leave one terminal running\n\
634             `cargo run` and Ctrl-C + re-run after each edit. Templates\n\
635             still hot-reload in dev mode without any restart.",
636        );
637        std::process::exit(1);
638    }
639
640    // Build the cargo-watch invocation. -x runs the given cargo command;
641    // -w adds extra watch paths. Default watches are cargo-watch's own
642    // (Cargo.toml + src/) so we don't pile -w on every invocation.
643    let mut cmd = std::process::Command::new("cargo");
644    cmd.arg("watch");
645    for path in &extra_watches {
646        cmd.arg("-w").arg(path);
647    }
648    let cargo_cmd = if run_args.is_empty() {
649        "run".to_string()
650    } else {
651        format!("run -- {}", run_args.join(" "))
652    };
653    cmd.arg("-x").arg(&cargo_cmd);
654
655    eprintln!("umbral dev: watching for changes, running `cargo {cargo_cmd}` on each save");
656    eprintln!(
657        "umbral dev: templates also hot-reload in-process; no restart needed for .html edits"
658    );
659    eprintln!("umbral dev: Ctrl-C to stop");
660    eprintln!();
661
662    let status = cmd.status()?;
663    if !status.success() {
664        return Err(format!(
665            "cargo-watch exited with status {}",
666            status
667                .code()
668                .map(|c| c.to_string())
669                .unwrap_or_else(|| "<signal>".to_string())
670        )
671        .into());
672    }
673    Ok(())
674}
675
676async fn serve(
677    app: App,
678    addr_override: Option<String>,
679) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
680    // gaps3 #23: `App::builder().auto_migrate_on_serve()` applies pending
681    // migrations here — on the `serve` command ONLY, never during
682    // `makemigrations` / `migrate` / any other subcommand (which don't route
683    // through this fn). This owns the "migrate exactly when starting the server"
684    // logic that consumers otherwise hand-roll with an argv-sniffing guard.
685    if app.auto_migrate_on_serve_enabled() {
686        let n = umbral::migrate::run().await?;
687        if n > 0 {
688            eprintln!("auto-migrate: applied {n} migration(s)");
689        }
690    }
691    let addr_str = match addr_override {
692        Some(s) => s,
693        None => umbral_core::settings::get().bind_addr.clone(),
694    };
695    let addr: SocketAddr = addr_str
696        .parse()
697        .map_err(|e| format!("umbral: invalid bind_addr `{addr_str}`: {e}"))?;
698    app.serve(addr).await?;
699    Ok(())
700}
701
702async fn makemigrations(
703    empty: Option<String>,
704) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
705    // --empty <plugin>: write a no-op migration (current snapshot, empty
706    // ops) the developer edits to add a `RunSql` data migration.
707    if let Some(plugin) = empty {
708        let path = umbral::migrate::make_empty(&plugin).await?;
709        println!("Wrote {} (empty)", path.display());
710        println!(
711            "  Edit it to add a data migration, e.g.:\n  \
712             {{ \"kind\": \"RunSql\", \"sql\": \"UPDATE ... SET ...\", \
713             \"reverse_sql\": null }}"
714        );
715        return Ok(());
716    }
717
718    match umbral::migrate::make().await {
719        Ok(paths) => {
720            for path in paths {
721                println!("Wrote {}", path.display());
722            }
723            Ok(())
724        }
725        Err(MigrateError::NoChanges) => {
726            println!("no changes detected");
727            Ok(())
728        }
729        Err(err) => Err(Box::new(err)),
730    }
731}
732
733async fn migrate(
734    fake: Option<String>,
735    fake_initial: bool,
736    allow_drift: bool,
737    allow_destructive: bool,
738    allow_in_memory: bool,
739) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
740    // gaps3 #61 — refuse to "migrate" a database that is about to evaporate.
741    //
742    // The default `database_url` is `sqlite::memory:`, so an app whose config never
743    // loaded (a stale `UMBRA_`-prefixed `.env` after the rename, a missing umbral.toml)
744    // silently migrates an IN-MEMORY database and prints "Applied 19 migration(s)". The
745    // command reports success, writes nothing, and the operator has no way to tell —
746    // which is strictly worse than an error, because they will now trust it.
747    //
748    // Found in `examples/shop`, whose entire `.env` had been dead since the rename.
749    if let Some(cfg) = umbral::settings::get_opt() {
750        let url = &cfg.database_url;
751        if !allow_in_memory && (url.contains(":memory:") || url.contains("mode=memory")) {
752            eprintln!("error: umbral migrate: `database_url` is an IN-MEMORY database ({url}).");
753            eprintln!();
754            eprintln!("  Migrating it would apply every migration to a database that is");
755            eprintln!("  discarded the moment this process exits — reporting success and");
756            eprintln!("  persisting nothing.");
757            eprintln!();
758            eprintln!("  `sqlite::memory:` is the DEFAULT, so this almost always means your");
759            eprintln!("  configuration never loaded. Common causes:");
760            eprintln!("    - a `.env` still using the old `UMBRA_` prefix (it is now `UMBRAL_`)");
761            eprintln!("    - no `umbral.toml` and no `UMBRAL_DATABASE_URL` in the environment");
762            eprintln!();
763            eprintln!("  Set UMBRAL_DATABASE_URL (e.g. sqlite://app.db?mode=rwc) and re-run.");
764            eprintln!("  If an ephemeral migrate IS what you want (tests, CI), say so:");
765            eprintln!("    umbral migrate --allow-in-memory");
766            return Err("refusing to migrate an in-memory database".into());
767        }
768    }
769
770    // --fake <plugin/name>: mark one migration applied without running SQL.
771    if let Some(ref spec) = fake {
772        let (plugin, name) = parse_migration_spec(spec)?;
773        umbral::migrate::fake_apply(plugin, name).await?;
774        println!("Marked {spec} as applied (no SQL executed)");
775        return Ok(());
776    }
777
778    // audit_2 core-migrate #6: refuse to APPLY a migration that drops a table /
779    // column (destroys rows) unless the operator explicitly opts in with
780    // `--allow-destructive`. A single missing `.model::<T>()` registration
781    // auto-generates a DropTable, and the plain `makemigrations && migrate` loop
782    // would otherwise drop a production table with no confirmation. This gates
783    // the APPLY (checkmigrations is only advisory / CI-side).
784    if !allow_destructive {
785        let unsafe_ops: Vec<_> = umbral::migrate::check_pending_safety()
786            .await?
787            .into_iter()
788            .filter(|c| c.safety.is_unsafe())
789            .collect();
790        if !unsafe_ops.is_empty() {
791            eprintln!(
792                "error: umbral migrate: {} pending destructive operation(s) would DESTROY DATA:",
793                unsafe_ops.len()
794            );
795            for c in &unsafe_ops {
796                eprintln!(
797                    "    [UNSAFE] {}/{}: {}",
798                    c.plugin,
799                    c.migration,
800                    c.safety.reason()
801                );
802            }
803            eprintln!();
804            eprintln!(
805                "  These usually come from an unregistered model/plugin (a removed \
806                 `.model::<T>()`, a dropped plugin, or a feature flag off).\n  \
807                 If the drop is intended, re-run: `umbral migrate --allow-destructive`.\n  \
808                 If NOT, restore the model registration and re-run `makemigrations`."
809            );
810            return Err(format!(
811                "refusing to apply {} destructive migration operation(s) without --allow-destructive",
812                unsafe_ops.len()
813            )
814            .into());
815        }
816    }
817
818    // --fake-initial: for every plugin, if the 0001 tables exist, fake-apply.
819    if fake_initial {
820        let n = umbral::migrate::fake_initial().await?;
821        if n == 0 {
822            println!("No plugins needed fake-initial (either already applied or tables absent)");
823        } else {
824            println!("Fake-applied initial migration for {n} plugin(s)");
825        }
826        return Ok(());
827    }
828
829    // Normal migrate with optional --allow-drift.
830    match umbral::migrate::run_checked(allow_drift).await {
831        Ok(n) => {
832            if n == 0 {
833                println!("No pending migrations");
834            } else {
835                println!("Applied {n} migration(s)");
836            }
837            Ok(())
838        }
839        Err(MigrateError::DriftDetected { ref missing }) => {
840            let names: Vec<String> = missing.iter().map(|(p, n)| format!("{p}/{n}")).collect();
841            eprintln!("error: umbral migrate: drift detected");
842            eprintln!("  The following migrations are in the tracking table but missing on disk:");
843            for name in &names {
844                eprintln!("    [!] {name}");
845            }
846            eprintln!();
847            eprintln!(
848                "  Options:\n  \
849                 1. Restore the file(s) from VCS.\n  \
850                 2. Run `umbral migrate --allow-drift` to proceed and apply pending migrations.\n  \
851                 3. Run `umbral migrate --fake <plugin/name>` to mark an individual migration \
852                 as applied without running SQL."
853            );
854            Err(Box::new(MigrateError::DriftDetected {
855                missing: missing.clone(),
856            }))
857        }
858        Err(err) => Err(Box::new(err)),
859    }
860}
861
862/// Parse `"plugin/name"` into `(&str, &str)`. Returns an error if the
863/// format is wrong.
864fn parse_migration_spec(
865    spec: &str,
866) -> Result<(&str, &str), Box<dyn std::error::Error + Send + Sync>> {
867    let mut parts = spec.splitn(2, '/');
868    let plugin = parts.next().ok_or("migration spec must be `plugin/name`")?;
869    let name = parts
870        .next()
871        .ok_or("migration spec must be `plugin/name`; missing name after `/`")?;
872    Ok((plugin, name))
873}
874
875async fn showmigrations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
876    let pending = umbral::migrate::show().await?;
877    if pending > 0 {
878        println!("\n{pending} migration(s) not yet applied.");
879    }
880    Ok(())
881}
882
883/// `umbral typegen` — emit TypeScript types for every registered model
884/// (gaps3 #38).
885///
886/// Reads the model registry, which `App::build()` has already populated by the
887/// time `dispatch` runs, so this touches no database.
888///
889/// `--check` is the CI gate: it compares the file `--out` names against what
890/// the models would generate now and exits non-zero on any difference. Run it
891/// beside `cargo test` and a schema change can never merge with a stale types
892/// file next to it.
893fn typegen(
894    out: Option<PathBuf>,
895    check: bool,
896) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
897    let generated = umbral::typegen::typescript();
898
899    let Some(path) = out else {
900        print!("{generated}");
901        return Ok(());
902    };
903
904    if check {
905        // A missing file is drift, not an IO error the operator has to decode.
906        let existing = std::fs::read_to_string(&path).unwrap_or_default();
907        if existing == generated {
908            println!("{} is up to date.", path.display());
909            return Ok(());
910        }
911        return Err(format!(
912            "{} is out of date with the models. Regenerate it:\n    \
913             cargo run -- typegen --out {}",
914            path.display(),
915            path.display(),
916        )
917        .into());
918    }
919
920    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
921        std::fs::create_dir_all(parent)?;
922    }
923    std::fs::write(&path, &generated)?;
924    println!("Wrote {}.", path.display());
925    Ok(())
926}
927
928/// `umbral checkmigrations` — classify every pending operation for
929/// zero-downtime safety (feature #65). Prints the UNSAFE ops first, then
930/// WARNING, then a SAFE count, and exits non-zero when any UNSAFE op is
931/// present (or any WARNING under `--strict`). Applies nothing.
932async fn checkmigrations(strict: bool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
933    let ops = umbral::migrate::check_pending_safety().await?;
934    if ops.is_empty() {
935        println!("No pending migrations — nothing to check.");
936        return Ok(());
937    }
938
939    let unsafe_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_unsafe()).collect();
940    let warn_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_warning()).collect();
941    let safe_count = ops.len() - unsafe_ops.len() - warn_ops.len();
942
943    let migrations: std::collections::BTreeSet<_> =
944        ops.iter().map(|c| (&c.plugin, &c.migration)).collect();
945    println!(
946        "Checking {} operation(s) across {} pending migration(s)...\n",
947        ops.len(),
948        migrations.len()
949    );
950
951    if !unsafe_ops.is_empty() {
952        println!("UNSAFE ({}):", unsafe_ops.len());
953        for c in &unsafe_ops {
954            println!(
955                "  [{}] {}/{} — {}",
956                op_kind(&c.op),
957                c.plugin,
958                c.migration,
959                c.safety.reason()
960            );
961        }
962        println!();
963    }
964
965    if !warn_ops.is_empty() {
966        println!("WARNING ({}):", warn_ops.len());
967        for c in &warn_ops {
968            println!(
969                "  [{}] {}/{} — {}",
970                op_kind(&c.op),
971                c.plugin,
972                c.migration,
973                c.safety.reason()
974            );
975        }
976        println!();
977    }
978
979    println!(
980        "Summary: {} safe, {} warning, {} unsafe.",
981        safe_count,
982        warn_ops.len(),
983        unsafe_ops.len()
984    );
985
986    // Gate: UNSAFE always fails; WARNING fails only under --strict.
987    let blocked = !unsafe_ops.is_empty() || (strict && !warn_ops.is_empty());
988    if blocked {
989        let why = if !unsafe_ops.is_empty() {
990            format!("{} unsafe operation(s) found", unsafe_ops.len())
991        } else {
992            format!("{} warning(s) found (--strict)", warn_ops.len())
993        };
994        return Err(format!(
995            "checkmigrations: {why}. Review the expand-contract notes above before deploying."
996        )
997        .into());
998    }
999
1000    println!("\nAll pending operations are safe for a rolling deploy.");
1001    Ok(())
1002}
1003
1004/// Short uppercase tag for an operation, used in the `checkmigrations`
1005/// report (e.g. `DROP TABLE`, `RENAME COL`, `ADD COL`).
1006fn op_kind(op: &umbral::migrate::Operation) -> &'static str {
1007    use umbral::migrate::Operation;
1008    match op {
1009        Operation::CreateTable { .. } => "CREATE TABLE",
1010        Operation::DropTable { .. } => "DROP TABLE",
1011        Operation::CreateView {
1012            materialized: true, ..
1013        } => "CREATE MATVIEW",
1014        Operation::CreateView { .. } => "CREATE VIEW",
1015        Operation::DropView {
1016            materialized: true, ..
1017        } => "DROP MATVIEW",
1018        Operation::DropView { .. } => "DROP VIEW",
1019        Operation::AddColumn { .. } => "ADD COL",
1020        Operation::DropColumn { .. } => "DROP COL",
1021        Operation::AlterColumn { .. } => "ALTER COL",
1022        Operation::RenameTable { .. } => "RENAME TABLE",
1023        Operation::RenameColumn { .. } => "RENAME COL",
1024        Operation::SetColumnComment { .. } => "COMMENT COL",
1025        Operation::CreateM2MTable { .. } => "CREATE M2M",
1026        Operation::DropM2MTable { .. } => "DROP M2M",
1027        Operation::RunSql { .. } => "RUN SQL",
1028        Operation::AddIndex { unique: true, .. } => "ADD UNIQUE",
1029        Operation::AddIndex { unique: false, .. } => "ADD INDEX",
1030        Operation::DropIndex { .. } => "DROP INDEX",
1031    }
1032}
1033
1034async fn inspectdb(
1035    output: PathBuf,
1036    mark_applied: bool,
1037) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1038    let opts = InspectOptions {
1039        output,
1040        mark_applied,
1041    };
1042    match umbral::inspect::inspectdb(opts).await {
1043        Ok(report) => {
1044            println!(
1045                "Inspected {} table(s), {} column(s)",
1046                report.tables, report.columns,
1047            );
1048            println!("Wrote {}", report.models_path.display());
1049            println!("Wrote {}", report.migration_path.display());
1050            Ok(())
1051        }
1052        Err(InspectError::NoTables) => {
1053            println!("no tables found in the database");
1054            Ok(())
1055        }
1056        Err(err) => Err(Box::new(err)),
1057    }
1058}
1059
1060async fn dumpdata(output: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1061    umbral::backup::dump_to_path(&output).await?;
1062    println!("Wrote {}", output.display());
1063    Ok(())
1064}
1065
1066async fn loaddata(input: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1067    let report = umbral::backup::load_from_path(&input).await?;
1068    println!(
1069        "Loaded {} row(s) into {} table(s)",
1070        report.rows_loaded,
1071        report.tables_loaded.len()
1072    );
1073    for skipped in &report.skipped_tables {
1074        eprintln!("warning: skipped table `{skipped}` (not in current schema)");
1075    }
1076    Ok(())
1077}
1078
1079/// `umbral importcsv <table> <file.csv>` — parse the CSV (the `csv` crate
1080/// handles quoting/escaping) and hand the header + string rows to
1081/// `import_table_rows`, which coerces each cell to its column type and
1082/// inserts through the validated dynamic write path.
1083async fn importcsv(
1084    table: String,
1085    input: PathBuf,
1086) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1087    // Resolve the table against the registered models so a typo fails
1088    // loudly (with the list of valid tables) before we read the file.
1089    let models = umbral::migrate::registered_models();
1090    let Some(meta) = models.into_iter().find(|m| m.table == table) else {
1091        let mut known: Vec<String> = umbral::migrate::registered_models()
1092            .iter()
1093            .map(|m| m.table.clone())
1094            .collect();
1095        known.sort();
1096        return Err(format!(
1097            "importcsv: unknown table `{table}`. Registered tables: {}",
1098            known.join(", ")
1099        )
1100        .into());
1101    };
1102
1103    let mut reader = csv::ReaderBuilder::new()
1104        .has_headers(true)
1105        .flexible(true)
1106        .from_path(&input)?;
1107    let headers: Vec<String> = reader.headers()?.iter().map(|s| s.to_string()).collect();
1108    if headers.is_empty() {
1109        return Err("importcsv: the CSV has no header row".into());
1110    }
1111    let mut rows: Vec<Vec<String>> = Vec::new();
1112    for record in reader.records() {
1113        let record = record?;
1114        rows.push(record.iter().map(|s| s.to_string()).collect());
1115    }
1116
1117    let report = umbral::orm::import_table_rows(&meta, &headers, &rows).await;
1118    println!(
1119        "Imported {} row(s) into `{}` ({} failed)",
1120        report.inserted,
1121        table,
1122        report.errors.len()
1123    );
1124    for (line, message) in &report.errors {
1125        eprintln!("  line {line}: {message}");
1126    }
1127    // Non-zero exit when any row failed, so a CI/script catches a partial
1128    // import without parsing stdout.
1129    if report.errors.is_empty() {
1130        Ok(())
1131    } else {
1132        Err(format!("importcsv: {} row(s) failed", report.errors.len()).into())
1133    }
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139    use async_trait::async_trait;
1140    use clap::ArgMatches;
1141    use umbral::Settings;
1142    use umbral_core::cli::{CliError, PluginCommand};
1143    use umbral_core::plugin::Plugin;
1144
1145    #[test]
1146    fn forward_args_prefix_cargo_run_dashdash() {
1147        // `umbral dev` → `cargo run -- dev`
1148        assert_eq!(
1149            cargo_run_forward_args(&["dev".to_string()]),
1150            vec!["run", "--", "dev"]
1151        );
1152        // Flags and extra args ride along verbatim.
1153        assert_eq!(
1154            cargo_run_forward_args(&[
1155                "migrate".to_string(),
1156                "--fake".to_string(),
1157                "accounts/0001_auto".to_string(),
1158            ]),
1159            vec!["run", "--", "migrate", "--fake", "accounts/0001_auto"]
1160        );
1161    }
1162
1163    #[test]
1164    fn in_cargo_project_detects_manifest_upward() {
1165        let tmp = tempfile::tempdir().expect("tempdir");
1166        let root = tmp.path();
1167        // No Cargo.toml anywhere yet.
1168        assert!(!in_cargo_project(root));
1169        // A manifest at the root is found from a nested subdir (like cargo).
1170        std::fs::write(root.join("Cargo.toml"), b"[package]\nname='x'\n").unwrap();
1171        let nested = root.join("src").join("widgets");
1172        std::fs::create_dir_all(&nested).unwrap();
1173        assert!(in_cargo_project(&nested), "walks up to find the manifest");
1174        assert!(in_cargo_project(root));
1175    }
1176
1177    struct WorkerCmd;
1178
1179    #[async_trait]
1180    impl PluginCommand for WorkerCmd {
1181        fn command(&self) -> clap::Command {
1182            clap::Command::new("tasks-worker").about("Run the task worker")
1183        }
1184        async fn run(&self, _m: &ArgMatches) -> Result<(), CliError> {
1185            Ok(())
1186        }
1187    }
1188
1189    struct WorkerPlugin;
1190
1191    impl Plugin for WorkerPlugin {
1192        fn name(&self) -> &'static str {
1193            "tasks"
1194        }
1195        fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
1196            vec![Box::new(WorkerCmd)]
1197        }
1198    }
1199
1200    async fn app_with_worker() -> App {
1201        let settings = Settings::from_env().expect("figment defaults load");
1202        let pool = umbral::db::connect_sqlite("sqlite::memory:")
1203            .await
1204            .expect("in-memory sqlite connects");
1205        App::builder()
1206            .settings(settings)
1207            .database("default", pool)
1208            .plugin(WorkerPlugin)
1209            .build()
1210            .expect("App builds")
1211    }
1212
1213    #[test]
1214    fn wants_top_level_help_recognizes_help_forms() {
1215        let os = |s: &str| std::ffi::OsString::from(s);
1216        assert!(wants_top_level_help(&[os("umbral"), os("help")]));
1217        assert!(wants_top_level_help(&[os("umbral"), os("--help")]));
1218        assert!(wants_top_level_help(&[os("umbral"), os("-h")]));
1219        // Bare invocation keeps the serve default — NOT intercepted.
1220        assert!(!wants_top_level_help(&[os("umbral")]));
1221        // `migrate --help` is command-specific, left to clap.
1222        assert!(!wants_top_level_help(&[
1223            os("umbral"),
1224            os("migrate"),
1225            os("--help")
1226        ]));
1227        // A real subcommand is not help.
1228        assert!(!wants_top_level_help(&[os("umbral"), os("migrate")]));
1229    }
1230
1231    #[test]
1232    fn unknown_token_picks_first_non_flag() {
1233        let os = |s: &str| std::ffi::OsString::from(s);
1234        assert_eq!(
1235            unknown_token(&[os("umbral"), os("--verbose"), os("frobnicate")]).as_deref(),
1236            Some("frobnicate")
1237        );
1238        assert_eq!(unknown_token(&[os("umbral")]), None);
1239    }
1240
1241    // NOTE: both the help and unknown-command paths are asserted in ONE
1242    // test because `App::build` calls the global `settings::init` (a
1243    // `OnceLock`) which panics if called twice in the same process.
1244    // Building one App and exercising both render paths against it sidesteps
1245    // that, and is also a faithful "one process, one App" shape.
1246    #[tokio::test]
1247    async fn help_and_unknown_list_builtins_and_plugin_commands() {
1248        let app = app_with_worker().await;
1249
1250        // --- full help (umbral help / --help) ---
1251        let out = render_full_help(&app);
1252        // A built-in subcommand with its real `about`.
1253        assert!(
1254            out.contains("migrate"),
1255            "built-in `migrate` missing:\n{out}"
1256        );
1257        assert!(
1258            out.contains("Apply every pending migration"),
1259            "built-in `migrate` about missing:\n{out}"
1260        );
1261        // The plugin-contributed command with its about.
1262        assert!(
1263            out.contains("tasks-worker") && out.contains("Run the task worker"),
1264            "plugin command missing:\n{out}"
1265        );
1266        // Column alignment: built-in and plugin descriptions start at the
1267        // same offset on their respective lines.
1268        let mig_line = out
1269            .lines()
1270            .find(|l| l.trim_start().starts_with("migrate"))
1271            .unwrap();
1272        let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
1273        let mig_col = mig_line.find("Apply every pending migration").unwrap();
1274        let worker_col = worker_line.find("Run the task worker").unwrap();
1275        assert_eq!(mig_col, worker_col, "descriptions not aligned:\n{out}");
1276
1277        // --- unknown command (umbral frobnicate) ---
1278        let out = render_unknown(&app, Some("frobnicate"));
1279        assert!(
1280            out.contains("unknown command") && out.contains("frobnicate"),
1281            "missing unknown-command error:\n{out}"
1282        );
1283        // Still shows what IS available — both a built-in and the plugin cmd.
1284        assert!(out.contains("migrate"), "listing missing built-in:\n{out}");
1285        assert!(
1286            out.contains("tasks-worker"),
1287            "listing missing plugin cmd:\n{out}"
1288        );
1289    }
1290}