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