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;
#[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,
},
Showmigrations,
Checkmigrations {
#[arg(long, default_value_t = false)]
strict: 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,
}
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
}
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(());
}
if !app.plugins().is_empty() {
match umbral_core::cli::dispatch(app.plugins(), 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,
} => migrate(fake, fake_initial, allow_drift).await,
Command::Showmigrations => showmigrations().await,
Command::Checkmigrations { strict } => checkmigrations(strict).await,
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(),
}
}
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!("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()),
));
}
catalog.extend(umbral_core::cli::command_catalog(app.plugins()));
catalog
}
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>> {
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,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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 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(())
}
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::AddColumn { .. } => "ADD COL",
Operation::DropColumn { .. } => "DROP COL",
Operation::AlterColumn { .. } => "ALTER COL",
Operation::RenameTable { .. } => "RENAME TABLE",
Operation::RenameColumn { .. } => "RENAME COL",
Operation::CreateM2MTable { .. } => "CREATE M2M",
Operation::DropM2MTable { .. } => "DROP M2M",
Operation::RunSql { .. } => "RUN SQL",
}
}
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;
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}"
);
}
}