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 an existing database into models + a `0001_initial` migration.
193    ///
194    /// The porting on-ramp: point it at a database and it writes one
195    /// `#[derive(Model)]` struct per table plus a migration that recreates the
196    /// schema, so an existing database drops straight into the managed
197    /// declare -> migrate loop.
198    ///
199    /// The source database is the positional argument — a `sqlite://` /
200    /// `postgres://` URL, or a path to a SQLite file. Omit it to use the app's
201    /// ambient database, which you set with the `UMBRAL_DATABASE_URL`
202    /// environment variable. `--output` is REQUIRED: it's the directory the
203    /// generated `models.rs` and `migrations/` land in.
204    ///
205    /// Examples (each is one command):
206    ///
207    /// A Postgres database, URL passed explicitly — `umbral inspectdb postgres://user:pass@localhost/mydb --output plugins/imported`
208    ///
209    /// The app's ambient database (set `UMBRAL_DATABASE_URL` first) — `umbral inspectdb --output plugins/imported`
210    ///
211    /// A SQLite file, undoing Django's conventions — `umbral inspectdb ./legacy.sqlite3 --framework django --output plugins/imported`
212    ///
213    /// A Prisma/Postgres schema, undoing Prisma's conventions — `umbral inspectdb postgres://... --framework prisma --output plugins/imported`
214    ///
215    /// Note: running `inspectdb` (and every other command) requires an umbral project — run it from your project directory (or `cargo run -- inspectdb ...`).
216    Inspectdb {
217        /// The source database to introspect: a `sqlite://` / `postgres://`
218        /// URL, or a path to a SQLite file (`./db.sqlite3`). When omitted,
219        /// the app's ambient database (`UMBRAL_DATABASE_URL`) is used.
220        database: Option<String>,
221        /// The source framework whose naming conventions to undo: `django`
222        /// (also sheds the app prefix + maps `auth_user`), `rails` /
223        /// `laravel` (FK `<field>_id` -> `<field>`), or `prisma` (camelCase
224        /// columns -> snake_case, FK `<field>Id` -> `<field>`). Omit to keep
225        /// the raw database names.
226        #[arg(long)]
227        framework: Option<String>,
228        /// Strip the framework app-prefix off struct names (`blog_post` ->
229        /// `Post`) and preserve the real table with a `#[umbral(table = "...")]`
230        /// macro. Off by default: struct names stay full (`BlogPost`) and
231        /// round-trip to their table, so no table macro is emitted.
232        #[arg(long, default_value_t = false)]
233        with_table_names: bool,
234        /// Directory the generated files are written under.
235        #[arg(long)]
236        output: PathBuf,
237        /// Record `0001_initial` in `umbral_migrations` after writing
238        /// it, so the next `migrate` is a no-op against the
239        /// already-populated database.
240        #[arg(long, default_value_t = false)]
241        mark_applied: bool,
242    },
243    /// Dump every registered model's rows to JSON. The upgrade-safety
244    /// snapshot.
245    Dumpdata {
246        /// Where the JSON envelope is written.
247        #[arg(long)]
248        output: PathBuf,
249    },
250    /// Load a `dumpdata` JSON envelope into the schema. `migrate`
251    /// first so the schema exists.
252    Loaddata {
253        /// Path to the JSON envelope.
254        input: PathBuf,
255    },
256    /// Stream-copy every row from one umbral database to another, preserving
257    /// primary and foreign keys. Resumable: rerun the same command after an
258    /// interruption and it picks up where it stopped. `migrate` the target
259    /// first so its schema exists.
260    Transferdata {
261        /// Source database: a `sqlite://` / `postgres://` URL or a SQLite file
262        /// path (opened read-only).
263        #[arg(long)]
264        from: String,
265        /// Target database: a URL or a SQLite file path (opened read-write).
266        #[arg(long)]
267        to: String,
268        /// Rows per batch / per target transaction.
269        #[arg(long, default_value_t = 1000)]
270        batch: u64,
271        /// Limit the copy to these tables (comma-separated); FK order is still
272        /// respected among them.
273        #[arg(long)]
274        only: Option<String>,
275        /// Translate a foreign-shaped source's column names to the umbral
276        /// target's. A framework preset — `django` / `rails` / `laravel` (FK
277        /// `<field>_id`, junction `<model>_id`) or `prisma` (camelCase
278        /// `<field>Id`) — OR a path to a JSON file for a custom map. The JSON
279        /// maps umbral field names to source columns, per-table and/or globally:
280        /// `{"tables": {"users": {"created_at": "createdAt"}}, "columns": {...}}`.
281        /// Omit for a umbral->umbral copy (columns already match).
282        #[arg(long)]
283        map: Option<String>,
284        /// Copy this many independent tables concurrently (per FK level). `1`
285        /// is fully sequential.
286        #[arg(long, default_value_t = 1)]
287        workers: usize,
288        /// Report the copy order + source row counts without writing anything.
289        #[arg(long, default_value_t = false)]
290        dry_run: bool,
291    },
292    /// Import a CSV file into one table's rows. The header row names the
293    /// columns; each cell is coerced to its column type and inserted
294    /// through the same validated write path as a REST POST (validators,
295    /// `auto_now`, `slug_from`, FK-existence all apply). Best-effort: a
296    /// bad row is reported by line number and skipped, not fatal. The
297    /// inverse of the REST list endpoint's `?format=csv` export.
298    Importcsv {
299        /// Target table name (e.g. `blog_post`).
300        table: String,
301        /// Path to the CSV file. Must have a header row.
302        input: PathBuf,
303    },
304    /// Dev-loop runner: watches `src/` and re-runs `cargo run` on
305    /// change. Wraps `cargo-watch`; if not installed, prints the
306    /// install hint and exits. Templates hot-reload in-process when
307    /// `settings.environment == Dev`, so editing an `.html` file
308    /// doesn't need a restart at all.
309    Dev {
310        /// Watch additional paths beyond the default (`src/`,
311        /// `Cargo.toml`). Repeatable.
312        #[arg(long, short = 'w')]
313        watch: Vec<String>,
314        /// Pass-through args to `cargo run`. After `--`, e.g.
315        /// `umbral dev -- migrate` re-runs `cargo run -- migrate`
316        /// on every change.
317        #[arg(last = true)]
318        run_args: Vec<String>,
319    },
320    /// Generate a fresh X25519 keypair for `Masked<T>` field encryption
321    /// and print the two env-var lines (`UMBRAL_MASK_PUBLIC_KEY` /
322    /// `UMBRAL_MASK_PRIVATE_KEY`) needed to configure it.
323    Maskkeygen,
324    /// Collapse a plugin's whole migration history into one optimized squash
325    /// file, non-destructively (the originals stay on disk). Applying the
326    /// squash on a fresh DB builds the schema in one shot; on a DB that already
327    /// ran the originals it records without re-running. Once every deploy has
328    /// migrated past the squash, delete the now-redundant original files.
329    Squashmigrations {
330        /// The plugin whose migrations to squash (e.g. `blog`, `auth`).
331        plugin: String,
332    },
333}
334
335/// Parse argv and run the requested management subcommand against the
336/// passed-in App. The user binary's `main.rs` calls this after
337/// wiring its App — see the module-level docs for the pattern.
338///
339/// # Build the app with [`AppBuilder::build_deferred`]
340///
341/// ```rust,ignore
342/// let app = App::builder()
343///     .settings(settings)
344///     .database("default", pool)
345///     .plugin(AuthPlugin::default())
346///     .build_deferred()?;          // wire, but don't fire `on_ready` yet
347///
348/// umbral_cli::dispatch(app).await  // fires it iff argv warrants it
349/// ```
350///
351/// `on_ready` is where plugins seed content, backfill rows, and create the
352/// standard permissions — all of which need a migrated schema. `dispatch` is the
353/// first place that knows what argv asked for, so it is the only place that can
354/// decide whether the app is really "ready": it fires the hooks for `serve`
355/// (after any auto-migrate) and for every command that runs against live data,
356/// and skips them for the schema commands. See [`command_needs_ready`].
357///
358/// `App::build()` still fires `on_ready` itself, which is right for a test or an
359/// embedder holding an `App` directly. Handing *that* app to `dispatch` leaves
360/// the hooks already fired, which is the gaps3 #41 bug: `migrate` against a fresh
361/// database ran every seed before the first table existed. `dispatch` warns when
362/// it sees that combination.
363pub async fn dispatch(app: App) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
364    let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
365    dispatch_with_argv(app, argv).await
366}
367
368/// The first non-flag token after the program name: the subcommand, or `None`
369/// for a bare `umbral` (which defaults to `serve`) or a flag-only invocation
370/// like `umbral --version`.
371fn subcommand_name(argv: &[std::ffi::OsString]) -> Option<String> {
372    argv.iter()
373        .skip(1)
374        .find(|a| !a.to_string_lossy().starts_with('-'))
375        .map(|a| a.to_string_lossy().into_owned())
376}
377
378/// Whether this subcommand runs against a *live* application, and so should
379/// fire every plugin's `on_ready` before it runs (gaps3 #41).
380///
381/// The `false` arm is the interesting one. Three groups:
382///
383/// - **Schema commands.** `migrate` and friends exist to bring the database up
384///   to the models. Firing hooks that write rows first is backwards: on a fresh
385///   database they run before a single table exists.
386/// - **Offline utilities.** `typegen` reads the model registry, `maskkeygen`
387///   generates a key, `dev` re-execs the binary under a file watcher (the child
388///   process fires its own hooks). None of them touch application rows.
389/// - **`serve`**, and the bare `umbral` that defaults to it. Handled separately
390///   so the hooks fire *after* `auto_migrate_on_serve` has applied migrations,
391///   not before. [`umbral_core::app::App::serve`] calls `ready()` itself.
392///
393/// Everything else — `dumpdata`, `loaddata`, `importcsv`, and every
394/// plugin-contributed command (`createsuperuser`, `worker`, an app's own
395/// `seed_orm_data`) — runs against a database that is expected to be migrated
396/// already, so the hooks fire first, exactly as they did before the split.
397fn builtin_needs_ready(subcommand: Option<&str>) -> bool {
398    match subcommand {
399        // Bare `umbral` / `umbral --addr …` defaults to serve.
400        None => false,
401        // INVARIANT: every name here must be one of THIS binary's own clap
402        // subcommands (see `builtin_command_names`). A plugin's command must
403        // never appear — it answers for itself via `PluginCommand::needs_ready`,
404        // which is consulted first, so a name listed here that belongs to a
405        // plugin is simply dead and misleading. `gen-client` (umbral-openapi)
406        // used to be in this list; the moment `needs_ready` landed, the list
407        // stopped being consulted for it and it silently started firing
408        // `on_ready` again. It now declares `needs_ready() -> false` itself.
409        Some(
410            "serve" | "migrate" | "makemigrations" | "showmigrations" | "checkmigrations"
411            | "squashmigrations" | "inspectdb" | "typegen" | "maskkeygen" | "dev" | "help",
412        ) => false,
413        Some(_) => true,
414    }
415}
416
417/// Same as [`dispatch`] but argv is passed explicitly instead of read
418/// from the process. Lets tests exercise the routing without spawning
419/// a subprocess. User code should call [`dispatch`] (which reads
420/// `std::env::args_os()` and delegates here).
421///
422/// The dispatch order is the same as [`dispatch`]: the app's own commands
423/// (`AppBuilder::command`) and the plugin-contributed ones first, via
424/// [`umbral_core::cli::dispatch_with_app_commands`], then the built-in
425/// subcommand set (`serve` / `migrate` / etc.).
426pub async fn dispatch_with_argv(
427    app: App,
428    argv: Vec<std::ffi::OsString>,
429) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
430    // Step 0: intercept the unified-help requests before any per-command
431    // clap parser sees argv. `umbral help`, `umbral --help`, and `umbral -h`
432    // all print the merged catalog of built-in + plugin commands and exit
433    // clean. This is gaps2 #54: the user gets one list of everything they
434    // can run, not a per-layer clap help that omits the other layer's
435    // commands. (A bare `umbral` keeps its documented serve default.)
436    if wants_top_level_help(&argv) {
437        print!("{}", render_full_help(&app));
438        return Ok(());
439    }
440
441    // Step 0.5: decide whether this command runs against a live application.
442    // If it does, fire every plugin's `on_ready` before either dispatch layer
443    // runs. If it doesn't — a schema command, an offline utility — the hooks
444    // must not run at all: they seed content into tables `migrate` has not
445    // created yet (gaps3 #41). `serve` is deferred rather than skipped; it fires
446    // them from `App::serve`, after `auto_migrate_on_serve` has applied
447    // migrations. `App::ready` is idempotent, so this is a no-op if the caller
448    // used `App::build()`.
449    let subcommand = subcommand_name(&argv);
450    let builtins = builtin_command_names();
451    let reserved: Vec<&str> = builtins.iter().map(String::as_str).collect();
452
453    // Collect the registered commands ONCE. Collecting runs every plugin's
454    // command constructors, builds each command's clap parser, and prints the
455    // built-in-shadow warning — so asking the three questions below via three
456    // separate collections printed that warning three times and rebuilt every
457    // parser three times. One `CommandSet`, three questions.
458    let commands = umbral_core::cli::CommandSet::collect(app.commands(), app.plugins(), &reserved);
459
460    // A registered command gets to say whether it needs a live app
461    // (`PluginCommand::needs_ready`). A code generator like `startpermission`
462    // says no: firing `on_ready` would run every plugin's seeding/backfill
463    // before writing a file, and on a fresh checkout that fails against tables
464    // `migrate` has not created yet. Only if NO registered command claims the
465    // name do we fall back to `builtin_needs_ready`, which speaks only for this
466    // binary's own subcommands.
467    let needs_ready = subcommand
468        .as_deref()
469        .and_then(|name| commands.needs_ready(name))
470        .unwrap_or_else(|| builtin_needs_ready(subcommand.as_deref()));
471
472    if needs_ready {
473        app.ready()?;
474    } else if app.ready_already_fired() && !matches!(subcommand.as_deref(), None | Some("serve")) {
475        // The caller built with `App::build()`, so the hooks fired before argv
476        // was ever read — the exact shape of gaps3 #41. Nothing we can do about
477        // it here (they've already run), but say so at the moment it bites.
478        eprintln!(
479            "warning: plugin `on_ready` hooks already fired before `{}` ran. They seed \n\
480             content and backfill rows, which is wrong for a schema command against a \n\
481             fresh database. In main.rs, build with `.build_deferred()?` instead of \n\
482             `.build()?` and let `dispatch` decide when the app is ready.",
483            subcommand.as_deref().unwrap_or("<none>"),
484        );
485    }
486
487    // Step 1: try the project's own commands and the plugin-contributed
488    // ones first. The App's `AppBuilder::command` registrations come first
489    // (what `umbral startcommand --in root` writes), then each registered
490    // plugin's `commands()` — `createsuperuser` from `umbral-auth`,
491    // `tasks-worker` from `umbral-tasks`. If argv matches one, that
492    // command's `run` fires and we return; otherwise we fall through to
493    // the built-in subcommand set below.
494    if !commands.is_empty() {
495        match commands.dispatch(argv.clone()).await {
496            Ok(umbral_core::cli::DispatchOutcome::Matched(_)) => return Ok(()),
497            Ok(umbral_core::cli::DispatchOutcome::Help(msg)) => {
498                // A plugin command's --help was requested (e.g.
499                // `umbral createsuperuser --help`). That's command-specific
500                // help, not the top-level catalog, so print clap's
501                // rendered body verbatim and exit clean.
502                print!("{msg}");
503                return Ok(());
504            }
505            Ok(umbral_core::cli::DispatchOutcome::Unmatched) => {
506                // Fall through to the built-in subcommands.
507            }
508            Err(e) => return Err(e),
509        }
510    }
511
512    // Step 2: built-in subcommands. clap parses argv against the fixed
513    // `Command` enum. If argv has a token that's neither a built-in
514    // subcommand nor a plugin command, clap surfaces a usage error here.
515    let cli = match Cli::try_parse_from(&argv) {
516        Ok(c) => c,
517        Err(e) => {
518            use clap::error::ErrorKind;
519            match e.kind() {
520                // Unknown subcommand / stray arg. The token is neither a
521                // plugin command (Step 1 ruled that out) nor a built-in.
522                // Print our unified `error: unknown command` + the full
523                // catalog so the user sees what IS available, then exit
524                // non-zero. Routing through `render_full_help` instead of
525                // clap's default keeps plugin commands in the listing.
526                ErrorKind::InvalidSubcommand
527                | ErrorKind::UnknownArgument
528                | ErrorKind::InvalidValue => {
529                    let bad = unknown_token(&argv);
530                    eprint!("{}", render_unknown(&app, bad.as_deref()));
531                    std::process::exit(2);
532                }
533                _ => {
534                    // Genuine clap output (a subcommand's own --help, a
535                    // missing-required-arg usage error, --version, …).
536                    // Let clap render it as before.
537                    e.print()?;
538                    std::process::exit(if e.use_stderr() { 2 } else { 0 });
539                }
540            }
541        }
542    };
543    match cli.command.unwrap_or(Command::Serve { addr: None }) {
544        Command::Serve { addr } => serve(app, addr).await,
545        Command::Makemigrations { empty } => makemigrations(empty).await,
546        Command::Migrate {
547            fake,
548            fake_initial,
549            allow_drift,
550            allow_destructive,
551            allow_in_memory,
552        } => {
553            migrate(
554                fake,
555                fake_initial,
556                allow_drift,
557                allow_destructive,
558                allow_in_memory,
559            )
560            .await
561        }
562        Command::Showmigrations => showmigrations().await,
563        Command::Checkmigrations { strict } => checkmigrations(strict).await,
564        Command::Typegen { out, check } => typegen(out, check),
565        Command::Inspectdb {
566            database,
567            framework,
568            with_table_names,
569            output,
570            mark_applied,
571        } => inspectdb(database, framework, with_table_names, output, mark_applied).await,
572        Command::Dumpdata { output } => dumpdata(output).await,
573        Command::Loaddata { input } => loaddata(input).await,
574        Command::Transferdata {
575            from,
576            to,
577            batch,
578            only,
579            map,
580            workers,
581            dry_run,
582        } => transferdata(from, to, batch, only, map, workers, dry_run).await,
583        Command::Importcsv { table, input } => importcsv(table, input).await,
584        Command::Dev { watch, run_args } => dev(watch, run_args).await,
585        Command::Maskkeygen => maskkeygen(),
586        Command::Squashmigrations { plugin } => squashmigrations(plugin).await,
587    }
588}
589
590/// gaps2 #100 — collapse `<plugin>`'s migration history into a single optimized
591/// squash file. Non-destructive: originals stay on disk so older deploys keep
592/// working, and the runner treats the squash and its originals as mutually
593/// exclusive. Prints what was written and the next step.
594async fn squashmigrations(plugin: String) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
595    let out = umbral::migrate::squash_in(
596        std::path::Path::new(umbral::migrate::MIGRATIONS_DIR),
597        &plugin,
598    )?;
599    println!(
600        "Squashed {} migrations for `{plugin}` into {}",
601        out.replaced.len(),
602        out.id
603    );
604    println!("  wrote {}", out.path.display());
605    println!("  replaces: {}", out.replaced.join(", "));
606    println!(
607        "\nThe originals are kept on disk (non-destructive). `migrate` now applies the squash on \n\
608         a fresh database and record-only on databases that already ran the originals. Once EVERY \n\
609         deploy has migrated past this squash, delete the {} original file(s) it replaces.",
610        out.replaced.len()
611    );
612    Ok(())
613}
614
615/// The built-in commands that need NO project — no `App`, database, settings,
616/// or compiled models — and can therefore run standalone. Every OTHER command
617/// (`serve`, `migrate`, `makemigrations`, `seed_data`, …) needs the project's
618/// compiled `App`, so the global `umbral` binary forwards it to
619/// `cargo run -- <cmd>` instead.
620///
621/// Keep this in sync with [`try_run_standalone`]. It's a list, not a special
622/// case: add a project-independent utility here and both the global binary and
623/// `cargo run -- <cmd>` pick it up.
624pub const STANDALONE_COMMANDS: &[&str] = &["maskkeygen"];
625
626/// If `argv` names a [project-independent](STANDALONE_COMMANDS) built-in, run it
627/// and return `Some(result)`. Return `None` otherwise, so the caller (the global
628/// `umbral` binary) forwards the command to the project via `cargo run`.
629///
630/// This is what lets `umbral maskkeygen` work anywhere — including outside a
631/// project — without a build, while `umbral migrate` / `umbral seed_data` still
632/// forward to the compiled project that actually owns those commands.
633pub fn try_run_standalone(
634    argv: &[String],
635) -> Option<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
636    match argv.first().map(String::as_str) {
637        Some("maskkeygen") => Some(maskkeygen()),
638        _ => None,
639    }
640}
641
642/// Generate a fresh `Masked<T>` field-encryption keypair and print the
643/// two env-var lines. The public key encrypts (every tier that writes
644/// masked data needs it); the private key decrypts (`reveal()`) and
645/// crypto-shreds on deletion.
646fn maskkeygen() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
647    let (public, secret) = umbral_core::orm::MaskKeyring::generate();
648    println!("# Masked<T> field-encryption keypair — add to your environment / .env:");
649    println!("#   UMBRAL_MASK_PUBLIC_KEY encrypts; UMBRAL_MASK_PRIVATE_KEY decrypts (reveal()).");
650    println!(
651        "#   Keep the PRIVATE key secret. Destroying it crypto-shreds every masked column\n\
652         #   (a fast bulk \"right to be forgotten\")."
653    );
654    println!(
655        "#   WARNING: the private key is printed below to STDOUT. Capture it straight into a\n\
656         #   secret store (Vault, cloud secret manager, a sealed CI variable) and keep it out\n\
657         #   of shell history, terminal scrollback, CI job logs, and any committed .env."
658    );
659    println!("UMBRAL_MASK_PUBLIC_KEY={public}");
660    println!("UMBRAL_MASK_PRIVATE_KEY={secret}");
661    Ok(())
662}
663
664/// True when argv is asking for the top-level command catalog: the
665/// `help` pseudo-subcommand, or a top-level `--help` / `-h`. A `--help`
666/// that follows a subcommand (e.g. `migrate --help`) is NOT top-level —
667/// that's command-specific help and is left to clap, so we only treat
668/// the FIRST post-argv0 token.
669///
670/// A bare `umbral` (no subcommand) is deliberately NOT intercepted: it
671/// keeps its documented default of booting the server (`Serve`), which
672/// the example apps rely on via a plain `cargo run`.
673fn wants_top_level_help(argv: &[std::ffi::OsString]) -> bool {
674    match argv.get(1) {
675        None => false,
676        Some(first) => first == "help" || first == "--help" || first == "-h",
677    }
678}
679
680/// The first non-flag token after argv0 — the subcommand the user
681/// tried to run. Used to name the offending command in the
682/// `error: unknown command \`<x>\`` line.
683fn unknown_token(argv: &[std::ffi::OsString]) -> Option<String> {
684    argv.iter()
685        .skip(1)
686        .find(|a| !a.to_string_lossy().starts_with('-'))
687        .map(|a| a.to_string_lossy().into_owned())
688}
689
690/// Build the merged `(name, about)` catalog: every built-in subcommand
691/// (read off the derived clap `Command` via `CommandFactory`), then the
692/// project's own `AppBuilder::command` registrations, then every
693/// plugin-contributed command. Built-ins are placed first so they win a
694/// name clash in [`umbral_core::cli::render_help`]'s dedup.
695fn full_catalog(app: &App) -> Vec<(String, Option<String>)> {
696    let mut catalog: Vec<(String, Option<String>)> = Vec::new();
697    let root = <Cli as CommandFactory>::command();
698    for sub in root.get_subcommands() {
699        catalog.push((
700            sub.get_name().to_string(),
701            sub.get_about().map(|s| s.to_string()),
702        ));
703    }
704    let builtins = builtin_command_names();
705    let reserved: Vec<&str> = builtins.iter().map(String::as_str).collect();
706    catalog.extend(umbral_core::cli::command_catalog_with_app_commands(
707        app.commands(),
708        app.plugins(),
709        &reserved,
710    ));
711    catalog
712}
713
714/// The framework binary's own subcommands — `serve`, `migrate`, `makemigrations`,
715/// … — read off the derived clap parser rather than hand-listed, so a new
716/// subcommand reserves its own name with nothing to remember.
717///
718/// These names are **unavailable** to an app or plugin command. Dispatch tries
719/// registered commands before the built-in parser, so a command named `migrate`
720/// would not collide loudly — it would quietly take over, and the next deploy
721/// would apply zero migrations and exit 0. `collect_commands` drops any command
722/// that lands on one of these, and says so.
723pub fn builtin_command_names() -> Vec<String> {
724    let mut names: Vec<String> = <Cli as CommandFactory>::command()
725        .get_subcommands()
726        .map(|s| s.get_name().to_string())
727        .collect();
728    names.push("help".to_string());
729    names
730}
731
732/// Render the full help screen (built-ins + plugin commands), for
733/// `umbral help` / `umbral --help` / bare `umbral`. Prints to stdout.
734fn render_full_help(app: &App) -> String {
735    umbral_core::cli::render_help(&full_catalog(app))
736}
737
738/// Render the unknown-command screen: an `error: unknown command` line
739/// (naming the bad token if known) followed by the full catalog so the
740/// user sees what they CAN run. Printed to stderr; the caller exits
741/// non-zero.
742fn render_unknown(app: &App, bad: Option<&str>) -> String {
743    let mut s = String::new();
744    match bad {
745        Some(b) => s.push_str(&format!("error: unknown command `{b}`\n\n")),
746        None => s.push_str("error: unknown command\n\n"),
747    }
748    s.push_str(&render_full_help(app));
749    s
750}
751
752/// `umbral dev` — wraps `cargo-watch` to re-run `cargo run` on source
753/// changes. If `cargo-watch` isn't installed, prints the install hint
754/// and exits non-zero so the user notices.
755///
756/// Template edits don't need this command — they hot-reload in-process
757/// when `settings.environment == Dev` (see `umbral-core/src/templates.rs`).
758/// `dev` exists for the Rust-source case where the binary needs a
759/// rebuild + restart.
760async fn dev(
761    extra_watches: Vec<String>,
762    run_args: Vec<String>,
763) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
764    // Probe for cargo-watch up front so the failure message is clear.
765    let probe = std::process::Command::new("cargo")
766        .args(["watch", "--version"])
767        .stdout(std::process::Stdio::null())
768        .stderr(std::process::Stdio::null())
769        .status();
770    if probe.is_err() || probe.as_ref().map(|s| !s.success()).unwrap_or(true) {
771        eprintln!(
772            "umbral dev: `cargo-watch` is not installed.\n\n\
773             Install with:\n\n\
774             \x20\x20\x20\x20cargo install cargo-watch\n\n\
775             Then re-run `cargo run -- dev`.\n\n\
776             Workaround without cargo-watch: leave one terminal running\n\
777             `cargo run` and Ctrl-C + re-run after each edit. Templates\n\
778             still hot-reload in dev mode without any restart.",
779        );
780        std::process::exit(1);
781    }
782
783    // Build the cargo-watch invocation. -x runs the given cargo command;
784    // -w adds extra watch paths. Default watches are cargo-watch's own
785    // (Cargo.toml + src/) so we don't pile -w on every invocation.
786    let mut cmd = std::process::Command::new("cargo");
787    cmd.arg("watch");
788    for path in &extra_watches {
789        cmd.arg("-w").arg(path);
790    }
791    let cargo_cmd = if run_args.is_empty() {
792        "run".to_string()
793    } else {
794        format!("run -- {}", run_args.join(" "))
795    };
796    cmd.arg("-x").arg(&cargo_cmd);
797
798    eprintln!("umbral dev: watching for changes, running `cargo {cargo_cmd}` on each save");
799    eprintln!(
800        "umbral dev: templates also hot-reload in-process; no restart needed for .html edits"
801    );
802    eprintln!("umbral dev: Ctrl-C to stop");
803    eprintln!();
804
805    let status = cmd.status()?;
806    if !status.success() {
807        return Err(format!(
808            "cargo-watch exited with status {}",
809            status
810                .code()
811                .map(|c| c.to_string())
812                .unwrap_or_else(|| "<signal>".to_string())
813        )
814        .into());
815    }
816    Ok(())
817}
818
819async fn serve(
820    app: App,
821    addr_override: Option<String>,
822) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
823    // gaps3 #23: `App::builder().auto_migrate_on_serve()` applies pending
824    // migrations here — on the `serve` command ONLY, never during
825    // `makemigrations` / `migrate` / any other subcommand (which don't route
826    // through this fn). This owns the "migrate exactly when starting the server"
827    // logic that consumers otherwise hand-roll with an argv-sniffing guard.
828    if app.auto_migrate_on_serve_enabled() {
829        // gaps4 #47: in Dev, ALSO autodetect first — the equivalent of
830        // `makemigrations` — so a model change is picked up on the next
831        // `serve` with no explicit command. Prod only applies pending
832        // migrations; a server never generates migration files.
833        let dev = matches!(
834            umbral_core::settings::get().environment,
835            umbral::Environment::Dev
836        );
837        if dev {
838            match umbral::migrate::make().await {
839                Ok(paths) => {
840                    for path in paths {
841                        eprintln!("auto-migrate: wrote {}", path.display());
842                    }
843                }
844                Err(umbral::migrate::MigrateError::NoChanges) => {}
845                Err(err) => return Err(Box::new(err)),
846            }
847        }
848        let n = umbral::migrate::run().await?;
849        if n > 0 {
850            eprintln!("auto-migrate: applied {n} migration(s)");
851        }
852    }
853    // gaps4 #47: the seed hook runs on serve only, AFTER migrations (a seed
854    // writes to tables migrations create). The contract is idempotence —
855    // it runs on every boot.
856    if let Some(seed) = app.seed_on_serve_hook() {
857        seed().await?;
858    }
859    let addr_str = match addr_override {
860        Some(s) => s,
861        None => umbral_core::settings::get().bind_addr.clone(),
862    };
863    let addr: SocketAddr = addr_str
864        .parse()
865        .map_err(|e| format!("umbral: invalid bind_addr `{addr_str}`: {e}"))?;
866    app.serve(addr).await?;
867    Ok(())
868}
869
870async fn makemigrations(
871    empty: Option<String>,
872) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
873    // --empty <plugin>: write a no-op migration (current snapshot, empty
874    // ops) the developer edits to add a `RunSql` data migration.
875    if let Some(plugin) = empty {
876        let path = umbral::migrate::make_empty(&plugin).await?;
877        println!("Wrote {} (empty)", path.display());
878        println!(
879            "  Edit it to add a data migration, e.g.:\n  \
880             {{ \"kind\": \"RunSql\", \"sql\": \"UPDATE ... SET ...\", \
881             \"reverse_sql\": null }}"
882        );
883        return Ok(());
884    }
885
886    match umbral::migrate::make().await {
887        Ok(paths) => {
888            for path in paths {
889                println!("Wrote {}", path.display());
890            }
891            Ok(())
892        }
893        Err(MigrateError::NoChanges) => {
894            println!("no changes detected");
895            Ok(())
896        }
897        Err(err) => Err(Box::new(err)),
898    }
899}
900
901async fn migrate(
902    fake: Option<String>,
903    fake_initial: bool,
904    allow_drift: bool,
905    allow_destructive: bool,
906    allow_in_memory: bool,
907) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
908    // gaps3 #61 — refuse to "migrate" a database that is about to evaporate.
909    //
910    // The default `database_url` is `sqlite::memory:`, so an app whose config never
911    // loaded (a stale `UMBRA_`-prefixed `.env` after the rename, a missing umbral.toml)
912    // silently migrates an IN-MEMORY database and prints "Applied 19 migration(s)". The
913    // command reports success, writes nothing, and the operator has no way to tell —
914    // which is strictly worse than an error, because they will now trust it.
915    //
916    // Found in `examples/shop`, whose entire `.env` had been dead since the rename.
917    if let Some(cfg) = umbral::settings::get_opt() {
918        let url = &cfg.database_url;
919        if !allow_in_memory && (url.contains(":memory:") || url.contains("mode=memory")) {
920            eprintln!("error: umbral migrate: `database_url` is an IN-MEMORY database ({url}).");
921            eprintln!();
922            eprintln!("  Migrating it would apply every migration to a database that is");
923            eprintln!("  discarded the moment this process exits — reporting success and");
924            eprintln!("  persisting nothing.");
925            eprintln!();
926            eprintln!("  `sqlite::memory:` is the DEFAULT, so this almost always means your");
927            eprintln!("  configuration never loaded. Common causes:");
928            eprintln!("    - a `.env` still using the old `UMBRA_` prefix (it is now `UMBRAL_`)");
929            eprintln!("    - no `umbral.toml` and no `UMBRAL_DATABASE_URL` in the environment");
930            eprintln!();
931            eprintln!("  Set UMBRAL_DATABASE_URL (e.g. sqlite://app.db?mode=rwc) and re-run.");
932            eprintln!("  If an ephemeral migrate IS what you want (tests, CI), say so:");
933            eprintln!("    umbral migrate --allow-in-memory");
934            return Err("refusing to migrate an in-memory database".into());
935        }
936    }
937
938    // --fake <plugin/name>: mark one migration applied without running SQL.
939    if let Some(ref spec) = fake {
940        let (plugin, name) = parse_migration_spec(spec)?;
941        umbral::migrate::fake_apply(plugin, name).await?;
942        println!("Marked {spec} as applied (no SQL executed)");
943        return Ok(());
944    }
945
946    // audit_2 core-migrate #6: refuse to APPLY a migration that drops a table /
947    // column (destroys rows) unless the operator explicitly opts in with
948    // `--allow-destructive`. A single missing `.model::<T>()` registration
949    // auto-generates a DropTable, and the plain `makemigrations && migrate` loop
950    // would otherwise drop a production table with no confirmation. This gates
951    // the APPLY (checkmigrations is only advisory / CI-side).
952    if !allow_destructive {
953        let unsafe_ops: Vec<_> = umbral::migrate::check_pending_safety()
954            .await?
955            .into_iter()
956            .filter(|c| c.safety.is_unsafe())
957            .collect();
958        if !unsafe_ops.is_empty() {
959            eprintln!(
960                "error: umbral migrate: {} pending destructive operation(s) would DESTROY DATA:",
961                unsafe_ops.len()
962            );
963            for c in &unsafe_ops {
964                eprintln!(
965                    "    [UNSAFE] {}/{}: {}",
966                    c.plugin,
967                    c.migration,
968                    c.safety.reason()
969                );
970            }
971            eprintln!();
972            eprintln!(
973                "  These usually come from an unregistered model/plugin (a removed \
974                 `.model::<T>()`, a dropped plugin, or a feature flag off).\n  \
975                 If the drop is intended, re-run: `umbral migrate --allow-destructive`.\n  \
976                 If NOT, restore the model registration and re-run `makemigrations`."
977            );
978            return Err(format!(
979                "refusing to apply {} destructive migration operation(s) without --allow-destructive",
980                unsafe_ops.len()
981            )
982            .into());
983        }
984    }
985
986    // --fake-initial: for every plugin, if the 0001 tables exist, fake-apply.
987    if fake_initial {
988        let n = umbral::migrate::fake_initial().await?;
989        if n == 0 {
990            println!("No plugins needed fake-initial (either already applied or tables absent)");
991        } else {
992            println!("Fake-applied initial migration for {n} plugin(s)");
993        }
994        return Ok(());
995    }
996
997    // Normal migrate with optional --allow-drift.
998    match umbral::migrate::run_checked(allow_drift).await {
999        Ok(n) => {
1000            if n == 0 {
1001                println!("No pending migrations");
1002            } else {
1003                println!("Applied {n} migration(s)");
1004            }
1005            Ok(())
1006        }
1007        Err(MigrateError::DriftDetected { ref missing }) => {
1008            let names: Vec<String> = missing.iter().map(|(p, n)| format!("{p}/{n}")).collect();
1009            eprintln!("error: umbral migrate: drift detected");
1010            eprintln!("  The following migrations are in the tracking table but missing on disk:");
1011            for name in &names {
1012                eprintln!("    [!] {name}");
1013            }
1014            eprintln!();
1015            eprintln!(
1016                "  Options:\n  \
1017                 1. Restore the file(s) from VCS.\n  \
1018                 2. Run `umbral migrate --allow-drift` to proceed and apply pending migrations.\n  \
1019                 3. Run `umbral migrate --fake <plugin/name>` to mark an individual migration \
1020                 as applied without running SQL."
1021            );
1022            Err(Box::new(MigrateError::DriftDetected {
1023                missing: missing.clone(),
1024            }))
1025        }
1026        Err(err) => Err(Box::new(err)),
1027    }
1028}
1029
1030/// Parse `"plugin/name"` into `(&str, &str)`. Returns an error if the
1031/// format is wrong.
1032fn parse_migration_spec(
1033    spec: &str,
1034) -> Result<(&str, &str), Box<dyn std::error::Error + Send + Sync>> {
1035    let mut parts = spec.splitn(2, '/');
1036    let plugin = parts.next().ok_or("migration spec must be `plugin/name`")?;
1037    let name = parts
1038        .next()
1039        .ok_or("migration spec must be `plugin/name`; missing name after `/`")?;
1040    Ok((plugin, name))
1041}
1042
1043async fn showmigrations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1044    let pending = umbral::migrate::show().await?;
1045    if pending > 0 {
1046        println!("\n{pending} migration(s) not yet applied.");
1047    }
1048    Ok(())
1049}
1050
1051/// `umbral typegen` — emit TypeScript types for every registered model
1052/// (gaps3 #38).
1053///
1054/// Reads the model registry, which `App::build()` has already populated by the
1055/// time `dispatch` runs, so this touches no database.
1056///
1057/// `--check` is the CI gate: it compares the file `--out` names against what
1058/// the models would generate now and exits non-zero on any difference. Run it
1059/// beside `cargo test` and a schema change can never merge with a stale types
1060/// file next to it.
1061fn typegen(
1062    out: Option<PathBuf>,
1063    check: bool,
1064) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1065    let generated = umbral::typegen::typescript();
1066
1067    let Some(path) = out else {
1068        print!("{generated}");
1069        return Ok(());
1070    };
1071
1072    if check {
1073        // A missing file is drift, not an IO error the operator has to decode.
1074        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1075        if existing == generated {
1076            println!("{} is up to date.", path.display());
1077            return Ok(());
1078        }
1079        return Err(format!(
1080            "{} is out of date with the models. Regenerate it:\n    \
1081             cargo run -- typegen --out {}",
1082            path.display(),
1083            path.display(),
1084        )
1085        .into());
1086    }
1087
1088    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
1089        std::fs::create_dir_all(parent)?;
1090    }
1091    std::fs::write(&path, &generated)?;
1092    println!("Wrote {}.", path.display());
1093    Ok(())
1094}
1095
1096/// `umbral checkmigrations` — classify every pending operation for
1097/// zero-downtime safety (feature #65). Prints the UNSAFE ops first, then
1098/// WARNING, then a SAFE count, and exits non-zero when any UNSAFE op is
1099/// present (or any WARNING under `--strict`). Applies nothing.
1100async fn checkmigrations(strict: bool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1101    let ops = umbral::migrate::check_pending_safety().await?;
1102    if ops.is_empty() {
1103        println!("No pending migrations — nothing to check.");
1104        return Ok(());
1105    }
1106
1107    let unsafe_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_unsafe()).collect();
1108    let warn_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_warning()).collect();
1109    let safe_count = ops.len() - unsafe_ops.len() - warn_ops.len();
1110
1111    let migrations: std::collections::BTreeSet<_> =
1112        ops.iter().map(|c| (&c.plugin, &c.migration)).collect();
1113    println!(
1114        "Checking {} operation(s) across {} pending migration(s)...\n",
1115        ops.len(),
1116        migrations.len()
1117    );
1118
1119    if !unsafe_ops.is_empty() {
1120        println!("UNSAFE ({}):", unsafe_ops.len());
1121        for c in &unsafe_ops {
1122            println!(
1123                "  [{}] {}/{} — {}",
1124                op_kind(&c.op),
1125                c.plugin,
1126                c.migration,
1127                c.safety.reason()
1128            );
1129        }
1130        println!();
1131    }
1132
1133    if !warn_ops.is_empty() {
1134        println!("WARNING ({}):", warn_ops.len());
1135        for c in &warn_ops {
1136            println!(
1137                "  [{}] {}/{} — {}",
1138                op_kind(&c.op),
1139                c.plugin,
1140                c.migration,
1141                c.safety.reason()
1142            );
1143        }
1144        println!();
1145    }
1146
1147    println!(
1148        "Summary: {} safe, {} warning, {} unsafe.",
1149        safe_count,
1150        warn_ops.len(),
1151        unsafe_ops.len()
1152    );
1153
1154    // Gate: UNSAFE always fails; WARNING fails only under --strict.
1155    let blocked = !unsafe_ops.is_empty() || (strict && !warn_ops.is_empty());
1156    if blocked {
1157        let why = if !unsafe_ops.is_empty() {
1158            format!("{} unsafe operation(s) found", unsafe_ops.len())
1159        } else {
1160            format!("{} warning(s) found (--strict)", warn_ops.len())
1161        };
1162        return Err(format!(
1163            "checkmigrations: {why}. Review the expand-contract notes above before deploying."
1164        )
1165        .into());
1166    }
1167
1168    println!("\nAll pending operations are safe for a rolling deploy.");
1169    Ok(())
1170}
1171
1172/// Short uppercase tag for an operation, used in the `checkmigrations`
1173/// report (e.g. `DROP TABLE`, `RENAME COL`, `ADD COL`).
1174fn op_kind(op: &umbral::migrate::Operation) -> &'static str {
1175    use umbral::migrate::Operation;
1176    match op {
1177        Operation::CreateTable { .. } => "CREATE TABLE",
1178        Operation::DropTable { .. } => "DROP TABLE",
1179        Operation::CreateView {
1180            materialized: true, ..
1181        } => "CREATE MATVIEW",
1182        Operation::CreateView { .. } => "CREATE VIEW",
1183        Operation::DropView {
1184            materialized: true, ..
1185        } => "DROP MATVIEW",
1186        Operation::DropView { .. } => "DROP VIEW",
1187        Operation::AddColumn { .. } => "ADD COL",
1188        Operation::DropColumn { .. } => "DROP COL",
1189        Operation::AlterColumn { .. } => "ALTER COL",
1190        Operation::RenameTable { .. } => "RENAME TABLE",
1191        Operation::RenameColumn { .. } => "RENAME COL",
1192        Operation::SetColumnComment { .. } => "COMMENT COL",
1193        Operation::CreateM2MTable { .. } => "CREATE M2M",
1194        Operation::DropM2MTable { .. } => "DROP M2M",
1195        Operation::RunSql { .. } => "RUN SQL",
1196        Operation::AddIndex { unique: true, .. } => "ADD UNIQUE",
1197        Operation::AddIndex { unique: false, .. } => "ADD INDEX",
1198        Operation::DropIndex { .. } => "DROP INDEX",
1199    }
1200}
1201
1202async fn inspectdb(
1203    database: Option<String>,
1204    framework: Option<String>,
1205    with_table_names: bool,
1206    output: PathBuf,
1207    mark_applied: bool,
1208) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1209    // Reject an unknown `--framework` up front with a clear message rather than
1210    // silently ignoring it.
1211    let framework = match framework.as_deref() {
1212        None => None,
1213        Some(name) => match umbral::inspect::Framework::parse(name) {
1214            Some(f) => Some(f),
1215            None => {
1216                return Err(format!(
1217                    "unknown --framework `{name}`; supported: django (or omit to keep raw names)"
1218                )
1219                .into());
1220            }
1221        },
1222    };
1223    let opts = InspectOptions {
1224        source: database.map(|d| normalize_source_db(&d)),
1225        framework,
1226        with_table_names,
1227        output,
1228        mark_applied,
1229    };
1230    match umbral::inspect::inspectdb(opts).await {
1231        Ok(report) => {
1232            println!(
1233                "Inspected {} table(s), {} column(s)",
1234                report.tables, report.columns,
1235            );
1236            println!("Wrote {}", report.models_path.display());
1237            println!("Wrote {}", report.migration_path.display());
1238            Ok(())
1239        }
1240        Err(InspectError::NoTables) => {
1241            println!("no tables found in the database");
1242            Ok(())
1243        }
1244        Err(err) => Err(Box::new(err)),
1245    }
1246}
1247
1248/// Normalize a user-supplied `inspectdb` source into a connection URL.
1249///
1250/// A value that already looks like a URL (`sqlite://…`, `postgres://…`,
1251/// `postgresql://…`, or the in-memory `sqlite::memory:`) is passed through
1252/// untouched. Anything else is treated as a **path to a SQLite file** and
1253/// wrapped as a read-only `sqlite://<abs-path>?mode=ro`, so `umbral inspectdb
1254/// ./db.sqlite3` works without the caller hand-writing a URL and can't mutate
1255/// the source database it's only reading.
1256fn normalize_source_db(input: &str) -> String {
1257    let lower = input.to_ascii_lowercase();
1258    if lower.starts_with("sqlite:")
1259        || lower.starts_with("postgres://")
1260        || lower.starts_with("postgresql://")
1261    {
1262        return input.to_string();
1263    }
1264    // A bare filesystem path. Absolutize so a relative path resolves against
1265    // the caller's CWD rather than sqlx's, then open read-only.
1266    let abs = std::fs::canonicalize(input)
1267        .map(|p| p.to_string_lossy().into_owned())
1268        .unwrap_or_else(|_| input.to_string());
1269    format!("sqlite://{abs}?mode=ro")
1270}
1271
1272async fn dumpdata(output: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1273    umbral::backup::dump_to_path(&output).await?;
1274    println!("Wrote {}", output.display());
1275    Ok(())
1276}
1277
1278async fn loaddata(input: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1279    let report = umbral::backup::load_from_path(&input).await?;
1280    println!(
1281        "Loaded {} row(s) into {} table(s)",
1282        report.rows_loaded,
1283        report.tables_loaded.len()
1284    );
1285    for skipped in &report.skipped_tables {
1286        eprintln!("warning: skipped table `{skipped}` (not in current schema)");
1287    }
1288    Ok(())
1289}
1290
1291/// A writable target DB: URLs pass through; a bare path opens read-write
1292/// (`?mode=rwc`), the twin of [`normalize_source_db`]'s read-only default.
1293fn normalize_target_db(input: &str) -> String {
1294    let lower = input.to_ascii_lowercase();
1295    if lower.starts_with("sqlite:")
1296        || lower.starts_with("postgres://")
1297        || lower.starts_with("postgresql://")
1298    {
1299        return input.to_string();
1300    }
1301    let abs = std::fs::canonicalize(input)
1302        .map(|p| p.to_string_lossy().into_owned())
1303        .unwrap_or_else(|_| input.to_string());
1304    format!("sqlite://{abs}?mode=rwc")
1305}
1306
1307#[allow(clippy::too_many_arguments)]
1308async fn transferdata(
1309    from: String,
1310    to: String,
1311    batch: u64,
1312    only: Option<String>,
1313    map: Option<String>,
1314    workers: usize,
1315    dry_run: bool,
1316) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1317    let map = match map.as_deref() {
1318        None => umbral::transfer::TransferMap::None,
1319        // A framework preset name, or a path to a custom-map JSON file.
1320        Some(arg) => umbral::transfer::TransferMap::from_cli_arg(arg)?,
1321    };
1322    let source = umbral::db::connect(&normalize_source_db(&from)).await?;
1323    let target = umbral::db::connect(&normalize_target_db(&to)).await?;
1324    let models = umbral::migrate::registered_models();
1325    let opts = umbral::transfer::TransferOptions {
1326        batch_size: batch,
1327        only: only.map(|s| {
1328            s.split(',')
1329                .map(|t| t.trim().to_string())
1330                .filter(|t| !t.is_empty())
1331                .collect()
1332        }),
1333        map,
1334        workers,
1335        dry_run,
1336    };
1337    let report = umbral::transfer::transfer(&source, &target, models, &opts).await?;
1338    if dry_run {
1339        println!("Dry run — copy order and source row counts:");
1340    }
1341    for (table, n) in &report.per_table {
1342        println!("  {table}: {n} rows");
1343    }
1344    println!(
1345        "{} {} row(s) across {} table(s)",
1346        if dry_run { "Would copy" } else { "Copied" },
1347        report.rows,
1348        report.per_table.len(),
1349    );
1350    Ok(())
1351}
1352
1353/// `umbral importcsv <table> <file.csv>` — parse the CSV (the `csv` crate
1354/// handles quoting/escaping) and hand the header + string rows to
1355/// `import_table_rows`, which coerces each cell to its column type and
1356/// inserts through the validated dynamic write path.
1357async fn importcsv(
1358    table: String,
1359    input: PathBuf,
1360) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1361    // Resolve the table against the registered models so a typo fails
1362    // loudly (with the list of valid tables) before we read the file.
1363    let models = umbral::migrate::registered_models();
1364    let Some(meta) = models.into_iter().find(|m| m.table == table) else {
1365        let mut known: Vec<String> = umbral::migrate::registered_models()
1366            .iter()
1367            .map(|m| m.table.clone())
1368            .collect();
1369        known.sort();
1370        return Err(format!(
1371            "importcsv: unknown table `{table}`. Registered tables: {}",
1372            known.join(", ")
1373        )
1374        .into());
1375    };
1376
1377    let mut reader = csv::ReaderBuilder::new()
1378        .has_headers(true)
1379        .flexible(true)
1380        .from_path(&input)?;
1381    let headers: Vec<String> = reader.headers()?.iter().map(|s| s.to_string()).collect();
1382    if headers.is_empty() {
1383        return Err("importcsv: the CSV has no header row".into());
1384    }
1385    let mut rows: Vec<Vec<String>> = Vec::new();
1386    for record in reader.records() {
1387        let record = record?;
1388        rows.push(record.iter().map(|s| s.to_string()).collect());
1389    }
1390
1391    let report = umbral::orm::import_table_rows(&meta, &headers, &rows).await;
1392    println!(
1393        "Imported {} row(s) into `{}` ({} failed)",
1394        report.inserted,
1395        table,
1396        report.errors.len()
1397    );
1398    for (line, message) in &report.errors {
1399        eprintln!("  line {line}: {message}");
1400    }
1401    // Non-zero exit when any row failed, so a CI/script catches a partial
1402    // import without parsing stdout.
1403    if report.errors.is_empty() {
1404        Ok(())
1405    } else {
1406        Err(format!("importcsv: {} row(s) failed", report.errors.len()).into())
1407    }
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412    use super::*;
1413    use async_trait::async_trait;
1414    use clap::ArgMatches;
1415    use umbral::Settings;
1416    use umbral_core::cli::{CliError, PluginCommand};
1417    use umbral_core::plugin::Plugin;
1418
1419    #[test]
1420    fn forward_args_prefix_cargo_run_dashdash() {
1421        // `umbral dev` → `cargo run -- dev`
1422        assert_eq!(
1423            cargo_run_forward_args(&["dev".to_string()]),
1424            vec!["run", "--", "dev"]
1425        );
1426        // Flags and extra args ride along verbatim.
1427        assert_eq!(
1428            cargo_run_forward_args(&[
1429                "migrate".to_string(),
1430                "--fake".to_string(),
1431                "accounts/0001_auto".to_string(),
1432            ]),
1433            vec!["run", "--", "migrate", "--fake", "accounts/0001_auto"]
1434        );
1435    }
1436
1437    #[test]
1438    fn in_cargo_project_detects_manifest_upward() {
1439        let tmp = tempfile::tempdir().expect("tempdir");
1440        let root = tmp.path();
1441        // No Cargo.toml anywhere yet.
1442        assert!(!in_cargo_project(root));
1443        // A manifest at the root is found from a nested subdir (like cargo).
1444        std::fs::write(root.join("Cargo.toml"), b"[package]\nname='x'\n").unwrap();
1445        let nested = root.join("src").join("widgets");
1446        std::fs::create_dir_all(&nested).unwrap();
1447        assert!(in_cargo_project(&nested), "walks up to find the manifest");
1448        assert!(in_cargo_project(root));
1449    }
1450
1451    struct WorkerCmd;
1452
1453    #[async_trait]
1454    impl PluginCommand for WorkerCmd {
1455        fn command(&self) -> clap::Command {
1456            clap::Command::new("tasks-worker").about("Run the task worker")
1457        }
1458        async fn run(&self, _m: &ArgMatches) -> Result<(), CliError> {
1459            Ok(())
1460        }
1461    }
1462
1463    struct WorkerPlugin;
1464
1465    impl Plugin for WorkerPlugin {
1466        fn name(&self) -> &'static str {
1467            "tasks"
1468        }
1469        fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
1470            vec![Box::new(WorkerCmd)]
1471        }
1472    }
1473
1474    async fn app_with_worker() -> App {
1475        let settings = Settings::from_env().expect("figment defaults load");
1476        let pool = umbral::db::connect_sqlite("sqlite::memory:")
1477            .await
1478            .expect("in-memory sqlite connects");
1479        App::builder()
1480            .settings(settings)
1481            .database("default", pool)
1482            .plugin(WorkerPlugin)
1483            .build()
1484            .expect("App builds")
1485    }
1486
1487    #[test]
1488    fn wants_top_level_help_recognizes_help_forms() {
1489        let os = |s: &str| std::ffi::OsString::from(s);
1490        assert!(wants_top_level_help(&[os("umbral"), os("help")]));
1491        assert!(wants_top_level_help(&[os("umbral"), os("--help")]));
1492        assert!(wants_top_level_help(&[os("umbral"), os("-h")]));
1493        // Bare invocation keeps the serve default — NOT intercepted.
1494        assert!(!wants_top_level_help(&[os("umbral")]));
1495        // `migrate --help` is command-specific, left to clap.
1496        assert!(!wants_top_level_help(&[
1497            os("umbral"),
1498            os("migrate"),
1499            os("--help")
1500        ]));
1501        // A real subcommand is not help.
1502        assert!(!wants_top_level_help(&[os("umbral"), os("migrate")]));
1503    }
1504
1505    #[test]
1506    fn unknown_token_picks_first_non_flag() {
1507        let os = |s: &str| std::ffi::OsString::from(s);
1508        assert_eq!(
1509            unknown_token(&[os("umbral"), os("--verbose"), os("frobnicate")]).as_deref(),
1510            Some("frobnicate")
1511        );
1512        assert_eq!(unknown_token(&[os("umbral")]), None);
1513    }
1514
1515    // NOTE: both the help and unknown-command paths are asserted in ONE
1516    // test because `App::build` calls the global `settings::init` (a
1517    // `OnceLock`) which panics if called twice in the same process.
1518    // Building one App and exercising both render paths against it sidesteps
1519    // that, and is also a faithful "one process, one App" shape.
1520    #[tokio::test]
1521    async fn help_and_unknown_list_builtins_and_plugin_commands() {
1522        let app = app_with_worker().await;
1523
1524        // --- full help (umbral help / --help) ---
1525        let out = render_full_help(&app);
1526        // A built-in subcommand with its real `about`.
1527        assert!(
1528            out.contains("migrate"),
1529            "built-in `migrate` missing:\n{out}"
1530        );
1531        assert!(
1532            out.contains("Apply every pending migration"),
1533            "built-in `migrate` about missing:\n{out}"
1534        );
1535        // The plugin-contributed command with its about.
1536        assert!(
1537            out.contains("tasks-worker") && out.contains("Run the task worker"),
1538            "plugin command missing:\n{out}"
1539        );
1540        // Column alignment: built-in and plugin descriptions start at the
1541        // same offset on their respective lines.
1542        let mig_line = out
1543            .lines()
1544            .find(|l| l.trim_start().starts_with("migrate"))
1545            .unwrap();
1546        let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
1547        let mig_col = mig_line.find("Apply every pending migration").unwrap();
1548        let worker_col = worker_line.find("Run the task worker").unwrap();
1549        assert_eq!(mig_col, worker_col, "descriptions not aligned:\n{out}");
1550
1551        // --- unknown command (umbral frobnicate) ---
1552        let out = render_unknown(&app, Some("frobnicate"));
1553        assert!(
1554            out.contains("unknown command") && out.contains("frobnicate"),
1555            "missing unknown-command error:\n{out}"
1556        );
1557        // Still shows what IS available — both a built-in and the plugin cmd.
1558        assert!(out.contains("migrate"), "listing missing built-in:\n{out}");
1559        assert!(
1560            out.contains("tasks-worker"),
1561            "listing missing plugin cmd:\n{out}"
1562        );
1563    }
1564}