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