use std::process::ExitCode;
use clap::{Parser, Subcommand};
mod ai;
mod app_fields;
mod audit;
mod builder;
mod docs;
mod doctor;
mod doctor_email;
mod emergency_ui;
mod group;
mod memory;
mod migrate;
mod perm;
mod progress;
mod proposal;
mod reload;
mod scaffold;
mod style;
mod template_override;
mod test_init;
mod theme;
mod ui;
mod user;
mod wizard;
const WELCOME_HELP: &str = "\
RustIO Admin — describe your data as Rust structs, get a complete admin panel.
You write the structs; RustIO builds the screens: list, create, edit, search,
and delete — with a login page, user roles, password recovery, and a full
audit trail already wired in. The Rust answer to \"Django Admin\", on PostgreSQL.
Why RustIO is different
That login, those roles, and that audit trail are not add-ons you bolt on
later — they come built in, as one system. The admin you get on the very
first run is already secure and accountable, with nothing to assemble
yourself. Most tools leave that part to you.
Start here
rustio-admin new <project> guided setup → a ready-to-run app
cd <project> && cargo run then sign in at http://127.0.0.1:8000/admin
Helpful
rustio-admin doctor is my environment ready?
rustio-admin docs where the documentation lives
Every command is listed below.
────────────────────────────────────────────────────────────\
";
#[derive(Parser)]
#[command(
name = "rustio-admin",
version,
about = "The rustio-admin command-line tool.",
before_help = WELCOME_HELP,
)]
struct Cli {
#[arg(long, global = true)]
quiet: bool,
#[arg(long, global = true)]
no_progress: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
New {
name: Option<String>,
#[arg(long, default_value = "minimal")]
preset: String,
#[arg(long)]
no_interactive: bool,
#[arg(long, hide = true)]
builder: bool,
},
#[command(name = "startproject")]
Startproject {
name: String,
#[arg(long, default_value = "minimal")]
preset: String,
},
#[command(name = "startapp")]
Startapp {
name: String,
#[arg(long = "field", value_name = "NAME:TYPE")]
fields: Vec<String>,
#[arg(long)]
no_interactive: bool,
},
Migrate {
#[command(subcommand)]
action: migrate::Action,
},
User {
#[command(subcommand)]
action: user::Action,
},
Group {
#[command(subcommand)]
action: group::Action,
},
Perm {
#[command(subcommand)]
action: perm::Action,
},
Audit {
#[command(subcommand)]
action: audit::Action,
},
Doctor {
#[command(subcommand)]
action: Option<DoctorAction>,
},
Docs {
#[arg(long)]
open: bool,
},
Theme {
#[command(subcommand)]
action: theme::Action,
},
Ai {
#[command(subcommand)]
action: ai::Action,
},
Memory {
#[command(subcommand)]
action: memory::Action,
},
#[command(name = "override")]
Override {
name: Option<String>,
#[arg(long)]
force: bool,
#[arg(long, default_value = "templates")]
out: String,
},
Reload,
#[command(name = "test-init")]
TestInit {
#[arg(long)]
force: bool,
#[arg(long, default_value = "tests")]
out: String,
},
Builder {
#[command(subcommand)]
action: BuilderAction,
},
Add {
#[command(subcommand)]
action: BuilderAddAction,
},
Plan,
Commit {
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum BuilderAction {
New {
name: String,
},
}
#[derive(Subcommand)]
enum BuilderAddAction {
Model {
name: String,
},
Field {
model: String,
name: String,
#[arg(name = "type")]
type_name: String,
#[arg(long)]
unique: bool,
},
}
#[derive(Subcommand)]
enum DoctorAction {
Email {
#[arg(long)]
to: Option<String>,
#[arg(long)]
html_preview: bool,
},
}
fn main() -> ExitCode {
let _ = dotenvy::dotenv();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {
if let Some(rewritten) = rewrite_clap_invalid_value(&e) {
eprintln!("{}", rewritten.format());
return ExitCode::from(2);
}
e.exit();
}
};
progress::configure(cli.quiet, cli.no_progress);
let result = match cli.command {
Command::New {
name,
preset,
no_interactive,
builder,
} => dispatch_new(name, preset, no_interactive, builder),
Command::Startproject { name, preset } => scaffold::project(&name, &preset),
Command::Startapp {
name,
fields,
no_interactive,
} => scaffold::app(&name, fields, no_interactive),
Command::Builder { action } => match action {
BuilderAction::New { name } => builder_new(&name),
},
Command::Add { action } => builder_add(action),
Command::Plan => builder_plan(),
Command::Commit { force } => builder_commit(force),
Command::Docs { open } => docs::print_docs(open),
Command::Override { name, force, out } => template_override::run(name, force, &out),
Command::Reload => reload::run(),
Command::TestInit { force, out } => test_init::run(force, &out),
Command::Theme { action } => theme::run(action),
Command::Ai { action } => ai::run(action),
Command::Memory { action } => memory::run(action),
other => tokio_run(async {
match other {
Command::New { .. }
| Command::Startproject { .. }
| Command::Startapp { .. }
| Command::Builder { .. }
| Command::Add { .. }
| Command::Plan
| Command::Commit { .. }
| Command::Docs { .. }
| Command::Override { .. }
| Command::Reload
| Command::TestInit { .. }
| Command::Theme { .. }
| Command::Ai { .. }
| Command::Memory { .. } => unreachable!("handled above"),
Command::Migrate { action } => migrate::run(action).await,
Command::User { action } => user::run(action).await,
Command::Group { action } => group::run(action).await,
Command::Perm { action } => perm::run(action).await,
Command::Audit { action } => audit::run(action).await,
Command::Doctor { action } => match action {
None => doctor::run().await,
Some(DoctorAction::Email { to, html_preview }) => {
doctor_email::run(to, html_preview).await
}
},
}
}),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
if e.starts_with(ui::ONBOARDING_SENTINEL) {
eprintln!("{e}");
} else {
eprintln!("error: {e}");
}
ExitCode::FAILURE
}
}
}
fn rewrite_clap_invalid_value(e: &clap::Error) -> Option<ui::OnboardingError> {
use clap::error::{ContextKind, ContextValue, ErrorKind};
if e.kind() != ErrorKind::InvalidValue {
return None;
}
let arg = match e.get(ContextKind::InvalidArg)? {
ContextValue::String(s) => s.clone(),
_ => return None,
};
let bad = match e.get(ContextKind::InvalidValue)? {
ContextValue::String(s) => s.clone(),
_ => return None,
};
let valid: Vec<String> = match e.get(ContextKind::ValidValue)? {
ContextValue::Strings(v) => v.clone(),
_ => return None,
};
Some(ui::invalid_value(&arg, &bad, &valid))
}
fn builder_new(name: &str) -> Result<(), String> {
let summary = builder::cmd::run_new(name)?;
println!("{summary}");
Ok(())
}
fn dispatch_new(
name: Option<String>,
preset: String,
no_interactive: bool,
builder: bool,
) -> Result<(), String> {
if builder {
let name =
name.ok_or_else(|| "`rustio-admin new --builder <name>` requires a name".to_string())?;
eprintln!("note: `rustio-admin new --builder` is the legacy Builder entrypoint.");
eprintln!(" Prefer `rustio-admin builder new <name>` going forward.");
eprintln!();
return builder_new(&name);
}
if wizard::should_run(no_interactive) {
let input = wizard::run(name.as_deref())?;
let resolved_preset = match input.project_type.as_str() {
"blog" => "blog",
"clinic" => "clinic",
_ => "minimal",
};
return scaffold::project_with_db(
&input.project_name,
resolved_preset,
&input.db_name,
&input.project_type,
);
}
let name = name.ok_or_else(|| {
"project name is required in non-interactive mode (try `rustio-admin new <name>`)"
.to_string()
})?;
scaffold::project(&name, &preset)
}
fn builder_add(action: BuilderAddAction) -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
let msg = match action {
BuilderAddAction::Model { name } => builder::cmd::run_add_model(&cwd, &name)?,
BuilderAddAction::Field {
model,
name,
type_name,
unique,
} => builder::cmd::run_add_field(&cwd, &model, &name, &type_name, unique)?,
};
println!("{msg}");
Ok(())
}
fn builder_plan() -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
let out = builder::cmd::run_plan(&cwd)?;
print!("{out}");
Ok(())
}
fn builder_commit(force: bool) -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
let out = builder::cmd::run_commit(&cwd, force)?;
print!("{out}");
Ok(())
}
fn tokio_run<F>(fut: F) -> Result<(), String>
where
F: std::future::Future<Output = Result<(), String>>,
{
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| format!("tokio runtime: {e}"))?
.block_on(fut)
}
pub(crate) async fn db() -> Result<rustio_admin::Db, String> {
let url = std::env::var("DATABASE_URL").map_err(|_| ui::database_url_missing().format())?;
let step = progress::Step::start("Connecting to PostgreSQL");
match rustio_admin::Db::connect(&url).await {
Ok(db) => {
step.clear();
Ok(db)
}
Err(e) => {
step.clear();
Err(ui::classify_db_connect_error(&redact_password(&url), &e.to_string()).format())
}
}
}
fn redact_password(url: &str) -> String {
if let Some(at) = url.rfind('@') {
if let Some(scheme_end) = url.find("://") {
let prefix = &url[..scheme_end + 3];
let creds_and_after = &url[scheme_end + 3..];
let creds_end = at - (scheme_end + 3);
let creds = &creds_and_after[..creds_end];
let after = &creds_and_after[creds_end..];
if let Some((user, _)) = creds.split_once(':') {
return format!("{prefix}{user}:***{after}");
}
}
}
url.to_string()
}
#[cfg(test)]
mod tests {
use super::redact_password;
#[test]
fn redact_strips_password() {
assert_eq!(
redact_password("postgres://postgres:secret@localhost/db"),
"postgres://postgres:***@localhost/db"
);
}
#[test]
fn redact_passthrough_when_no_password() {
assert_eq!(
redact_password("postgres://localhost/db"),
"postgres://localhost/db"
);
}
}