use std::net::SocketAddr;
use std::path::PathBuf;
use clap::{CommandFactory, Parser, Subcommand};
use umbral::App;
use umbral::inspect::{InspectError, InspectOptions};
use umbral::migrate::MigrateError;
pub mod scaffold;
pub fn cargo_run_forward_args(forwarded: &[String]) -> Vec<String> {
let mut argv = vec!["run".to_string(), "--".to_string()];
argv.extend(forwarded.iter().cloned());
argv
}
pub fn in_cargo_project(start: &std::path::Path) -> bool {
start
.ancestors()
.any(|dir| dir.join("Cargo.toml").is_file())
}
#[derive(Debug, Parser)]
#[command(
name = "umbral",
about = "umbral management commands. Run from your project's binary.",
disable_help_subcommand = true
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Serve {
#[arg(long)]
addr: Option<String>,
},
Makemigrations {
#[arg(long, value_name = "PLUGIN")]
empty: Option<String>,
},
Migrate {
#[arg(long, value_name = "PLUGIN/NAME")]
fake: Option<String>,
#[arg(long, default_value_t = false)]
fake_initial: bool,
#[arg(long, default_value_t = false)]
allow_drift: bool,
#[arg(long, default_value_t = false)]
allow_destructive: bool,
#[arg(long, default_value_t = false)]
allow_in_memory: bool,
},
Showmigrations,
Checkmigrations {
#[arg(long, default_value_t = false)]
strict: bool,
},
Typegen {
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, default_value_t = false, requires = "out")]
check: bool,
},
Inspectdb {
#[arg(long)]
output: PathBuf,
#[arg(long, default_value_t = false)]
mark_applied: bool,
},
Dumpdata {
#[arg(long)]
output: PathBuf,
},
Loaddata {
input: PathBuf,
},
Importcsv {
table: String,
input: PathBuf,
},
Dev {
#[arg(long, short = 'w')]
watch: Vec<String>,
#[arg(last = true)]
run_args: Vec<String>,
},
Maskkeygen,
Squashmigrations {
plugin: String,
},
}
pub async fn dispatch(app: App) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
dispatch_with_argv(app, argv).await
}
fn subcommand_name(argv: &[std::ffi::OsString]) -> Option<String> {
argv.iter()
.skip(1)
.find(|a| !a.to_string_lossy().starts_with('-'))
.map(|a| a.to_string_lossy().into_owned())
}
fn builtin_needs_ready(subcommand: Option<&str>) -> bool {
match subcommand {
None => false,
Some(
"serve" | "migrate" | "makemigrations" | "showmigrations" | "checkmigrations"
| "squashmigrations" | "inspectdb" | "typegen" | "maskkeygen" | "dev" | "help",
) => false,
Some(_) => true,
}
}
pub async fn dispatch_with_argv(
app: App,
argv: Vec<std::ffi::OsString>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if wants_top_level_help(&argv) {
print!("{}", render_full_help(&app));
return Ok(());
}
let subcommand = subcommand_name(&argv);
let builtins = builtin_command_names();
let reserved: Vec<&str> = builtins.iter().map(String::as_str).collect();
let commands = umbral_core::cli::CommandSet::collect(app.commands(), app.plugins(), &reserved);
let needs_ready = subcommand
.as_deref()
.and_then(|name| commands.needs_ready(name))
.unwrap_or_else(|| builtin_needs_ready(subcommand.as_deref()));
if needs_ready {
app.ready()?;
} else if app.ready_already_fired() && !matches!(subcommand.as_deref(), None | Some("serve")) {
eprintln!(
"warning: plugin `on_ready` hooks already fired before `{}` ran. They seed \n\
content and backfill rows, which is wrong for a schema command against a \n\
fresh database. In main.rs, build with `.build_deferred()?` instead of \n\
`.build()?` and let `dispatch` decide when the app is ready.",
subcommand.as_deref().unwrap_or("<none>"),
);
}
if !commands.is_empty() {
match commands.dispatch(argv.clone()).await {
Ok(umbral_core::cli::DispatchOutcome::Matched(_)) => return Ok(()),
Ok(umbral_core::cli::DispatchOutcome::Help(msg)) => {
print!("{msg}");
return Ok(());
}
Ok(umbral_core::cli::DispatchOutcome::Unmatched) => {
}
Err(e) => return Err(e),
}
}
let cli = match Cli::try_parse_from(&argv) {
Ok(c) => c,
Err(e) => {
use clap::error::ErrorKind;
match e.kind() {
ErrorKind::InvalidSubcommand
| ErrorKind::UnknownArgument
| ErrorKind::InvalidValue => {
let bad = unknown_token(&argv);
eprint!("{}", render_unknown(&app, bad.as_deref()));
std::process::exit(2);
}
_ => {
e.print()?;
std::process::exit(if e.use_stderr() { 2 } else { 0 });
}
}
}
};
match cli.command.unwrap_or(Command::Serve { addr: None }) {
Command::Serve { addr } => serve(app, addr).await,
Command::Makemigrations { empty } => makemigrations(empty).await,
Command::Migrate {
fake,
fake_initial,
allow_drift,
allow_destructive,
allow_in_memory,
} => {
migrate(
fake,
fake_initial,
allow_drift,
allow_destructive,
allow_in_memory,
)
.await
}
Command::Showmigrations => showmigrations().await,
Command::Checkmigrations { strict } => checkmigrations(strict).await,
Command::Typegen { out, check } => typegen(out, check),
Command::Inspectdb {
output,
mark_applied,
} => inspectdb(output, mark_applied).await,
Command::Dumpdata { output } => dumpdata(output).await,
Command::Loaddata { input } => loaddata(input).await,
Command::Importcsv { table, input } => importcsv(table, input).await,
Command::Dev { watch, run_args } => dev(watch, run_args).await,
Command::Maskkeygen => maskkeygen(),
Command::Squashmigrations { plugin } => squashmigrations(plugin).await,
}
}
async fn squashmigrations(plugin: String) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let out = umbral::migrate::squash_in(
std::path::Path::new(umbral::migrate::MIGRATIONS_DIR),
&plugin,
)?;
println!(
"Squashed {} migrations for `{plugin}` into {}",
out.replaced.len(),
out.id
);
println!(" wrote {}", out.path.display());
println!(" replaces: {}", out.replaced.join(", "));
println!(
"\nThe originals are kept on disk (non-destructive). `migrate` now applies the squash on \n\
a fresh database and record-only on databases that already ran the originals. Once EVERY \n\
deploy has migrated past this squash, delete the {} original file(s) it replaces.",
out.replaced.len()
);
Ok(())
}
pub const STANDALONE_COMMANDS: &[&str] = &["maskkeygen"];
pub fn try_run_standalone(
argv: &[String],
) -> Option<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
match argv.first().map(String::as_str) {
Some("maskkeygen") => Some(maskkeygen()),
_ => None,
}
}
fn maskkeygen() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (public, secret) = umbral_core::orm::MaskKeyring::generate();
println!("# Masked<T> field-encryption keypair — add to your environment / .env:");
println!("# UMBRAL_MASK_PUBLIC_KEY encrypts; UMBRAL_MASK_PRIVATE_KEY decrypts (reveal()).");
println!(
"# Keep the PRIVATE key secret. Destroying it crypto-shreds every masked column\n\
# (a fast bulk \"right to be forgotten\")."
);
println!(
"# WARNING: the private key is printed below to STDOUT. Capture it straight into a\n\
# secret store (Vault, cloud secret manager, a sealed CI variable) and keep it out\n\
# of shell history, terminal scrollback, CI job logs, and any committed .env."
);
println!("UMBRAL_MASK_PUBLIC_KEY={public}");
println!("UMBRAL_MASK_PRIVATE_KEY={secret}");
Ok(())
}
fn wants_top_level_help(argv: &[std::ffi::OsString]) -> bool {
match argv.get(1) {
None => false,
Some(first) => first == "help" || first == "--help" || first == "-h",
}
}
fn unknown_token(argv: &[std::ffi::OsString]) -> Option<String> {
argv.iter()
.skip(1)
.find(|a| !a.to_string_lossy().starts_with('-'))
.map(|a| a.to_string_lossy().into_owned())
}
fn full_catalog(app: &App) -> Vec<(String, Option<String>)> {
let mut catalog: Vec<(String, Option<String>)> = Vec::new();
let root = <Cli as CommandFactory>::command();
for sub in root.get_subcommands() {
catalog.push((
sub.get_name().to_string(),
sub.get_about().map(|s| s.to_string()),
));
}
let builtins = builtin_command_names();
let reserved: Vec<&str> = builtins.iter().map(String::as_str).collect();
catalog.extend(umbral_core::cli::command_catalog_with_app_commands(
app.commands(),
app.plugins(),
&reserved,
));
catalog
}
pub fn builtin_command_names() -> Vec<String> {
let mut names: Vec<String> = <Cli as CommandFactory>::command()
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect();
names.push("help".to_string());
names
}
fn render_full_help(app: &App) -> String {
umbral_core::cli::render_help(&full_catalog(app))
}
fn render_unknown(app: &App, bad: Option<&str>) -> String {
let mut s = String::new();
match bad {
Some(b) => s.push_str(&format!("error: unknown command `{b}`\n\n")),
None => s.push_str("error: unknown command\n\n"),
}
s.push_str(&render_full_help(app));
s
}
async fn dev(
extra_watches: Vec<String>,
run_args: Vec<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let probe = std::process::Command::new("cargo")
.args(["watch", "--version"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if probe.is_err() || probe.as_ref().map(|s| !s.success()).unwrap_or(true) {
eprintln!(
"umbral dev: `cargo-watch` is not installed.\n\n\
Install with:\n\n\
\x20\x20\x20\x20cargo install cargo-watch\n\n\
Then re-run `cargo run -- dev`.\n\n\
Workaround without cargo-watch: leave one terminal running\n\
`cargo run` and Ctrl-C + re-run after each edit. Templates\n\
still hot-reload in dev mode without any restart.",
);
std::process::exit(1);
}
let mut cmd = std::process::Command::new("cargo");
cmd.arg("watch");
for path in &extra_watches {
cmd.arg("-w").arg(path);
}
let cargo_cmd = if run_args.is_empty() {
"run".to_string()
} else {
format!("run -- {}", run_args.join(" "))
};
cmd.arg("-x").arg(&cargo_cmd);
eprintln!("umbral dev: watching for changes, running `cargo {cargo_cmd}` on each save");
eprintln!(
"umbral dev: templates also hot-reload in-process; no restart needed for .html edits"
);
eprintln!("umbral dev: Ctrl-C to stop");
eprintln!();
let status = cmd.status()?;
if !status.success() {
return Err(format!(
"cargo-watch exited with status {}",
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "<signal>".to_string())
)
.into());
}
Ok(())
}
async fn serve(
app: App,
addr_override: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if app.auto_migrate_on_serve_enabled() {
let dev = matches!(
umbral_core::settings::get().environment,
umbral::Environment::Dev
);
if dev {
match umbral::migrate::make().await {
Ok(paths) => {
for path in paths {
eprintln!("auto-migrate: wrote {}", path.display());
}
}
Err(umbral::migrate::MigrateError::NoChanges) => {}
Err(err) => return Err(Box::new(err)),
}
}
let n = umbral::migrate::run().await?;
if n > 0 {
eprintln!("auto-migrate: applied {n} migration(s)");
}
}
if let Some(seed) = app.seed_on_serve_hook() {
seed().await?;
}
let addr_str = match addr_override {
Some(s) => s,
None => umbral_core::settings::get().bind_addr.clone(),
};
let addr: SocketAddr = addr_str
.parse()
.map_err(|e| format!("umbral: invalid bind_addr `{addr_str}`: {e}"))?;
app.serve(addr).await?;
Ok(())
}
async fn makemigrations(
empty: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(plugin) = empty {
let path = umbral::migrate::make_empty(&plugin).await?;
println!("Wrote {} (empty)", path.display());
println!(
" Edit it to add a data migration, e.g.:\n \
{{ \"kind\": \"RunSql\", \"sql\": \"UPDATE ... SET ...\", \
\"reverse_sql\": null }}"
);
return Ok(());
}
match umbral::migrate::make().await {
Ok(paths) => {
for path in paths {
println!("Wrote {}", path.display());
}
Ok(())
}
Err(MigrateError::NoChanges) => {
println!("no changes detected");
Ok(())
}
Err(err) => Err(Box::new(err)),
}
}
async fn migrate(
fake: Option<String>,
fake_initial: bool,
allow_drift: bool,
allow_destructive: bool,
allow_in_memory: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(cfg) = umbral::settings::get_opt() {
let url = &cfg.database_url;
if !allow_in_memory && (url.contains(":memory:") || url.contains("mode=memory")) {
eprintln!("error: umbral migrate: `database_url` is an IN-MEMORY database ({url}).");
eprintln!();
eprintln!(" Migrating it would apply every migration to a database that is");
eprintln!(" discarded the moment this process exits — reporting success and");
eprintln!(" persisting nothing.");
eprintln!();
eprintln!(" `sqlite::memory:` is the DEFAULT, so this almost always means your");
eprintln!(" configuration never loaded. Common causes:");
eprintln!(" - a `.env` still using the old `UMBRA_` prefix (it is now `UMBRAL_`)");
eprintln!(" - no `umbral.toml` and no `UMBRAL_DATABASE_URL` in the environment");
eprintln!();
eprintln!(" Set UMBRAL_DATABASE_URL (e.g. sqlite://app.db?mode=rwc) and re-run.");
eprintln!(" If an ephemeral migrate IS what you want (tests, CI), say so:");
eprintln!(" umbral migrate --allow-in-memory");
return Err("refusing to migrate an in-memory database".into());
}
}
if let Some(ref spec) = fake {
let (plugin, name) = parse_migration_spec(spec)?;
umbral::migrate::fake_apply(plugin, name).await?;
println!("Marked {spec} as applied (no SQL executed)");
return Ok(());
}
if !allow_destructive {
let unsafe_ops: Vec<_> = umbral::migrate::check_pending_safety()
.await?
.into_iter()
.filter(|c| c.safety.is_unsafe())
.collect();
if !unsafe_ops.is_empty() {
eprintln!(
"error: umbral migrate: {} pending destructive operation(s) would DESTROY DATA:",
unsafe_ops.len()
);
for c in &unsafe_ops {
eprintln!(
" [UNSAFE] {}/{}: {}",
c.plugin,
c.migration,
c.safety.reason()
);
}
eprintln!();
eprintln!(
" These usually come from an unregistered model/plugin (a removed \
`.model::<T>()`, a dropped plugin, or a feature flag off).\n \
If the drop is intended, re-run: `umbral migrate --allow-destructive`.\n \
If NOT, restore the model registration and re-run `makemigrations`."
);
return Err(format!(
"refusing to apply {} destructive migration operation(s) without --allow-destructive",
unsafe_ops.len()
)
.into());
}
}
if fake_initial {
let n = umbral::migrate::fake_initial().await?;
if n == 0 {
println!("No plugins needed fake-initial (either already applied or tables absent)");
} else {
println!("Fake-applied initial migration for {n} plugin(s)");
}
return Ok(());
}
match umbral::migrate::run_checked(allow_drift).await {
Ok(n) => {
if n == 0 {
println!("No pending migrations");
} else {
println!("Applied {n} migration(s)");
}
Ok(())
}
Err(MigrateError::DriftDetected { ref missing }) => {
let names: Vec<String> = missing.iter().map(|(p, n)| format!("{p}/{n}")).collect();
eprintln!("error: umbral migrate: drift detected");
eprintln!(" The following migrations are in the tracking table but missing on disk:");
for name in &names {
eprintln!(" [!] {name}");
}
eprintln!();
eprintln!(
" Options:\n \
1. Restore the file(s) from VCS.\n \
2. Run `umbral migrate --allow-drift` to proceed and apply pending migrations.\n \
3. Run `umbral migrate --fake <plugin/name>` to mark an individual migration \
as applied without running SQL."
);
Err(Box::new(MigrateError::DriftDetected {
missing: missing.clone(),
}))
}
Err(err) => Err(Box::new(err)),
}
}
fn parse_migration_spec(
spec: &str,
) -> Result<(&str, &str), Box<dyn std::error::Error + Send + Sync>> {
let mut parts = spec.splitn(2, '/');
let plugin = parts.next().ok_or("migration spec must be `plugin/name`")?;
let name = parts
.next()
.ok_or("migration spec must be `plugin/name`; missing name after `/`")?;
Ok((plugin, name))
}
async fn showmigrations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pending = umbral::migrate::show().await?;
if pending > 0 {
println!("\n{pending} migration(s) not yet applied.");
}
Ok(())
}
fn typegen(
out: Option<PathBuf>,
check: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let generated = umbral::typegen::typescript();
let Some(path) = out else {
print!("{generated}");
return Ok(());
};
if check {
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing == generated {
println!("{} is up to date.", path.display());
return Ok(());
}
return Err(format!(
"{} is out of date with the models. Regenerate it:\n \
cargo run -- typegen --out {}",
path.display(),
path.display(),
)
.into());
}
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &generated)?;
println!("Wrote {}.", path.display());
Ok(())
}
async fn checkmigrations(strict: bool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ops = umbral::migrate::check_pending_safety().await?;
if ops.is_empty() {
println!("No pending migrations — nothing to check.");
return Ok(());
}
let unsafe_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_unsafe()).collect();
let warn_ops: Vec<_> = ops.iter().filter(|c| c.safety.is_warning()).collect();
let safe_count = ops.len() - unsafe_ops.len() - warn_ops.len();
let migrations: std::collections::BTreeSet<_> =
ops.iter().map(|c| (&c.plugin, &c.migration)).collect();
println!(
"Checking {} operation(s) across {} pending migration(s)...\n",
ops.len(),
migrations.len()
);
if !unsafe_ops.is_empty() {
println!("UNSAFE ({}):", unsafe_ops.len());
for c in &unsafe_ops {
println!(
" [{}] {}/{} — {}",
op_kind(&c.op),
c.plugin,
c.migration,
c.safety.reason()
);
}
println!();
}
if !warn_ops.is_empty() {
println!("WARNING ({}):", warn_ops.len());
for c in &warn_ops {
println!(
" [{}] {}/{} — {}",
op_kind(&c.op),
c.plugin,
c.migration,
c.safety.reason()
);
}
println!();
}
println!(
"Summary: {} safe, {} warning, {} unsafe.",
safe_count,
warn_ops.len(),
unsafe_ops.len()
);
let blocked = !unsafe_ops.is_empty() || (strict && !warn_ops.is_empty());
if blocked {
let why = if !unsafe_ops.is_empty() {
format!("{} unsafe operation(s) found", unsafe_ops.len())
} else {
format!("{} warning(s) found (--strict)", warn_ops.len())
};
return Err(format!(
"checkmigrations: {why}. Review the expand-contract notes above before deploying."
)
.into());
}
println!("\nAll pending operations are safe for a rolling deploy.");
Ok(())
}
fn op_kind(op: &umbral::migrate::Operation) -> &'static str {
use umbral::migrate::Operation;
match op {
Operation::CreateTable { .. } => "CREATE TABLE",
Operation::DropTable { .. } => "DROP TABLE",
Operation::CreateView {
materialized: true, ..
} => "CREATE MATVIEW",
Operation::CreateView { .. } => "CREATE VIEW",
Operation::DropView {
materialized: true, ..
} => "DROP MATVIEW",
Operation::DropView { .. } => "DROP VIEW",
Operation::AddColumn { .. } => "ADD COL",
Operation::DropColumn { .. } => "DROP COL",
Operation::AlterColumn { .. } => "ALTER COL",
Operation::RenameTable { .. } => "RENAME TABLE",
Operation::RenameColumn { .. } => "RENAME COL",
Operation::SetColumnComment { .. } => "COMMENT COL",
Operation::CreateM2MTable { .. } => "CREATE M2M",
Operation::DropM2MTable { .. } => "DROP M2M",
Operation::RunSql { .. } => "RUN SQL",
Operation::AddIndex { unique: true, .. } => "ADD UNIQUE",
Operation::AddIndex { unique: false, .. } => "ADD INDEX",
Operation::DropIndex { .. } => "DROP INDEX",
}
}
async fn inspectdb(
output: PathBuf,
mark_applied: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let opts = InspectOptions {
output,
mark_applied,
};
match umbral::inspect::inspectdb(opts).await {
Ok(report) => {
println!(
"Inspected {} table(s), {} column(s)",
report.tables, report.columns,
);
println!("Wrote {}", report.models_path.display());
println!("Wrote {}", report.migration_path.display());
Ok(())
}
Err(InspectError::NoTables) => {
println!("no tables found in the database");
Ok(())
}
Err(err) => Err(Box::new(err)),
}
}
async fn dumpdata(output: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
umbral::backup::dump_to_path(&output).await?;
println!("Wrote {}", output.display());
Ok(())
}
async fn loaddata(input: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let report = umbral::backup::load_from_path(&input).await?;
println!(
"Loaded {} row(s) into {} table(s)",
report.rows_loaded,
report.tables_loaded.len()
);
for skipped in &report.skipped_tables {
eprintln!("warning: skipped table `{skipped}` (not in current schema)");
}
Ok(())
}
async fn importcsv(
table: String,
input: PathBuf,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let models = umbral::migrate::registered_models();
let Some(meta) = models.into_iter().find(|m| m.table == table) else {
let mut known: Vec<String> = umbral::migrate::registered_models()
.iter()
.map(|m| m.table.clone())
.collect();
known.sort();
return Err(format!(
"importcsv: unknown table `{table}`. Registered tables: {}",
known.join(", ")
)
.into());
};
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.from_path(&input)?;
let headers: Vec<String> = reader.headers()?.iter().map(|s| s.to_string()).collect();
if headers.is_empty() {
return Err("importcsv: the CSV has no header row".into());
}
let mut rows: Vec<Vec<String>> = Vec::new();
for record in reader.records() {
let record = record?;
rows.push(record.iter().map(|s| s.to_string()).collect());
}
let report = umbral::orm::import_table_rows(&meta, &headers, &rows).await;
println!(
"Imported {} row(s) into `{}` ({} failed)",
report.inserted,
table,
report.errors.len()
);
for (line, message) in &report.errors {
eprintln!(" line {line}: {message}");
}
if report.errors.is_empty() {
Ok(())
} else {
Err(format!("importcsv: {} row(s) failed", report.errors.len()).into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use clap::ArgMatches;
use umbral::Settings;
use umbral_core::cli::{CliError, PluginCommand};
use umbral_core::plugin::Plugin;
#[test]
fn forward_args_prefix_cargo_run_dashdash() {
assert_eq!(
cargo_run_forward_args(&["dev".to_string()]),
vec!["run", "--", "dev"]
);
assert_eq!(
cargo_run_forward_args(&[
"migrate".to_string(),
"--fake".to_string(),
"accounts/0001_auto".to_string(),
]),
vec!["run", "--", "migrate", "--fake", "accounts/0001_auto"]
);
}
#[test]
fn in_cargo_project_detects_manifest_upward() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
assert!(!in_cargo_project(root));
std::fs::write(root.join("Cargo.toml"), b"[package]\nname='x'\n").unwrap();
let nested = root.join("src").join("widgets");
std::fs::create_dir_all(&nested).unwrap();
assert!(in_cargo_project(&nested), "walks up to find the manifest");
assert!(in_cargo_project(root));
}
struct WorkerCmd;
#[async_trait]
impl PluginCommand for WorkerCmd {
fn command(&self) -> clap::Command {
clap::Command::new("tasks-worker").about("Run the task worker")
}
async fn run(&self, _m: &ArgMatches) -> Result<(), CliError> {
Ok(())
}
}
struct WorkerPlugin;
impl Plugin for WorkerPlugin {
fn name(&self) -> &'static str {
"tasks"
}
fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
vec![Box::new(WorkerCmd)]
}
}
async fn app_with_worker() -> App {
let settings = Settings::from_env().expect("figment defaults load");
let pool = umbral::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite connects");
App::builder()
.settings(settings)
.database("default", pool)
.plugin(WorkerPlugin)
.build()
.expect("App builds")
}
#[test]
fn wants_top_level_help_recognizes_help_forms() {
let os = |s: &str| std::ffi::OsString::from(s);
assert!(wants_top_level_help(&[os("umbral"), os("help")]));
assert!(wants_top_level_help(&[os("umbral"), os("--help")]));
assert!(wants_top_level_help(&[os("umbral"), os("-h")]));
assert!(!wants_top_level_help(&[os("umbral")]));
assert!(!wants_top_level_help(&[
os("umbral"),
os("migrate"),
os("--help")
]));
assert!(!wants_top_level_help(&[os("umbral"), os("migrate")]));
}
#[test]
fn unknown_token_picks_first_non_flag() {
let os = |s: &str| std::ffi::OsString::from(s);
assert_eq!(
unknown_token(&[os("umbral"), os("--verbose"), os("frobnicate")]).as_deref(),
Some("frobnicate")
);
assert_eq!(unknown_token(&[os("umbral")]), None);
}
#[tokio::test]
async fn help_and_unknown_list_builtins_and_plugin_commands() {
let app = app_with_worker().await;
let out = render_full_help(&app);
assert!(
out.contains("migrate"),
"built-in `migrate` missing:\n{out}"
);
assert!(
out.contains("Apply every pending migration"),
"built-in `migrate` about missing:\n{out}"
);
assert!(
out.contains("tasks-worker") && out.contains("Run the task worker"),
"plugin command missing:\n{out}"
);
let mig_line = out
.lines()
.find(|l| l.trim_start().starts_with("migrate"))
.unwrap();
let worker_line = out.lines().find(|l| l.contains("tasks-worker")).unwrap();
let mig_col = mig_line.find("Apply every pending migration").unwrap();
let worker_col = worker_line.find("Run the task worker").unwrap();
assert_eq!(mig_col, worker_col, "descriptions not aligned:\n{out}");
let out = render_unknown(&app, Some("frobnicate"));
assert!(
out.contains("unknown command") && out.contains("frobnicate"),
"missing unknown-command error:\n{out}"
);
assert!(out.contains("migrate"), "listing missing built-in:\n{out}");
assert!(
out.contains("tasks-worker"),
"listing missing plugin cmd:\n{out}"
);
}
}