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