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}
216
217/// Parse argv and run the requested management subcommand against the
218/// passed-in App. The user binary's `main.rs` calls this after
219/// building its App — see the module-level docs for the pattern.
220///
221/// The App must already be built (`App::builder()...build()?`) — the
222/// builder phases publish the ambient pool and model registry, which
223/// every management command reads. Passing a built `App` instead of
224/// an `AppBuilder` keeps the boot order in the user's hands and lets
225/// them register plugins / models / databases freely before
226/// dispatching.
227pub async fn dispatch(app: App) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
228    let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
229    dispatch_with_argv(app, argv).await
230}
231
232/// Same as [`dispatch`] but argv is passed explicitly instead of read
233/// from the process. Lets tests exercise the routing without spawning
234/// a subprocess. User code should call [`dispatch`] (which reads
235/// `std::env::args_os()` and delegates here).
236///
237/// The dispatch order is the same as [`dispatch`]: plugin-contributed
238/// commands first via [`umbral_core::cli::dispatch`], then the built-in
239/// subcommand set (`serve` / `migrate` / etc.).
240pub async fn dispatch_with_argv(
241    app: App,
242    argv: Vec<std::ffi::OsString>,
243) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
244    // Step 0: intercept the unified-help requests before any per-command
245    // clap parser sees argv. `umbral help`, `umbral --help`, and `umbral -h`
246    // all print the merged catalog of built-in + plugin commands and exit
247    // clean. This is gaps2 #54: the user gets one list of everything they
248    // can run, not a per-layer clap help that omits the other layer's
249    // commands. (A bare `umbral` keeps its documented serve default.)
250    if wants_top_level_help(&argv) {
251        print!("{}", render_full_help(&app));
252        return Ok(());
253    }
254
255    // Step 1: try plugin-contributed subcommands first. Each registered
256    // plugin's `commands()` is queried; if argv matches one of them
257    // (e.g. `createsuperuser` from `umbral-auth`, `worker` from
258    // `umbral-tasks`), that command's `run` fires and we return. If no
259    // plugin command matches argv, fall through to the built-in
260    // subcommand set below.
261    if !app.plugins().is_empty() {
262        match umbral_core::cli::dispatch(app.plugins(), argv.clone()).await {
263            Ok(umbral_core::cli::DispatchOutcome::Matched(_)) => return Ok(()),
264            Ok(umbral_core::cli::DispatchOutcome::Help(msg)) => {
265                // A plugin command's --help was requested (e.g.
266                // `umbral createsuperuser --help`). That's command-specific
267                // help, not the top-level catalog, so print clap's
268                // rendered body verbatim and exit clean.
269                print!("{msg}");
270                return Ok(());
271            }
272            Ok(umbral_core::cli::DispatchOutcome::Unmatched) => {
273                // Fall through to the built-in subcommands.
274            }
275            Err(e) => return Err(e),
276        }
277    }
278
279    // Step 2: built-in subcommands. clap parses argv against the fixed
280    // `Command` enum. If argv has a token that's neither a built-in
281    // subcommand nor a plugin command, clap surfaces a usage error here.
282    let cli = match Cli::try_parse_from(&argv) {
283        Ok(c) => c,
284        Err(e) => {
285            use clap::error::ErrorKind;
286            match e.kind() {
287                // Unknown subcommand / stray arg. The token is neither a
288                // plugin command (Step 1 ruled that out) nor a built-in.
289                // Print our unified `error: unknown command` + the full
290                // catalog so the user sees what IS available, then exit
291                // non-zero. Routing through `render_full_help` instead of
292                // clap's default keeps plugin commands in the listing.
293                ErrorKind::InvalidSubcommand
294                | ErrorKind::UnknownArgument
295                | ErrorKind::InvalidValue => {
296                    let bad = unknown_token(&argv);
297                    eprint!("{}", render_unknown(&app, bad.as_deref()));
298                    std::process::exit(2);
299                }
300                _ => {
301                    // Genuine clap output (a subcommand's own --help, a
302                    // missing-required-arg usage error, --version, …).
303                    // Let clap render it as before.
304                    e.print()?;
305                    std::process::exit(if e.use_stderr() { 2 } else { 0 });
306                }
307            }
308        }
309    };
310    match cli.command.unwrap_or(Command::Serve { addr: None }) {
311        Command::Serve { addr } => serve(app, addr).await,
312        Command::Makemigrations { empty } => makemigrations(empty).await,
313        Command::Migrate {
314            fake,
315            fake_initial,
316            allow_drift,
317            allow_destructive,
318        } => migrate(fake, fake_initial, allow_drift, allow_destructive).await,
319        Command::Showmigrations => showmigrations().await,
320        Command::Checkmigrations { strict } => checkmigrations(strict).await,
321        Command::Inspectdb {
322            output,
323            mark_applied,
324        } => inspectdb(output, mark_applied).await,
325        Command::Dumpdata { output } => dumpdata(output).await,
326        Command::Loaddata { input } => loaddata(input).await,
327        Command::Importcsv { table, input } => importcsv(table, input).await,
328        Command::Dev { watch, run_args } => dev(watch, run_args).await,
329        Command::Maskkeygen => maskkeygen(),
330    }
331}
332
333/// Generate a fresh `Masked<T>` field-encryption keypair and print the
334/// two env-var lines. The public key encrypts (every tier that writes
335/// masked data needs it); the private key decrypts (`reveal()`) and
336/// crypto-shreds on deletion.
337fn maskkeygen() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
338    let (public, secret) = umbral_core::orm::MaskKeyring::generate();
339    println!("# Masked<T> field-encryption keypair — add to your environment / .env:");
340    println!("#   UMBRAL_MASK_PUBLIC_KEY encrypts; UMBRAL_MASK_PRIVATE_KEY decrypts (reveal()).");
341    println!(
342        "#   Keep the PRIVATE key secret. Destroying it crypto-shreds every masked column\n\
343         #   (a fast bulk \"right to be forgotten\")."
344    );
345    println!(
346        "#   WARNING: the private key is printed below to STDOUT. Capture it straight into a\n\
347         #   secret store (Vault, cloud secret manager, a sealed CI variable) and keep it out\n\
348         #   of shell history, terminal scrollback, CI job logs, and any committed .env."
349    );
350    println!("UMBRAL_MASK_PUBLIC_KEY={public}");
351    println!("UMBRAL_MASK_PRIVATE_KEY={secret}");
352    Ok(())
353}
354
355/// True when argv is asking for the top-level command catalog: the
356/// `help` pseudo-subcommand, or a top-level `--help` / `-h`. A `--help`
357/// that follows a subcommand (e.g. `migrate --help`) is NOT top-level —
358/// that's command-specific help and is left to clap, so we only treat
359/// the FIRST post-argv0 token.
360///
361/// A bare `umbral` (no subcommand) is deliberately NOT intercepted: it
362/// keeps its documented default of booting the server (`Serve`), which
363/// the example apps rely on via a plain `cargo run`.
364fn wants_top_level_help(argv: &[std::ffi::OsString]) -> bool {
365    match argv.get(1) {
366        None => false,
367        Some(first) => first == "help" || first == "--help" || first == "-h",
368    }
369}
370
371/// The first non-flag token after argv0 — the subcommand the user
372/// tried to run. Used to name the offending command in the
373/// `error: unknown command \`<x>\`` line.
374fn unknown_token(argv: &[std::ffi::OsString]) -> Option<String> {
375    argv.iter()
376        .skip(1)
377        .find(|a| !a.to_string_lossy().starts_with('-'))
378        .map(|a| a.to_string_lossy().into_owned())
379}
380
381/// Build the merged `(name, about)` catalog: every built-in subcommand
382/// (read off the derived clap `Command` via `CommandFactory`) followed
383/// by every plugin-contributed command. Built-ins are placed first so
384/// they win a name clash in [`umbral_core::cli::render_help`]'s dedup.
385fn full_catalog(app: &App) -> Vec<(String, Option<String>)> {
386    let mut catalog: Vec<(String, Option<String>)> = Vec::new();
387    let root = <Cli as CommandFactory>::command();
388    for sub in root.get_subcommands() {
389        catalog.push((
390            sub.get_name().to_string(),
391            sub.get_about().map(|s| s.to_string()),
392        ));
393    }
394    catalog.extend(umbral_core::cli::command_catalog(app.plugins()));
395    catalog
396}
397
398/// Render the full help screen (built-ins + plugin commands), for
399/// `umbral help` / `umbral --help` / bare `umbral`. Prints to stdout.
400fn render_full_help(app: &App) -> String {
401    umbral_core::cli::render_help(&full_catalog(app))
402}
403
404/// Render the unknown-command screen: an `error: unknown command` line
405/// (naming the bad token if known) followed by the full catalog so the
406/// user sees what they CAN run. Printed to stderr; the caller exits
407/// non-zero.
408fn render_unknown(app: &App, bad: Option<&str>) -> String {
409    let mut s = String::new();
410    match bad {
411        Some(b) => s.push_str(&format!("error: unknown command `{b}`\n\n")),
412        None => s.push_str("error: unknown command\n\n"),
413    }
414    s.push_str(&render_full_help(app));
415    s
416}
417
418/// `umbral dev` — wraps `cargo-watch` to re-run `cargo run` on source
419/// changes. If `cargo-watch` isn't installed, prints the install hint
420/// and exits non-zero so the user notices.
421///
422/// Template edits don't need this command — they hot-reload in-process
423/// when `settings.environment == Dev` (see `umbral-core/src/templates.rs`).
424/// `dev` exists for the Rust-source case where the binary needs a
425/// rebuild + restart.
426async fn dev(
427    extra_watches: Vec<String>,
428    run_args: Vec<String>,
429) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
430    // Probe for cargo-watch up front so the failure message is clear.
431    let probe = std::process::Command::new("cargo")
432        .args(["watch", "--version"])
433        .stdout(std::process::Stdio::null())
434        .stderr(std::process::Stdio::null())
435        .status();
436    if probe.is_err() || probe.as_ref().map(|s| !s.success()).unwrap_or(true) {
437        eprintln!(
438            "umbral dev: `cargo-watch` is not installed.\n\n\
439             Install with:\n\n\
440             \x20\x20\x20\x20cargo install cargo-watch\n\n\
441             Then re-run `cargo run -- dev`.\n\n\
442             Workaround without cargo-watch: leave one terminal running\n\
443             `cargo run` and Ctrl-C + re-run after each edit. Templates\n\
444             still hot-reload in dev mode without any restart.",
445        );
446        std::process::exit(1);
447    }
448
449    // Build the cargo-watch invocation. -x runs the given cargo command;
450    // -w adds extra watch paths. Default watches are cargo-watch's own
451    // (Cargo.toml + src/) so we don't pile -w on every invocation.
452    let mut cmd = std::process::Command::new("cargo");
453    cmd.arg("watch");
454    for path in &extra_watches {
455        cmd.arg("-w").arg(path);
456    }
457    let cargo_cmd = if run_args.is_empty() {
458        "run".to_string()
459    } else {
460        format!("run -- {}", run_args.join(" "))
461    };
462    cmd.arg("-x").arg(&cargo_cmd);
463
464    eprintln!("umbral dev: watching for changes, running `cargo {cargo_cmd}` on each save");
465    eprintln!(
466        "umbral dev: templates also hot-reload in-process; no restart needed for .html edits"
467    );
468    eprintln!("umbral dev: Ctrl-C to stop");
469    eprintln!();
470
471    let status = cmd.status()?;
472    if !status.success() {
473        return Err(format!(
474            "cargo-watch exited with status {}",
475            status
476                .code()
477                .map(|c| c.to_string())
478                .unwrap_or_else(|| "<signal>".to_string())
479        )
480        .into());
481    }
482    Ok(())
483}
484
485async fn serve(
486    app: App,
487    addr_override: Option<String>,
488) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
489    let addr_str = match addr_override {
490        Some(s) => s,
491        None => umbral_core::settings::get().bind_addr.clone(),
492    };
493    let addr: SocketAddr = addr_str
494        .parse()
495        .map_err(|e| format!("umbral: invalid bind_addr `{addr_str}`: {e}"))?;
496    app.serve(addr).await?;
497    Ok(())
498}
499
500async fn makemigrations(
501    empty: Option<String>,
502) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
503    // --empty <plugin>: write a no-op migration (current snapshot, empty
504    // ops) the developer edits to add a `RunSql` data migration.
505    if let Some(plugin) = empty {
506        let path = umbral::migrate::make_empty(&plugin).await?;
507        println!("Wrote {} (empty)", path.display());
508        println!(
509            "  Edit it to add a data migration, e.g.:\n  \
510             {{ \"kind\": \"RunSql\", \"sql\": \"UPDATE ... SET ...\", \
511             \"reverse_sql\": null }}"
512        );
513        return Ok(());
514    }
515
516    match umbral::migrate::make().await {
517        Ok(paths) => {
518            for path in paths {
519                println!("Wrote {}", path.display());
520            }
521            Ok(())
522        }
523        Err(MigrateError::NoChanges) => {
524            println!("no changes detected");
525            Ok(())
526        }
527        Err(err) => Err(Box::new(err)),
528    }
529}
530
531async fn migrate(
532    fake: Option<String>,
533    fake_initial: bool,
534    allow_drift: bool,
535    allow_destructive: bool,
536) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
537    // --fake <plugin/name>: mark one migration applied without running SQL.
538    if let Some(ref spec) = fake {
539        let (plugin, name) = parse_migration_spec(spec)?;
540        umbral::migrate::fake_apply(plugin, name).await?;
541        println!("Marked {spec} as applied (no SQL executed)");
542        return Ok(());
543    }
544
545    // audit_2 core-migrate #6: refuse to APPLY a migration that drops a table /
546    // column (destroys rows) unless the operator explicitly opts in with
547    // `--allow-destructive`. A single missing `.model::<T>()` registration
548    // auto-generates a DropTable, and the plain `makemigrations && migrate` loop
549    // would otherwise drop a production table with no confirmation. This gates
550    // the APPLY (checkmigrations is only advisory / CI-side).
551    if !allow_destructive {
552        let unsafe_ops: Vec<_> = umbral::migrate::check_pending_safety()
553            .await?
554            .into_iter()
555            .filter(|c| c.safety.is_unsafe())
556            .collect();
557        if !unsafe_ops.is_empty() {
558            eprintln!(
559                "error: umbral migrate: {} pending destructive operation(s) would DESTROY DATA:",
560                unsafe_ops.len()
561            );
562            for c in &unsafe_ops {
563                eprintln!(
564                    "    [UNSAFE] {}/{}: {}",
565                    c.plugin,
566                    c.migration,
567                    c.safety.reason()
568                );
569            }
570            eprintln!();
571            eprintln!(
572                "  These usually come from an unregistered model/plugin (a removed \
573                 `.model::<T>()`, a dropped plugin, or a feature flag off).\n  \
574                 If the drop is intended, re-run: `umbral migrate --allow-destructive`.\n  \
575                 If NOT, restore the model registration and re-run `makemigrations`."
576            );
577            return Err(format!(
578                "refusing to apply {} destructive migration operation(s) without --allow-destructive",
579                unsafe_ops.len()
580            )
581            .into());
582        }
583    }
584
585    // --fake-initial: for every plugin, if the 0001 tables exist, fake-apply.
586    if fake_initial {
587        let n = umbral::migrate::fake_initial().await?;
588        if n == 0 {
589            println!("No plugins needed fake-initial (either already applied or tables absent)");
590        } else {
591            println!("Fake-applied initial migration for {n} plugin(s)");
592        }
593        return Ok(());
594    }
595
596    // Normal migrate with optional --allow-drift.
597    match umbral::migrate::run_checked(allow_drift).await {
598        Ok(n) => {
599            if n == 0 {
600                println!("No pending migrations");
601            } else {
602                println!("Applied {n} migration(s)");
603            }
604            Ok(())
605        }
606        Err(MigrateError::DriftDetected { ref missing }) => {
607            let names: Vec<String> = missing.iter().map(|(p, n)| format!("{p}/{n}")).collect();
608            eprintln!("error: umbral migrate: drift detected");
609            eprintln!("  The following migrations are in the tracking table but missing on disk:");
610            for name in &names {
611                eprintln!("    [!] {name}");
612            }
613            eprintln!();
614            eprintln!(
615                "  Options:\n  \
616                 1. Restore the file(s) from VCS.\n  \
617                 2. Run `umbral migrate --allow-drift` to proceed and apply pending migrations.\n  \
618                 3. Run `umbral migrate --fake <plugin/name>` to mark an individual migration \
619                 as applied without running SQL."
620            );
621            Err(Box::new(MigrateError::DriftDetected {
622                missing: missing.clone(),
623            }))
624        }
625        Err(err) => Err(Box::new(err)),
626    }
627}
628
629/// Parse `"plugin/name"` into `(&str, &str)`. Returns an error if the
630/// format is wrong.
631fn parse_migration_spec(
632    spec: &str,
633) -> Result<(&str, &str), Box<dyn std::error::Error + Send + Sync>> {
634    let mut parts = spec.splitn(2, '/');
635    let plugin = parts.next().ok_or("migration spec must be `plugin/name`")?;
636    let name = parts
637        .next()
638        .ok_or("migration spec must be `plugin/name`; missing name after `/`")?;
639    Ok((plugin, name))
640}
641
642async fn showmigrations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
643    let pending = umbral::migrate::show().await?;
644    if pending > 0 {
645        println!("\n{pending} migration(s) not yet applied.");
646    }
647    Ok(())
648}
649
650/// `umbral checkmigrations` — classify every pending operation for
651/// zero-downtime safety (feature #65). Prints the UNSAFE ops first, then
652/// WARNING, then a SAFE count, and exits non-zero when any UNSAFE op is
653/// present (or any WARNING under `--strict`). Applies nothing.
654async fn checkmigrations(strict: bool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
655    let ops = umbral::migrate::check_pending_safety().await?;
656    if ops.is_empty() {
657        println!("No pending migrations — nothing to check.");
658        return Ok(());
659    }
660
661    let unsafe_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_unsafe()).collect();
662    let warn_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_warning()).collect();
663    let safe_count = ops.len() - unsafe_ops.len() - warn_ops.len();
664
665    let migrations: std::collections::BTreeSet<_> =
666        ops.iter().map(|c| (&c.plugin, &c.migration)).collect();
667    println!(
668        "Checking {} operation(s) across {} pending migration(s)...\n",
669        ops.len(),
670        migrations.len()
671    );
672
673    if !unsafe_ops.is_empty() {
674        println!("UNSAFE ({}):", unsafe_ops.len());
675        for c in &unsafe_ops {
676            println!(
677                "  [{}] {}/{} — {}",
678                op_kind(&c.op),
679                c.plugin,
680                c.migration,
681                c.safety.reason()
682            );
683        }
684        println!();
685    }
686
687    if !warn_ops.is_empty() {
688        println!("WARNING ({}):", warn_ops.len());
689        for c in &warn_ops {
690            println!(
691                "  [{}] {}/{} — {}",
692                op_kind(&c.op),
693                c.plugin,
694                c.migration,
695                c.safety.reason()
696            );
697        }
698        println!();
699    }
700
701    println!(
702        "Summary: {} safe, {} warning, {} unsafe.",
703        safe_count,
704        warn_ops.len(),
705        unsafe_ops.len()
706    );
707
708    // Gate: UNSAFE always fails; WARNING fails only under --strict.
709    let blocked = !unsafe_ops.is_empty() || (strict && !warn_ops.is_empty());
710    if blocked {
711        let why = if !unsafe_ops.is_empty() {
712            format!("{} unsafe operation(s) found", unsafe_ops.len())
713        } else {
714            format!("{} warning(s) found (--strict)", warn_ops.len())
715        };
716        return Err(format!(
717            "checkmigrations: {why}. Review the expand-contract notes above before deploying."
718        )
719        .into());
720    }
721
722    println!("\nAll pending operations are safe for a rolling deploy.");
723    Ok(())
724}
725
726/// Short uppercase tag for an operation, used in the `checkmigrations`
727/// report (e.g. `DROP TABLE`, `RENAME COL`, `ADD COL`).
728fn op_kind(op: &umbral::migrate::Operation) -> &'static str {
729    use umbral::migrate::Operation;
730    match op {
731        Operation::CreateTable { .. } => "CREATE TABLE",
732        Operation::DropTable { .. } => "DROP TABLE",
733        Operation::AddColumn { .. } => "ADD COL",
734        Operation::DropColumn { .. } => "DROP COL",
735        Operation::AlterColumn { .. } => "ALTER COL",
736        Operation::RenameTable { .. } => "RENAME TABLE",
737        Operation::RenameColumn { .. } => "RENAME COL",
738        Operation::CreateM2MTable { .. } => "CREATE M2M",
739        Operation::DropM2MTable { .. } => "DROP M2M",
740        Operation::RunSql { .. } => "RUN SQL",
741        Operation::AddIndex { unique: true, .. } => "ADD UNIQUE",
742        Operation::AddIndex { unique: false, .. } => "ADD INDEX",
743        Operation::DropIndex { .. } => "DROP INDEX",
744    }
745}
746
747async fn inspectdb(
748    output: PathBuf,
749    mark_applied: bool,
750) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
751    let opts = InspectOptions {
752        output,
753        mark_applied,
754    };
755    match umbral::inspect::inspectdb(opts).await {
756        Ok(report) => {
757            println!(
758                "Inspected {} table(s), {} column(s)",
759                report.tables, report.columns,
760            );
761            println!("Wrote {}", report.models_path.display());
762            println!("Wrote {}", report.migration_path.display());
763            Ok(())
764        }
765        Err(InspectError::NoTables) => {
766            println!("no tables found in the database");
767            Ok(())
768        }
769        Err(err) => Err(Box::new(err)),
770    }
771}
772
773async fn dumpdata(output: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
774    umbral::backup::dump_to_path(&output).await?;
775    println!("Wrote {}", output.display());
776    Ok(())
777}
778
779async fn loaddata(input: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
780    let report = umbral::backup::load_from_path(&input).await?;
781    println!(
782        "Loaded {} row(s) into {} table(s)",
783        report.rows_loaded,
784        report.tables_loaded.len()
785    );
786    for skipped in &report.skipped_tables {
787        eprintln!("warning: skipped table `{skipped}` (not in current schema)");
788    }
789    Ok(())
790}
791
792/// `umbral importcsv <table> <file.csv>` — parse the CSV (the `csv` crate
793/// handles quoting/escaping) and hand the header + string rows to
794/// `import_table_rows`, which coerces each cell to its column type and
795/// inserts through the validated dynamic write path.
796async fn importcsv(
797    table: String,
798    input: PathBuf,
799) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
800    // Resolve the table against the registered models so a typo fails
801    // loudly (with the list of valid tables) before we read the file.
802    let models = umbral::migrate::registered_models();
803    let Some(meta) = models.into_iter().find(|m| m.table == table) else {
804        let mut known: Vec<String> = umbral::migrate::registered_models()
805            .iter()
806            .map(|m| m.table.clone())
807            .collect();
808        known.sort();
809        return Err(format!(
810            "importcsv: unknown table `{table}`. Registered tables: {}",
811            known.join(", ")
812        )
813        .into());
814    };
815
816    let mut reader = csv::ReaderBuilder::new()
817        .has_headers(true)
818        .flexible(true)
819        .from_path(&input)?;
820    let headers: Vec<String> = reader.headers()?.iter().map(|s| s.to_string()).collect();
821    if headers.is_empty() {
822        return Err("importcsv: the CSV has no header row".into());
823    }
824    let mut rows: Vec<Vec<String>> = Vec::new();
825    for record in reader.records() {
826        let record = record?;
827        rows.push(record.iter().map(|s| s.to_string()).collect());
828    }
829
830    let report = umbral::orm::import_table_rows(&meta, &headers, &rows).await;
831    println!(
832        "Imported {} row(s) into `{}` ({} failed)",
833        report.inserted,
834        table,
835        report.errors.len()
836    );
837    for (line, message) in &report.errors {
838        eprintln!("  line {line}: {message}");
839    }
840    // Non-zero exit when any row failed, so a CI/script catches a partial
841    // import without parsing stdout.
842    if report.errors.is_empty() {
843        Ok(())
844    } else {
845        Err(format!("importcsv: {} row(s) failed", report.errors.len()).into())
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use async_trait::async_trait;
853    use clap::ArgMatches;
854    use umbral::Settings;
855    use umbral_core::cli::{CliError, PluginCommand};
856    use umbral_core::plugin::Plugin;
857
858    #[test]
859    fn forward_args_prefix_cargo_run_dashdash() {
860        // `umbral dev` → `cargo run -- dev`
861        assert_eq!(
862            cargo_run_forward_args(&["dev".to_string()]),
863            vec!["run", "--", "dev"]
864        );
865        // Flags and extra args ride along verbatim.
866        assert_eq!(
867            cargo_run_forward_args(&[
868                "migrate".to_string(),
869                "--fake".to_string(),
870                "accounts/0001_auto".to_string(),
871            ]),
872            vec!["run", "--", "migrate", "--fake", "accounts/0001_auto"]
873        );
874    }
875
876    #[test]
877    fn in_cargo_project_detects_manifest_upward() {
878        let tmp = tempfile::tempdir().expect("tempdir");
879        let root = tmp.path();
880        // No Cargo.toml anywhere yet.
881        assert!(!in_cargo_project(root));
882        // A manifest at the root is found from a nested subdir (like cargo).
883        std::fs::write(root.join("Cargo.toml"), b"[package]\nname='x'\n").unwrap();
884        let nested = root.join("src").join("widgets");
885        std::fs::create_dir_all(&nested).unwrap();
886        assert!(in_cargo_project(&nested), "walks up to find the manifest");
887        assert!(in_cargo_project(root));
888    }
889
890    struct WorkerCmd;
891
892    #[async_trait]
893    impl PluginCommand for WorkerCmd {
894        fn command(&self) -> clap::Command {
895            clap::Command::new("tasks-worker").about("Run the task worker")
896        }
897        async fn run(&self, _m: &ArgMatches) -> Result<(), CliError> {
898            Ok(())
899        }
900    }
901
902    struct WorkerPlugin;
903
904    impl Plugin for WorkerPlugin {
905        fn name(&self) -> &'static str {
906            "tasks"
907        }
908        fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
909            vec![Box::new(WorkerCmd)]
910        }
911    }
912
913    async fn app_with_worker() -> App {
914        let settings = Settings::from_env().expect("figment defaults load");
915        let pool = umbral::db::connect_sqlite("sqlite::memory:")
916            .await
917            .expect("in-memory sqlite connects");
918        App::builder()
919            .settings(settings)
920            .database("default", pool)
921            .plugin(WorkerPlugin)
922            .build()
923            .expect("App builds")
924    }
925
926    #[test]
927    fn wants_top_level_help_recognizes_help_forms() {
928        let os = |s: &str| std::ffi::OsString::from(s);
929        assert!(wants_top_level_help(&[os("umbral"), os("help")]));
930        assert!(wants_top_level_help(&[os("umbral"), os("--help")]));
931        assert!(wants_top_level_help(&[os("umbral"), os("-h")]));
932        // Bare invocation keeps the serve default — NOT intercepted.
933        assert!(!wants_top_level_help(&[os("umbral")]));
934        // `migrate --help` is command-specific, left to clap.
935        assert!(!wants_top_level_help(&[
936            os("umbral"),
937            os("migrate"),
938            os("--help")
939        ]));
940        // A real subcommand is not help.
941        assert!(!wants_top_level_help(&[os("umbral"), os("migrate")]));
942    }
943
944    #[test]
945    fn unknown_token_picks_first_non_flag() {
946        let os = |s: &str| std::ffi::OsString::from(s);
947        assert_eq!(
948            unknown_token(&[os("umbral"), os("--verbose"), os("frobnicate")]).as_deref(),
949            Some("frobnicate")
950        );
951        assert_eq!(unknown_token(&[os("umbral")]), None);
952    }
953
954    // NOTE: both the help and unknown-command paths are asserted in ONE
955    // test because `App::build` calls the global `settings::init` (a
956    // `OnceLock`) which panics if called twice in the same process.
957    // Building one App and exercising both render paths against it sidesteps
958    // that, and is also a faithful "one process, one App" shape.
959    #[tokio::test]
960    async fn help_and_unknown_list_builtins_and_plugin_commands() {
961        let app = app_with_worker().await;
962
963        // --- full help (umbral help / --help) ---
964        let out = render_full_help(&app);
965        // A built-in subcommand with its real `about`.
966        assert!(
967            out.contains("migrate"),
968            "built-in `migrate` missing:\n{out}"
969        );
970        assert!(
971            out.contains("Apply every pending migration"),
972            "built-in `migrate` about missing:\n{out}"
973        );
974        // The plugin-contributed command with its about.
975        assert!(
976            out.contains("tasks-worker") && out.contains("Run the task worker"),
977            "plugin command missing:\n{out}"
978        );
979        // Column alignment: built-in and plugin descriptions start at the
980        // same offset on their respective lines.
981        let mig_line = out
982            .lines()
983            .find(|l| l.trim_start().starts_with("migrate"))
984            .unwrap();
985        let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
986        let mig_col = mig_line.find("Apply every pending migration").unwrap();
987        let worker_col = worker_line.find("Run the task worker").unwrap();
988        assert_eq!(mig_col, worker_col, "descriptions not aligned:\n{out}");
989
990        // --- unknown command (umbral frobnicate) ---
991        let out = render_unknown(&app, Some("frobnicate"));
992        assert!(
993            out.contains("unknown command") && out.contains("frobnicate"),
994            "missing unknown-command error:\n{out}"
995        );
996        // Still shows what IS available — both a built-in and the plugin cmd.
997        assert!(out.contains("migrate"), "listing missing built-in:\n{out}");
998        assert!(
999            out.contains("tasks-worker"),
1000            "listing missing plugin cmd:\n{out}"
1001        );
1002    }
1003}