1use 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 Serve {
75 #[arg(long)]
78 addr: Option<String>,
79 },
80 Makemigrations {
83 #[arg(long, value_name = "PLUGIN")]
89 empty: Option<String>,
90 },
91 Migrate {
93 #[arg(long, value_name = "PLUGIN/NAME")]
98 fake: Option<String>,
99 #[arg(long, default_value_t = false)]
103 fake_initial: bool,
104 #[arg(long, default_value_t = false)]
109 allow_drift: bool,
110 },
111 Showmigrations,
116 Checkmigrations {
124 #[arg(long, default_value_t = false)]
127 strict: bool,
128 },
129 Inspectdb {
132 #[arg(long)]
134 output: PathBuf,
135 #[arg(long, default_value_t = false)]
139 mark_applied: bool,
140 },
141 Dumpdata {
144 #[arg(long)]
146 output: PathBuf,
147 },
148 Loaddata {
151 input: PathBuf,
153 },
154 Importcsv {
161 table: String,
163 input: PathBuf,
165 },
166 Dev {
172 #[arg(long, short = 'w')]
175 watch: Vec<String>,
176 #[arg(last = true)]
180 run_args: Vec<String>,
181 },
182 Maskkeygen,
186}
187
188pub 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
203pub async fn dispatch_with_argv(
212 app: App,
213 argv: Vec<std::ffi::OsString>,
214) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
215 if wants_top_level_help(&argv) {
222 print!("{}", render_full_help(&app));
223 return Ok(());
224 }
225
226 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 print!("{msg}");
241 return Ok(());
242 }
243 Ok(umbral_core::cli::DispatchOutcome::Unmatched) => {
244 }
246 Err(e) => return Err(e),
247 }
248 }
249
250 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 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 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
303fn 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
320fn 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
336fn 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
346fn 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
363fn render_full_help(app: &App) -> String {
366 umbral_core::cli::render_help(&full_catalog(app))
367}
368
369fn 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
383async fn dev(
392 extra_watches: Vec<String>,
393 run_args: Vec<String>,
394) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
395 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 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 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 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 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 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
551fn 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
572async 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 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
648fn 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
711async fn importcsv(
716 table: String,
717 input: PathBuf,
718) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
719 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 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 assert!(!wants_top_level_help(&[os("umbral")]));
821 assert!(!wants_top_level_help(&[
823 os("umbral"),
824 os("migrate"),
825 os("--help")
826 ]));
827 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 #[tokio::test]
847 async fn help_and_unknown_list_builtins_and_plugin_commands() {
848 let app = app_with_worker().await;
849
850 let out = render_full_help(&app);
852 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 assert!(
863 out.contains("tasks-worker") && out.contains("Run the task worker"),
864 "plugin command missing:\n{out}"
865 );
866 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 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 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}