use std::process::ExitCode;
use clap::{Parser, Subcommand};
mod app_fields;
mod audit;
mod builder;
mod docs;
mod doctor;
mod doctor_email;
mod emergency_ui;
mod group;
mod migrate;
mod perm;
mod progress;
mod reload;
mod scaffold;
mod template_override;
mod test_init;
mod theme;
mod ui;
mod user;
mod wizard;
const WELCOME_HELP: &str = "\
Welcome to RustIO
Start a project:
rustio new <project-name>
You can build:
- clinic systems
- school systems
- inventory systems
- blogs
- or custom projects
Helpful commands:
rustio doctor
rustio docs
────────────────────────────────────────────────────────────\
";
#[derive(Parser)]
#[command(
name = "rustio",
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,
},
#[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),
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 { .. } => 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 new --builder <name>` requires a name".to_string())?;
eprintln!("note: `rustio new --builder` is the legacy Builder entrypoint.");
eprintln!(" Prefer `rustio 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 = if input.project_type == "blog" {
"blog"
} else {
"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 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"
);
}
}