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
58pub fn cargo_run_forward_args(forwarded: &[String]) -> Vec<String> {
65 let mut argv = vec!["run".to_string(), "--".to_string()];
66 argv.extend(forwarded.iter().cloned());
67 argv
68}
69
70pub fn in_cargo_project(start: &std::path::Path) -> bool {
75 start
76 .ancestors()
77 .any(|dir| dir.join("Cargo.toml").is_file())
78}
79
80#[derive(Debug, Parser)]
81#[command(
82 name = "umbral",
83 about = "umbral management commands. Run from your project's binary.",
84 disable_help_subcommand = true
85)]
86struct Cli {
87 #[command(subcommand)]
88 command: Option<Command>,
89}
90
91#[derive(Debug, Subcommand)]
92enum Command {
93 Serve {
97 #[arg(long)]
100 addr: Option<String>,
101 },
102 Makemigrations {
105 #[arg(long, value_name = "PLUGIN")]
111 empty: Option<String>,
112 },
113 Migrate {
115 #[arg(long, value_name = "PLUGIN/NAME")]
120 fake: Option<String>,
121 #[arg(long, default_value_t = false)]
125 fake_initial: bool,
126 #[arg(long, default_value_t = false)]
131 allow_drift: bool,
132 #[arg(long, default_value_t = false)]
138 allow_destructive: bool,
139 },
140 Showmigrations,
145 Checkmigrations {
153 #[arg(long, default_value_t = false)]
156 strict: bool,
157 },
158 Inspectdb {
161 #[arg(long)]
163 output: PathBuf,
164 #[arg(long, default_value_t = false)]
168 mark_applied: bool,
169 },
170 Dumpdata {
173 #[arg(long)]
175 output: PathBuf,
176 },
177 Loaddata {
180 input: PathBuf,
182 },
183 Importcsv {
190 table: String,
192 input: PathBuf,
194 },
195 Dev {
201 #[arg(long, short = 'w')]
204 watch: Vec<String>,
205 #[arg(last = true)]
209 run_args: Vec<String>,
210 },
211 Maskkeygen,
215}
216
217pub async fn dispatch(app: App) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
228 let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
229 dispatch_with_argv(app, argv).await
230}
231
232pub async fn dispatch_with_argv(
241 app: App,
242 argv: Vec<std::ffi::OsString>,
243) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
244 if wants_top_level_help(&argv) {
251 print!("{}", render_full_help(&app));
252 return Ok(());
253 }
254
255 if !app.plugins().is_empty() {
262 match umbral_core::cli::dispatch(app.plugins(), argv.clone()).await {
263 Ok(umbral_core::cli::DispatchOutcome::Matched(_)) => return Ok(()),
264 Ok(umbral_core::cli::DispatchOutcome::Help(msg)) => {
265 print!("{msg}");
270 return Ok(());
271 }
272 Ok(umbral_core::cli::DispatchOutcome::Unmatched) => {
273 }
275 Err(e) => return Err(e),
276 }
277 }
278
279 let cli = match Cli::try_parse_from(&argv) {
283 Ok(c) => c,
284 Err(e) => {
285 use clap::error::ErrorKind;
286 match e.kind() {
287 ErrorKind::InvalidSubcommand
294 | ErrorKind::UnknownArgument
295 | ErrorKind::InvalidValue => {
296 let bad = unknown_token(&argv);
297 eprint!("{}", render_unknown(&app, bad.as_deref()));
298 std::process::exit(2);
299 }
300 _ => {
301 e.print()?;
305 std::process::exit(if e.use_stderr() { 2 } else { 0 });
306 }
307 }
308 }
309 };
310 match cli.command.unwrap_or(Command::Serve { addr: None }) {
311 Command::Serve { addr } => serve(app, addr).await,
312 Command::Makemigrations { empty } => makemigrations(empty).await,
313 Command::Migrate {
314 fake,
315 fake_initial,
316 allow_drift,
317 allow_destructive,
318 } => migrate(fake, fake_initial, allow_drift, allow_destructive).await,
319 Command::Showmigrations => showmigrations().await,
320 Command::Checkmigrations { strict } => checkmigrations(strict).await,
321 Command::Inspectdb {
322 output,
323 mark_applied,
324 } => inspectdb(output, mark_applied).await,
325 Command::Dumpdata { output } => dumpdata(output).await,
326 Command::Loaddata { input } => loaddata(input).await,
327 Command::Importcsv { table, input } => importcsv(table, input).await,
328 Command::Dev { watch, run_args } => dev(watch, run_args).await,
329 Command::Maskkeygen => maskkeygen(),
330 }
331}
332
333fn maskkeygen() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
338 let (public, secret) = umbral_core::orm::MaskKeyring::generate();
339 println!("# Masked<T> field-encryption keypair — add to your environment / .env:");
340 println!("# UMBRAL_MASK_PUBLIC_KEY encrypts; UMBRAL_MASK_PRIVATE_KEY decrypts (reveal()).");
341 println!(
342 "# Keep the PRIVATE key secret. Destroying it crypto-shreds every masked column\n\
343 # (a fast bulk \"right to be forgotten\")."
344 );
345 println!(
346 "# WARNING: the private key is printed below to STDOUT. Capture it straight into a\n\
347 # secret store (Vault, cloud secret manager, a sealed CI variable) and keep it out\n\
348 # of shell history, terminal scrollback, CI job logs, and any committed .env."
349 );
350 println!("UMBRAL_MASK_PUBLIC_KEY={public}");
351 println!("UMBRAL_MASK_PRIVATE_KEY={secret}");
352 Ok(())
353}
354
355fn wants_top_level_help(argv: &[std::ffi::OsString]) -> bool {
365 match argv.get(1) {
366 None => false,
367 Some(first) => first == "help" || first == "--help" || first == "-h",
368 }
369}
370
371fn unknown_token(argv: &[std::ffi::OsString]) -> Option<String> {
375 argv.iter()
376 .skip(1)
377 .find(|a| !a.to_string_lossy().starts_with('-'))
378 .map(|a| a.to_string_lossy().into_owned())
379}
380
381fn full_catalog(app: &App) -> Vec<(String, Option<String>)> {
386 let mut catalog: Vec<(String, Option<String>)> = Vec::new();
387 let root = <Cli as CommandFactory>::command();
388 for sub in root.get_subcommands() {
389 catalog.push((
390 sub.get_name().to_string(),
391 sub.get_about().map(|s| s.to_string()),
392 ));
393 }
394 catalog.extend(umbral_core::cli::command_catalog(app.plugins()));
395 catalog
396}
397
398fn render_full_help(app: &App) -> String {
401 umbral_core::cli::render_help(&full_catalog(app))
402}
403
404fn render_unknown(app: &App, bad: Option<&str>) -> String {
409 let mut s = String::new();
410 match bad {
411 Some(b) => s.push_str(&format!("error: unknown command `{b}`\n\n")),
412 None => s.push_str("error: unknown command\n\n"),
413 }
414 s.push_str(&render_full_help(app));
415 s
416}
417
418async fn dev(
427 extra_watches: Vec<String>,
428 run_args: Vec<String>,
429) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
430 let probe = std::process::Command::new("cargo")
432 .args(["watch", "--version"])
433 .stdout(std::process::Stdio::null())
434 .stderr(std::process::Stdio::null())
435 .status();
436 if probe.is_err() || probe.as_ref().map(|s| !s.success()).unwrap_or(true) {
437 eprintln!(
438 "umbral dev: `cargo-watch` is not installed.\n\n\
439 Install with:\n\n\
440 \x20\x20\x20\x20cargo install cargo-watch\n\n\
441 Then re-run `cargo run -- dev`.\n\n\
442 Workaround without cargo-watch: leave one terminal running\n\
443 `cargo run` and Ctrl-C + re-run after each edit. Templates\n\
444 still hot-reload in dev mode without any restart.",
445 );
446 std::process::exit(1);
447 }
448
449 let mut cmd = std::process::Command::new("cargo");
453 cmd.arg("watch");
454 for path in &extra_watches {
455 cmd.arg("-w").arg(path);
456 }
457 let cargo_cmd = if run_args.is_empty() {
458 "run".to_string()
459 } else {
460 format!("run -- {}", run_args.join(" "))
461 };
462 cmd.arg("-x").arg(&cargo_cmd);
463
464 eprintln!("umbral dev: watching for changes, running `cargo {cargo_cmd}` on each save");
465 eprintln!(
466 "umbral dev: templates also hot-reload in-process; no restart needed for .html edits"
467 );
468 eprintln!("umbral dev: Ctrl-C to stop");
469 eprintln!();
470
471 let status = cmd.status()?;
472 if !status.success() {
473 return Err(format!(
474 "cargo-watch exited with status {}",
475 status
476 .code()
477 .map(|c| c.to_string())
478 .unwrap_or_else(|| "<signal>".to_string())
479 )
480 .into());
481 }
482 Ok(())
483}
484
485async fn serve(
486 app: App,
487 addr_override: Option<String>,
488) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
489 let addr_str = match addr_override {
490 Some(s) => s,
491 None => umbral_core::settings::get().bind_addr.clone(),
492 };
493 let addr: SocketAddr = addr_str
494 .parse()
495 .map_err(|e| format!("umbral: invalid bind_addr `{addr_str}`: {e}"))?;
496 app.serve(addr).await?;
497 Ok(())
498}
499
500async fn makemigrations(
501 empty: Option<String>,
502) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
503 if let Some(plugin) = empty {
506 let path = umbral::migrate::make_empty(&plugin).await?;
507 println!("Wrote {} (empty)", path.display());
508 println!(
509 " Edit it to add a data migration, e.g.:\n \
510 {{ \"kind\": \"RunSql\", \"sql\": \"UPDATE ... SET ...\", \
511 \"reverse_sql\": null }}"
512 );
513 return Ok(());
514 }
515
516 match umbral::migrate::make().await {
517 Ok(paths) => {
518 for path in paths {
519 println!("Wrote {}", path.display());
520 }
521 Ok(())
522 }
523 Err(MigrateError::NoChanges) => {
524 println!("no changes detected");
525 Ok(())
526 }
527 Err(err) => Err(Box::new(err)),
528 }
529}
530
531async fn migrate(
532 fake: Option<String>,
533 fake_initial: bool,
534 allow_drift: bool,
535 allow_destructive: bool,
536) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
537 if let Some(ref spec) = fake {
539 let (plugin, name) = parse_migration_spec(spec)?;
540 umbral::migrate::fake_apply(plugin, name).await?;
541 println!("Marked {spec} as applied (no SQL executed)");
542 return Ok(());
543 }
544
545 if !allow_destructive {
552 let unsafe_ops: Vec<_> = umbral::migrate::check_pending_safety()
553 .await?
554 .into_iter()
555 .filter(|c| c.safety.is_unsafe())
556 .collect();
557 if !unsafe_ops.is_empty() {
558 eprintln!(
559 "error: umbral migrate: {} pending destructive operation(s) would DESTROY DATA:",
560 unsafe_ops.len()
561 );
562 for c in &unsafe_ops {
563 eprintln!(
564 " [UNSAFE] {}/{}: {}",
565 c.plugin,
566 c.migration,
567 c.safety.reason()
568 );
569 }
570 eprintln!();
571 eprintln!(
572 " These usually come from an unregistered model/plugin (a removed \
573 `.model::<T>()`, a dropped plugin, or a feature flag off).\n \
574 If the drop is intended, re-run: `umbral migrate --allow-destructive`.\n \
575 If NOT, restore the model registration and re-run `makemigrations`."
576 );
577 return Err(format!(
578 "refusing to apply {} destructive migration operation(s) without --allow-destructive",
579 unsafe_ops.len()
580 )
581 .into());
582 }
583 }
584
585 if fake_initial {
587 let n = umbral::migrate::fake_initial().await?;
588 if n == 0 {
589 println!("No plugins needed fake-initial (either already applied or tables absent)");
590 } else {
591 println!("Fake-applied initial migration for {n} plugin(s)");
592 }
593 return Ok(());
594 }
595
596 match umbral::migrate::run_checked(allow_drift).await {
598 Ok(n) => {
599 if n == 0 {
600 println!("No pending migrations");
601 } else {
602 println!("Applied {n} migration(s)");
603 }
604 Ok(())
605 }
606 Err(MigrateError::DriftDetected { ref missing }) => {
607 let names: Vec<String> = missing.iter().map(|(p, n)| format!("{p}/{n}")).collect();
608 eprintln!("error: umbral migrate: drift detected");
609 eprintln!(" The following migrations are in the tracking table but missing on disk:");
610 for name in &names {
611 eprintln!(" [!] {name}");
612 }
613 eprintln!();
614 eprintln!(
615 " Options:\n \
616 1. Restore the file(s) from VCS.\n \
617 2. Run `umbral migrate --allow-drift` to proceed and apply pending migrations.\n \
618 3. Run `umbral migrate --fake <plugin/name>` to mark an individual migration \
619 as applied without running SQL."
620 );
621 Err(Box::new(MigrateError::DriftDetected {
622 missing: missing.clone(),
623 }))
624 }
625 Err(err) => Err(Box::new(err)),
626 }
627}
628
629fn parse_migration_spec(
632 spec: &str,
633) -> Result<(&str, &str), Box<dyn std::error::Error + Send + Sync>> {
634 let mut parts = spec.splitn(2, '/');
635 let plugin = parts.next().ok_or("migration spec must be `plugin/name`")?;
636 let name = parts
637 .next()
638 .ok_or("migration spec must be `plugin/name`; missing name after `/`")?;
639 Ok((plugin, name))
640}
641
642async fn showmigrations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
643 let pending = umbral::migrate::show().await?;
644 if pending > 0 {
645 println!("\n{pending} migration(s) not yet applied.");
646 }
647 Ok(())
648}
649
650async fn checkmigrations(strict: bool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
655 let ops = umbral::migrate::check_pending_safety().await?;
656 if ops.is_empty() {
657 println!("No pending migrations — nothing to check.");
658 return Ok(());
659 }
660
661 let unsafe_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_unsafe()).collect();
662 let warn_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_warning()).collect();
663 let safe_count = ops.len() - unsafe_ops.len() - warn_ops.len();
664
665 let migrations: std::collections::BTreeSet<_> =
666 ops.iter().map(|c| (&c.plugin, &c.migration)).collect();
667 println!(
668 "Checking {} operation(s) across {} pending migration(s)...\n",
669 ops.len(),
670 migrations.len()
671 );
672
673 if !unsafe_ops.is_empty() {
674 println!("UNSAFE ({}):", unsafe_ops.len());
675 for c in &unsafe_ops {
676 println!(
677 " [{}] {}/{} — {}",
678 op_kind(&c.op),
679 c.plugin,
680 c.migration,
681 c.safety.reason()
682 );
683 }
684 println!();
685 }
686
687 if !warn_ops.is_empty() {
688 println!("WARNING ({}):", warn_ops.len());
689 for c in &warn_ops {
690 println!(
691 " [{}] {}/{} — {}",
692 op_kind(&c.op),
693 c.plugin,
694 c.migration,
695 c.safety.reason()
696 );
697 }
698 println!();
699 }
700
701 println!(
702 "Summary: {} safe, {} warning, {} unsafe.",
703 safe_count,
704 warn_ops.len(),
705 unsafe_ops.len()
706 );
707
708 let blocked = !unsafe_ops.is_empty() || (strict && !warn_ops.is_empty());
710 if blocked {
711 let why = if !unsafe_ops.is_empty() {
712 format!("{} unsafe operation(s) found", unsafe_ops.len())
713 } else {
714 format!("{} warning(s) found (--strict)", warn_ops.len())
715 };
716 return Err(format!(
717 "checkmigrations: {why}. Review the expand-contract notes above before deploying."
718 )
719 .into());
720 }
721
722 println!("\nAll pending operations are safe for a rolling deploy.");
723 Ok(())
724}
725
726fn op_kind(op: &umbral::migrate::Operation) -> &'static str {
729 use umbral::migrate::Operation;
730 match op {
731 Operation::CreateTable { .. } => "CREATE TABLE",
732 Operation::DropTable { .. } => "DROP TABLE",
733 Operation::AddColumn { .. } => "ADD COL",
734 Operation::DropColumn { .. } => "DROP COL",
735 Operation::AlterColumn { .. } => "ALTER COL",
736 Operation::RenameTable { .. } => "RENAME TABLE",
737 Operation::RenameColumn { .. } => "RENAME COL",
738 Operation::CreateM2MTable { .. } => "CREATE M2M",
739 Operation::DropM2MTable { .. } => "DROP M2M",
740 Operation::RunSql { .. } => "RUN SQL",
741 Operation::AddIndex { unique: true, .. } => "ADD UNIQUE",
742 Operation::AddIndex { unique: false, .. } => "ADD INDEX",
743 Operation::DropIndex { .. } => "DROP INDEX",
744 }
745}
746
747async fn inspectdb(
748 output: PathBuf,
749 mark_applied: bool,
750) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
751 let opts = InspectOptions {
752 output,
753 mark_applied,
754 };
755 match umbral::inspect::inspectdb(opts).await {
756 Ok(report) => {
757 println!(
758 "Inspected {} table(s), {} column(s)",
759 report.tables, report.columns,
760 );
761 println!("Wrote {}", report.models_path.display());
762 println!("Wrote {}", report.migration_path.display());
763 Ok(())
764 }
765 Err(InspectError::NoTables) => {
766 println!("no tables found in the database");
767 Ok(())
768 }
769 Err(err) => Err(Box::new(err)),
770 }
771}
772
773async fn dumpdata(output: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
774 umbral::backup::dump_to_path(&output).await?;
775 println!("Wrote {}", output.display());
776 Ok(())
777}
778
779async fn loaddata(input: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
780 let report = umbral::backup::load_from_path(&input).await?;
781 println!(
782 "Loaded {} row(s) into {} table(s)",
783 report.rows_loaded,
784 report.tables_loaded.len()
785 );
786 for skipped in &report.skipped_tables {
787 eprintln!("warning: skipped table `{skipped}` (not in current schema)");
788 }
789 Ok(())
790}
791
792async fn importcsv(
797 table: String,
798 input: PathBuf,
799) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
800 let models = umbral::migrate::registered_models();
803 let Some(meta) = models.into_iter().find(|m| m.table == table) else {
804 let mut known: Vec<String> = umbral::migrate::registered_models()
805 .iter()
806 .map(|m| m.table.clone())
807 .collect();
808 known.sort();
809 return Err(format!(
810 "importcsv: unknown table `{table}`. Registered tables: {}",
811 known.join(", ")
812 )
813 .into());
814 };
815
816 let mut reader = csv::ReaderBuilder::new()
817 .has_headers(true)
818 .flexible(true)
819 .from_path(&input)?;
820 let headers: Vec<String> = reader.headers()?.iter().map(|s| s.to_string()).collect();
821 if headers.is_empty() {
822 return Err("importcsv: the CSV has no header row".into());
823 }
824 let mut rows: Vec<Vec<String>> = Vec::new();
825 for record in reader.records() {
826 let record = record?;
827 rows.push(record.iter().map(|s| s.to_string()).collect());
828 }
829
830 let report = umbral::orm::import_table_rows(&meta, &headers, &rows).await;
831 println!(
832 "Imported {} row(s) into `{}` ({} failed)",
833 report.inserted,
834 table,
835 report.errors.len()
836 );
837 for (line, message) in &report.errors {
838 eprintln!(" line {line}: {message}");
839 }
840 if report.errors.is_empty() {
843 Ok(())
844 } else {
845 Err(format!("importcsv: {} row(s) failed", report.errors.len()).into())
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use async_trait::async_trait;
853 use clap::ArgMatches;
854 use umbral::Settings;
855 use umbral_core::cli::{CliError, PluginCommand};
856 use umbral_core::plugin::Plugin;
857
858 #[test]
859 fn forward_args_prefix_cargo_run_dashdash() {
860 assert_eq!(
862 cargo_run_forward_args(&["dev".to_string()]),
863 vec!["run", "--", "dev"]
864 );
865 assert_eq!(
867 cargo_run_forward_args(&[
868 "migrate".to_string(),
869 "--fake".to_string(),
870 "accounts/0001_auto".to_string(),
871 ]),
872 vec!["run", "--", "migrate", "--fake", "accounts/0001_auto"]
873 );
874 }
875
876 #[test]
877 fn in_cargo_project_detects_manifest_upward() {
878 let tmp = tempfile::tempdir().expect("tempdir");
879 let root = tmp.path();
880 assert!(!in_cargo_project(root));
882 std::fs::write(root.join("Cargo.toml"), b"[package]\nname='x'\n").unwrap();
884 let nested = root.join("src").join("widgets");
885 std::fs::create_dir_all(&nested).unwrap();
886 assert!(in_cargo_project(&nested), "walks up to find the manifest");
887 assert!(in_cargo_project(root));
888 }
889
890 struct WorkerCmd;
891
892 #[async_trait]
893 impl PluginCommand for WorkerCmd {
894 fn command(&self) -> clap::Command {
895 clap::Command::new("tasks-worker").about("Run the task worker")
896 }
897 async fn run(&self, _m: &ArgMatches) -> Result<(), CliError> {
898 Ok(())
899 }
900 }
901
902 struct WorkerPlugin;
903
904 impl Plugin for WorkerPlugin {
905 fn name(&self) -> &'static str {
906 "tasks"
907 }
908 fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
909 vec![Box::new(WorkerCmd)]
910 }
911 }
912
913 async fn app_with_worker() -> App {
914 let settings = Settings::from_env().expect("figment defaults load");
915 let pool = umbral::db::connect_sqlite("sqlite::memory:")
916 .await
917 .expect("in-memory sqlite connects");
918 App::builder()
919 .settings(settings)
920 .database("default", pool)
921 .plugin(WorkerPlugin)
922 .build()
923 .expect("App builds")
924 }
925
926 #[test]
927 fn wants_top_level_help_recognizes_help_forms() {
928 let os = |s: &str| std::ffi::OsString::from(s);
929 assert!(wants_top_level_help(&[os("umbral"), os("help")]));
930 assert!(wants_top_level_help(&[os("umbral"), os("--help")]));
931 assert!(wants_top_level_help(&[os("umbral"), os("-h")]));
932 assert!(!wants_top_level_help(&[os("umbral")]));
934 assert!(!wants_top_level_help(&[
936 os("umbral"),
937 os("migrate"),
938 os("--help")
939 ]));
940 assert!(!wants_top_level_help(&[os("umbral"), os("migrate")]));
942 }
943
944 #[test]
945 fn unknown_token_picks_first_non_flag() {
946 let os = |s: &str| std::ffi::OsString::from(s);
947 assert_eq!(
948 unknown_token(&[os("umbral"), os("--verbose"), os("frobnicate")]).as_deref(),
949 Some("frobnicate")
950 );
951 assert_eq!(unknown_token(&[os("umbral")]), None);
952 }
953
954 #[tokio::test]
960 async fn help_and_unknown_list_builtins_and_plugin_commands() {
961 let app = app_with_worker().await;
962
963 let out = render_full_help(&app);
965 assert!(
967 out.contains("migrate"),
968 "built-in `migrate` missing:\n{out}"
969 );
970 assert!(
971 out.contains("Apply every pending migration"),
972 "built-in `migrate` about missing:\n{out}"
973 );
974 assert!(
976 out.contains("tasks-worker") && out.contains("Run the task worker"),
977 "plugin command missing:\n{out}"
978 );
979 let mig_line = out
982 .lines()
983 .find(|l| l.trim_start().starts_with("migrate"))
984 .unwrap();
985 let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
986 let mig_col = mig_line.find("Apply every pending migration").unwrap();
987 let worker_col = worker_line.find("Run the task worker").unwrap();
988 assert_eq!(mig_col, worker_col, "descriptions not aligned:\n{out}");
989
990 let out = render_unknown(&app, Some("frobnicate"));
992 assert!(
993 out.contains("unknown command") && out.contains("frobnicate"),
994 "missing unknown-command error:\n{out}"
995 );
996 assert!(out.contains("migrate"), "listing missing built-in:\n{out}");
998 assert!(
999 out.contains("tasks-worker"),
1000 "listing missing plugin cmd:\n{out}"
1001 );
1002 }
1003}