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 Squashmigrations {
221 plugin: String,
223 },
224}
225
226pub async fn dispatch(app: App) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
237 let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
238 dispatch_with_argv(app, argv).await
239}
240
241pub async fn dispatch_with_argv(
250 app: App,
251 argv: Vec<std::ffi::OsString>,
252) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
253 if wants_top_level_help(&argv) {
260 print!("{}", render_full_help(&app));
261 return Ok(());
262 }
263
264 if !app.plugins().is_empty() {
271 match umbral_core::cli::dispatch(app.plugins(), argv.clone()).await {
272 Ok(umbral_core::cli::DispatchOutcome::Matched(_)) => return Ok(()),
273 Ok(umbral_core::cli::DispatchOutcome::Help(msg)) => {
274 print!("{msg}");
279 return Ok(());
280 }
281 Ok(umbral_core::cli::DispatchOutcome::Unmatched) => {
282 }
284 Err(e) => return Err(e),
285 }
286 }
287
288 let cli = match Cli::try_parse_from(&argv) {
292 Ok(c) => c,
293 Err(e) => {
294 use clap::error::ErrorKind;
295 match e.kind() {
296 ErrorKind::InvalidSubcommand
303 | ErrorKind::UnknownArgument
304 | ErrorKind::InvalidValue => {
305 let bad = unknown_token(&argv);
306 eprint!("{}", render_unknown(&app, bad.as_deref()));
307 std::process::exit(2);
308 }
309 _ => {
310 e.print()?;
314 std::process::exit(if e.use_stderr() { 2 } else { 0 });
315 }
316 }
317 }
318 };
319 match cli.command.unwrap_or(Command::Serve { addr: None }) {
320 Command::Serve { addr } => serve(app, addr).await,
321 Command::Makemigrations { empty } => makemigrations(empty).await,
322 Command::Migrate {
323 fake,
324 fake_initial,
325 allow_drift,
326 allow_destructive,
327 } => migrate(fake, fake_initial, allow_drift, allow_destructive).await,
328 Command::Showmigrations => showmigrations().await,
329 Command::Checkmigrations { strict } => checkmigrations(strict).await,
330 Command::Inspectdb {
331 output,
332 mark_applied,
333 } => inspectdb(output, mark_applied).await,
334 Command::Dumpdata { output } => dumpdata(output).await,
335 Command::Loaddata { input } => loaddata(input).await,
336 Command::Importcsv { table, input } => importcsv(table, input).await,
337 Command::Dev { watch, run_args } => dev(watch, run_args).await,
338 Command::Maskkeygen => maskkeygen(),
339 Command::Squashmigrations { plugin } => squashmigrations(plugin).await,
340 }
341}
342
343async fn squashmigrations(plugin: String) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
348 let out = umbral::migrate::squash_in(
349 std::path::Path::new(umbral::migrate::MIGRATIONS_DIR),
350 &plugin,
351 )?;
352 println!(
353 "Squashed {} migrations for `{plugin}` into {}",
354 out.replaced.len(),
355 out.id
356 );
357 println!(" wrote {}", out.path.display());
358 println!(" replaces: {}", out.replaced.join(", "));
359 println!(
360 "\nThe originals are kept on disk (non-destructive). `migrate` now applies the squash on \n\
361 a fresh database and record-only on databases that already ran the originals. Once EVERY \n\
362 deploy has migrated past this squash, delete the {} original file(s) it replaces.",
363 out.replaced.len()
364 );
365 Ok(())
366}
367
368pub const STANDALONE_COMMANDS: &[&str] = &["maskkeygen"];
378
379pub fn try_run_standalone(
387 argv: &[String],
388) -> Option<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
389 match argv.first().map(String::as_str) {
390 Some("maskkeygen") => Some(maskkeygen()),
391 _ => None,
392 }
393}
394
395fn maskkeygen() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
400 let (public, secret) = umbral_core::orm::MaskKeyring::generate();
401 println!("# Masked<T> field-encryption keypair — add to your environment / .env:");
402 println!("# UMBRAL_MASK_PUBLIC_KEY encrypts; UMBRAL_MASK_PRIVATE_KEY decrypts (reveal()).");
403 println!(
404 "# Keep the PRIVATE key secret. Destroying it crypto-shreds every masked column\n\
405 # (a fast bulk \"right to be forgotten\")."
406 );
407 println!(
408 "# WARNING: the private key is printed below to STDOUT. Capture it straight into a\n\
409 # secret store (Vault, cloud secret manager, a sealed CI variable) and keep it out\n\
410 # of shell history, terminal scrollback, CI job logs, and any committed .env."
411 );
412 println!("UMBRAL_MASK_PUBLIC_KEY={public}");
413 println!("UMBRAL_MASK_PRIVATE_KEY={secret}");
414 Ok(())
415}
416
417fn wants_top_level_help(argv: &[std::ffi::OsString]) -> bool {
427 match argv.get(1) {
428 None => false,
429 Some(first) => first == "help" || first == "--help" || first == "-h",
430 }
431}
432
433fn unknown_token(argv: &[std::ffi::OsString]) -> Option<String> {
437 argv.iter()
438 .skip(1)
439 .find(|a| !a.to_string_lossy().starts_with('-'))
440 .map(|a| a.to_string_lossy().into_owned())
441}
442
443fn full_catalog(app: &App) -> Vec<(String, Option<String>)> {
448 let mut catalog: Vec<(String, Option<String>)> = Vec::new();
449 let root = <Cli as CommandFactory>::command();
450 for sub in root.get_subcommands() {
451 catalog.push((
452 sub.get_name().to_string(),
453 sub.get_about().map(|s| s.to_string()),
454 ));
455 }
456 catalog.extend(umbral_core::cli::command_catalog(app.plugins()));
457 catalog
458}
459
460fn render_full_help(app: &App) -> String {
463 umbral_core::cli::render_help(&full_catalog(app))
464}
465
466fn render_unknown(app: &App, bad: Option<&str>) -> String {
471 let mut s = String::new();
472 match bad {
473 Some(b) => s.push_str(&format!("error: unknown command `{b}`\n\n")),
474 None => s.push_str("error: unknown command\n\n"),
475 }
476 s.push_str(&render_full_help(app));
477 s
478}
479
480async fn dev(
489 extra_watches: Vec<String>,
490 run_args: Vec<String>,
491) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
492 let probe = std::process::Command::new("cargo")
494 .args(["watch", "--version"])
495 .stdout(std::process::Stdio::null())
496 .stderr(std::process::Stdio::null())
497 .status();
498 if probe.is_err() || probe.as_ref().map(|s| !s.success()).unwrap_or(true) {
499 eprintln!(
500 "umbral dev: `cargo-watch` is not installed.\n\n\
501 Install with:\n\n\
502 \x20\x20\x20\x20cargo install cargo-watch\n\n\
503 Then re-run `cargo run -- dev`.\n\n\
504 Workaround without cargo-watch: leave one terminal running\n\
505 `cargo run` and Ctrl-C + re-run after each edit. Templates\n\
506 still hot-reload in dev mode without any restart.",
507 );
508 std::process::exit(1);
509 }
510
511 let mut cmd = std::process::Command::new("cargo");
515 cmd.arg("watch");
516 for path in &extra_watches {
517 cmd.arg("-w").arg(path);
518 }
519 let cargo_cmd = if run_args.is_empty() {
520 "run".to_string()
521 } else {
522 format!("run -- {}", run_args.join(" "))
523 };
524 cmd.arg("-x").arg(&cargo_cmd);
525
526 eprintln!("umbral dev: watching for changes, running `cargo {cargo_cmd}` on each save");
527 eprintln!(
528 "umbral dev: templates also hot-reload in-process; no restart needed for .html edits"
529 );
530 eprintln!("umbral dev: Ctrl-C to stop");
531 eprintln!();
532
533 let status = cmd.status()?;
534 if !status.success() {
535 return Err(format!(
536 "cargo-watch exited with status {}",
537 status
538 .code()
539 .map(|c| c.to_string())
540 .unwrap_or_else(|| "<signal>".to_string())
541 )
542 .into());
543 }
544 Ok(())
545}
546
547async fn serve(
548 app: App,
549 addr_override: Option<String>,
550) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
551 if app.auto_migrate_on_serve_enabled() {
557 let n = umbral::migrate::run().await?;
558 if n > 0 {
559 eprintln!("auto-migrate: applied {n} migration(s)");
560 }
561 }
562 let addr_str = match addr_override {
563 Some(s) => s,
564 None => umbral_core::settings::get().bind_addr.clone(),
565 };
566 let addr: SocketAddr = addr_str
567 .parse()
568 .map_err(|e| format!("umbral: invalid bind_addr `{addr_str}`: {e}"))?;
569 app.serve(addr).await?;
570 Ok(())
571}
572
573async fn makemigrations(
574 empty: Option<String>,
575) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
576 if let Some(plugin) = empty {
579 let path = umbral::migrate::make_empty(&plugin).await?;
580 println!("Wrote {} (empty)", path.display());
581 println!(
582 " Edit it to add a data migration, e.g.:\n \
583 {{ \"kind\": \"RunSql\", \"sql\": \"UPDATE ... SET ...\", \
584 \"reverse_sql\": null }}"
585 );
586 return Ok(());
587 }
588
589 match umbral::migrate::make().await {
590 Ok(paths) => {
591 for path in paths {
592 println!("Wrote {}", path.display());
593 }
594 Ok(())
595 }
596 Err(MigrateError::NoChanges) => {
597 println!("no changes detected");
598 Ok(())
599 }
600 Err(err) => Err(Box::new(err)),
601 }
602}
603
604async fn migrate(
605 fake: Option<String>,
606 fake_initial: bool,
607 allow_drift: bool,
608 allow_destructive: bool,
609) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
610 if let Some(ref spec) = fake {
612 let (plugin, name) = parse_migration_spec(spec)?;
613 umbral::migrate::fake_apply(plugin, name).await?;
614 println!("Marked {spec} as applied (no SQL executed)");
615 return Ok(());
616 }
617
618 if !allow_destructive {
625 let unsafe_ops: Vec<_> = umbral::migrate::check_pending_safety()
626 .await?
627 .into_iter()
628 .filter(|c| c.safety.is_unsafe())
629 .collect();
630 if !unsafe_ops.is_empty() {
631 eprintln!(
632 "error: umbral migrate: {} pending destructive operation(s) would DESTROY DATA:",
633 unsafe_ops.len()
634 );
635 for c in &unsafe_ops {
636 eprintln!(
637 " [UNSAFE] {}/{}: {}",
638 c.plugin,
639 c.migration,
640 c.safety.reason()
641 );
642 }
643 eprintln!();
644 eprintln!(
645 " These usually come from an unregistered model/plugin (a removed \
646 `.model::<T>()`, a dropped plugin, or a feature flag off).\n \
647 If the drop is intended, re-run: `umbral migrate --allow-destructive`.\n \
648 If NOT, restore the model registration and re-run `makemigrations`."
649 );
650 return Err(format!(
651 "refusing to apply {} destructive migration operation(s) without --allow-destructive",
652 unsafe_ops.len()
653 )
654 .into());
655 }
656 }
657
658 if fake_initial {
660 let n = umbral::migrate::fake_initial().await?;
661 if n == 0 {
662 println!("No plugins needed fake-initial (either already applied or tables absent)");
663 } else {
664 println!("Fake-applied initial migration for {n} plugin(s)");
665 }
666 return Ok(());
667 }
668
669 match umbral::migrate::run_checked(allow_drift).await {
671 Ok(n) => {
672 if n == 0 {
673 println!("No pending migrations");
674 } else {
675 println!("Applied {n} migration(s)");
676 }
677 Ok(())
678 }
679 Err(MigrateError::DriftDetected { ref missing }) => {
680 let names: Vec<String> = missing.iter().map(|(p, n)| format!("{p}/{n}")).collect();
681 eprintln!("error: umbral migrate: drift detected");
682 eprintln!(" The following migrations are in the tracking table but missing on disk:");
683 for name in &names {
684 eprintln!(" [!] {name}");
685 }
686 eprintln!();
687 eprintln!(
688 " Options:\n \
689 1. Restore the file(s) from VCS.\n \
690 2. Run `umbral migrate --allow-drift` to proceed and apply pending migrations.\n \
691 3. Run `umbral migrate --fake <plugin/name>` to mark an individual migration \
692 as applied without running SQL."
693 );
694 Err(Box::new(MigrateError::DriftDetected {
695 missing: missing.clone(),
696 }))
697 }
698 Err(err) => Err(Box::new(err)),
699 }
700}
701
702fn parse_migration_spec(
705 spec: &str,
706) -> Result<(&str, &str), Box<dyn std::error::Error + Send + Sync>> {
707 let mut parts = spec.splitn(2, '/');
708 let plugin = parts.next().ok_or("migration spec must be `plugin/name`")?;
709 let name = parts
710 .next()
711 .ok_or("migration spec must be `plugin/name`; missing name after `/`")?;
712 Ok((plugin, name))
713}
714
715async fn showmigrations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
716 let pending = umbral::migrate::show().await?;
717 if pending > 0 {
718 println!("\n{pending} migration(s) not yet applied.");
719 }
720 Ok(())
721}
722
723async fn checkmigrations(strict: bool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
728 let ops = umbral::migrate::check_pending_safety().await?;
729 if ops.is_empty() {
730 println!("No pending migrations — nothing to check.");
731 return Ok(());
732 }
733
734 let unsafe_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_unsafe()).collect();
735 let warn_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_warning()).collect();
736 let safe_count = ops.len() - unsafe_ops.len() - warn_ops.len();
737
738 let migrations: std::collections::BTreeSet<_> =
739 ops.iter().map(|c| (&c.plugin, &c.migration)).collect();
740 println!(
741 "Checking {} operation(s) across {} pending migration(s)...\n",
742 ops.len(),
743 migrations.len()
744 );
745
746 if !unsafe_ops.is_empty() {
747 println!("UNSAFE ({}):", unsafe_ops.len());
748 for c in &unsafe_ops {
749 println!(
750 " [{}] {}/{} — {}",
751 op_kind(&c.op),
752 c.plugin,
753 c.migration,
754 c.safety.reason()
755 );
756 }
757 println!();
758 }
759
760 if !warn_ops.is_empty() {
761 println!("WARNING ({}):", warn_ops.len());
762 for c in &warn_ops {
763 println!(
764 " [{}] {}/{} — {}",
765 op_kind(&c.op),
766 c.plugin,
767 c.migration,
768 c.safety.reason()
769 );
770 }
771 println!();
772 }
773
774 println!(
775 "Summary: {} safe, {} warning, {} unsafe.",
776 safe_count,
777 warn_ops.len(),
778 unsafe_ops.len()
779 );
780
781 let blocked = !unsafe_ops.is_empty() || (strict && !warn_ops.is_empty());
783 if blocked {
784 let why = if !unsafe_ops.is_empty() {
785 format!("{} unsafe operation(s) found", unsafe_ops.len())
786 } else {
787 format!("{} warning(s) found (--strict)", warn_ops.len())
788 };
789 return Err(format!(
790 "checkmigrations: {why}. Review the expand-contract notes above before deploying."
791 )
792 .into());
793 }
794
795 println!("\nAll pending operations are safe for a rolling deploy.");
796 Ok(())
797}
798
799fn op_kind(op: &umbral::migrate::Operation) -> &'static str {
802 use umbral::migrate::Operation;
803 match op {
804 Operation::CreateTable { .. } => "CREATE TABLE",
805 Operation::DropTable { .. } => "DROP TABLE",
806 Operation::AddColumn { .. } => "ADD COL",
807 Operation::DropColumn { .. } => "DROP COL",
808 Operation::AlterColumn { .. } => "ALTER COL",
809 Operation::RenameTable { .. } => "RENAME TABLE",
810 Operation::RenameColumn { .. } => "RENAME COL",
811 Operation::CreateM2MTable { .. } => "CREATE M2M",
812 Operation::DropM2MTable { .. } => "DROP M2M",
813 Operation::RunSql { .. } => "RUN SQL",
814 Operation::AddIndex { unique: true, .. } => "ADD UNIQUE",
815 Operation::AddIndex { unique: false, .. } => "ADD INDEX",
816 Operation::DropIndex { .. } => "DROP INDEX",
817 }
818}
819
820async fn inspectdb(
821 output: PathBuf,
822 mark_applied: bool,
823) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
824 let opts = InspectOptions {
825 output,
826 mark_applied,
827 };
828 match umbral::inspect::inspectdb(opts).await {
829 Ok(report) => {
830 println!(
831 "Inspected {} table(s), {} column(s)",
832 report.tables, report.columns,
833 );
834 println!("Wrote {}", report.models_path.display());
835 println!("Wrote {}", report.migration_path.display());
836 Ok(())
837 }
838 Err(InspectError::NoTables) => {
839 println!("no tables found in the database");
840 Ok(())
841 }
842 Err(err) => Err(Box::new(err)),
843 }
844}
845
846async fn dumpdata(output: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
847 umbral::backup::dump_to_path(&output).await?;
848 println!("Wrote {}", output.display());
849 Ok(())
850}
851
852async fn loaddata(input: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
853 let report = umbral::backup::load_from_path(&input).await?;
854 println!(
855 "Loaded {} row(s) into {} table(s)",
856 report.rows_loaded,
857 report.tables_loaded.len()
858 );
859 for skipped in &report.skipped_tables {
860 eprintln!("warning: skipped table `{skipped}` (not in current schema)");
861 }
862 Ok(())
863}
864
865async fn importcsv(
870 table: String,
871 input: PathBuf,
872) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
873 let models = umbral::migrate::registered_models();
876 let Some(meta) = models.into_iter().find(|m| m.table == table) else {
877 let mut known: Vec<String> = umbral::migrate::registered_models()
878 .iter()
879 .map(|m| m.table.clone())
880 .collect();
881 known.sort();
882 return Err(format!(
883 "importcsv: unknown table `{table}`. Registered tables: {}",
884 known.join(", ")
885 )
886 .into());
887 };
888
889 let mut reader = csv::ReaderBuilder::new()
890 .has_headers(true)
891 .flexible(true)
892 .from_path(&input)?;
893 let headers: Vec<String> = reader.headers()?.iter().map(|s| s.to_string()).collect();
894 if headers.is_empty() {
895 return Err("importcsv: the CSV has no header row".into());
896 }
897 let mut rows: Vec<Vec<String>> = Vec::new();
898 for record in reader.records() {
899 let record = record?;
900 rows.push(record.iter().map(|s| s.to_string()).collect());
901 }
902
903 let report = umbral::orm::import_table_rows(&meta, &headers, &rows).await;
904 println!(
905 "Imported {} row(s) into `{}` ({} failed)",
906 report.inserted,
907 table,
908 report.errors.len()
909 );
910 for (line, message) in &report.errors {
911 eprintln!(" line {line}: {message}");
912 }
913 if report.errors.is_empty() {
916 Ok(())
917 } else {
918 Err(format!("importcsv: {} row(s) failed", report.errors.len()).into())
919 }
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use async_trait::async_trait;
926 use clap::ArgMatches;
927 use umbral::Settings;
928 use umbral_core::cli::{CliError, PluginCommand};
929 use umbral_core::plugin::Plugin;
930
931 #[test]
932 fn forward_args_prefix_cargo_run_dashdash() {
933 assert_eq!(
935 cargo_run_forward_args(&["dev".to_string()]),
936 vec!["run", "--", "dev"]
937 );
938 assert_eq!(
940 cargo_run_forward_args(&[
941 "migrate".to_string(),
942 "--fake".to_string(),
943 "accounts/0001_auto".to_string(),
944 ]),
945 vec!["run", "--", "migrate", "--fake", "accounts/0001_auto"]
946 );
947 }
948
949 #[test]
950 fn in_cargo_project_detects_manifest_upward() {
951 let tmp = tempfile::tempdir().expect("tempdir");
952 let root = tmp.path();
953 assert!(!in_cargo_project(root));
955 std::fs::write(root.join("Cargo.toml"), b"[package]\nname='x'\n").unwrap();
957 let nested = root.join("src").join("widgets");
958 std::fs::create_dir_all(&nested).unwrap();
959 assert!(in_cargo_project(&nested), "walks up to find the manifest");
960 assert!(in_cargo_project(root));
961 }
962
963 struct WorkerCmd;
964
965 #[async_trait]
966 impl PluginCommand for WorkerCmd {
967 fn command(&self) -> clap::Command {
968 clap::Command::new("tasks-worker").about("Run the task worker")
969 }
970 async fn run(&self, _m: &ArgMatches) -> Result<(), CliError> {
971 Ok(())
972 }
973 }
974
975 struct WorkerPlugin;
976
977 impl Plugin for WorkerPlugin {
978 fn name(&self) -> &'static str {
979 "tasks"
980 }
981 fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
982 vec![Box::new(WorkerCmd)]
983 }
984 }
985
986 async fn app_with_worker() -> App {
987 let settings = Settings::from_env().expect("figment defaults load");
988 let pool = umbral::db::connect_sqlite("sqlite::memory:")
989 .await
990 .expect("in-memory sqlite connects");
991 App::builder()
992 .settings(settings)
993 .database("default", pool)
994 .plugin(WorkerPlugin)
995 .build()
996 .expect("App builds")
997 }
998
999 #[test]
1000 fn wants_top_level_help_recognizes_help_forms() {
1001 let os = |s: &str| std::ffi::OsString::from(s);
1002 assert!(wants_top_level_help(&[os("umbral"), os("help")]));
1003 assert!(wants_top_level_help(&[os("umbral"), os("--help")]));
1004 assert!(wants_top_level_help(&[os("umbral"), os("-h")]));
1005 assert!(!wants_top_level_help(&[os("umbral")]));
1007 assert!(!wants_top_level_help(&[
1009 os("umbral"),
1010 os("migrate"),
1011 os("--help")
1012 ]));
1013 assert!(!wants_top_level_help(&[os("umbral"), os("migrate")]));
1015 }
1016
1017 #[test]
1018 fn unknown_token_picks_first_non_flag() {
1019 let os = |s: &str| std::ffi::OsString::from(s);
1020 assert_eq!(
1021 unknown_token(&[os("umbral"), os("--verbose"), os("frobnicate")]).as_deref(),
1022 Some("frobnicate")
1023 );
1024 assert_eq!(unknown_token(&[os("umbral")]), None);
1025 }
1026
1027 #[tokio::test]
1033 async fn help_and_unknown_list_builtins_and_plugin_commands() {
1034 let app = app_with_worker().await;
1035
1036 let out = render_full_help(&app);
1038 assert!(
1040 out.contains("migrate"),
1041 "built-in `migrate` missing:\n{out}"
1042 );
1043 assert!(
1044 out.contains("Apply every pending migration"),
1045 "built-in `migrate` about missing:\n{out}"
1046 );
1047 assert!(
1049 out.contains("tasks-worker") && out.contains("Run the task worker"),
1050 "plugin command missing:\n{out}"
1051 );
1052 let mig_line = out
1055 .lines()
1056 .find(|l| l.trim_start().starts_with("migrate"))
1057 .unwrap();
1058 let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
1059 let mig_col = mig_line.find("Apply every pending migration").unwrap();
1060 let worker_col = worker_line.find("Run the task worker").unwrap();
1061 assert_eq!(mig_col, worker_col, "descriptions not aligned:\n{out}");
1062
1063 let out = render_unknown(&app, Some("frobnicate"));
1065 assert!(
1066 out.contains("unknown command") && out.contains("frobnicate"),
1067 "missing unknown-command error:\n{out}"
1068 );
1069 assert!(out.contains("migrate"), "listing missing built-in:\n{out}");
1071 assert!(
1072 out.contains("tasks-worker"),
1073 "listing missing plugin cmd:\n{out}"
1074 );
1075 }
1076}