use std::ffi::OsString;
use std::path::PathBuf;
use clap::{Arg, ArgAction, ArgMatches, Command};
#[derive(Debug, Clone)]
pub enum Subcommand {
New {
name: String,
dest: Option<PathBuf>,
stack: Stack,
database: Database,
},
Version,
Serve {
bind: Option<String>,
port: Option<u16>,
},
Migrate { dsn: Option<String> },
Schedule { dsn: Option<String> },
Make { kind: MakeKind, name: String },
#[cfg(feature = "auth")]
KeyGenerate { show: bool },
StorageLink,
Db {
action: DbAction,
dsn: Option<String>,
force: bool,
},
#[cfg(all(feature = "database", feature = "jobs"))]
Queue {
action: QueueAction,
dsn: Option<String>,
},
#[cfg(feature = "database")]
Doctor,
Dev {
port: Option<u16>,
host: Option<String>,
open: bool,
},
#[cfg(feature = "uag")]
Routes { json: bool },
#[cfg(feature = "uag")]
Typegen,
#[cfg(feature = "uag")]
Build,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Stack {
#[default]
React,
Vue,
Svelte,
}
impl Stack {
pub const ALL: &'static [Self] = &[Self::React, Self::Vue, Self::Svelte];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::React => "react",
Self::Vue => "vue",
Self::Svelte => "svelte",
}
}
fn parse(value: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|s| s.as_str() == value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Database {
#[default]
Postgres,
Sqlite,
Mysql,
}
impl Database {
pub const ALL: &'static [Self] = &[Self::Postgres, Self::Sqlite, Self::Mysql];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Postgres => "postgres",
Self::Sqlite => "sqlite",
Self::Mysql => "mysql",
}
}
#[must_use]
pub fn feature(self) -> &'static str {
match self {
Self::Postgres => "db-postgres",
Self::Sqlite => "db-sqlite",
Self::Mysql => "db-mysql",
}
}
fn parse(value: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|d| d.as_str() == value)
}
}
#[cfg(all(feature = "database", feature = "jobs"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueueAction {
Work,
Drain,
Stats,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DbAction {
Seed,
Fresh,
Reset,
}
impl DbAction {
#[must_use]
pub fn app_flag(self) -> &'static str {
match self {
Self::Seed => "--db-seed",
Self::Fresh => "--db-fresh",
Self::Reset => "--db-reset",
}
}
#[must_use]
pub fn is_destructive(self) -> bool {
matches!(self, Self::Fresh | Self::Reset)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Seed => "db:seed",
Self::Fresh => "db:fresh",
Self::Reset => "db:reset",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MakeKind {
Module,
Controller,
Model,
Migration,
Request,
Resource,
Policy,
Service,
Job,
Event,
Listener,
Middleware,
Command,
Page,
Test,
Factory,
Seeder,
Notification,
Mail,
View,
Upload,
Auth,
}
impl MakeKind {
pub const ALL: &'static [Self] = &[
Self::Module,
Self::Controller,
Self::Model,
Self::Migration,
Self::Request,
Self::Resource,
Self::Policy,
Self::Service,
Self::Job,
Self::Event,
Self::Listener,
Self::Middleware,
Self::Command,
Self::Page,
Self::Test,
Self::Factory,
Self::Seeder,
Self::Notification,
Self::Mail,
Self::View,
Self::Upload,
Self::Auth,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Module => "module",
Self::Controller => "controller",
Self::Model => "model",
Self::Migration => "migration",
Self::Request => "request",
Self::Resource => "resource",
Self::Policy => "policy",
Self::Service => "service",
Self::Job => "job",
Self::Event => "event",
Self::Listener => "listener",
Self::Middleware => "middleware",
Self::Command => "command",
Self::Page => "page",
Self::Test => "test",
Self::Factory => "factory",
Self::Seeder => "seeder",
Self::Notification => "notification",
Self::Mail => "mail",
Self::View => "view",
Self::Upload => "upload",
Self::Auth => "auth",
}
}
#[must_use]
pub fn subcommand_name(self) -> &'static str {
match self {
Self::Module => "make:module",
Self::Controller => "make:controller",
Self::Model => "make:model",
Self::Migration => "make:migration",
Self::Request => "make:request",
Self::Resource => "make:resource",
Self::Policy => "make:policy",
Self::Service => "make:service",
Self::Job => "make:job",
Self::Event => "make:event",
Self::Listener => "make:listener",
Self::Middleware => "make:middleware",
Self::Command => "make:command",
Self::Page => "make:page",
Self::Test => "make:test",
Self::Factory => "make:factory",
Self::Seeder => "make:seeder",
Self::Notification => "make:notification",
Self::Mail => "make:mail",
Self::View => "make:view",
Self::Upload => "make:upload",
Self::Auth => "make:auth",
}
}
#[must_use]
pub fn about(self) -> &'static str {
match self {
Self::Module => "Generate a feature module in app/modules",
Self::Controller => "Generate a controller in app/controllers",
Self::Model => "Generate a model in app/models",
Self::Migration => "Generate a timestamped migration in database/migrations",
Self::Request => "Generate a validated request in app/requests",
Self::Resource => "Generate a response resource in app/resources",
Self::Policy => "Generate an authorization policy in app/policies",
Self::Service => "Generate a service in app/services",
Self::Job => "Generate a background job in app/jobs",
Self::Event => "Generate an event in app/events",
Self::Listener => "Generate an event listener in app/listeners",
Self::Middleware => "Generate a middleware in app/middleware",
Self::Command => "Generate an application command in app/commands",
Self::Page => "Generate an Inertia page props struct in app/pages",
Self::Test => "Generate an integration test in tests",
Self::Factory => "Generate a model factory in database/factories",
Self::Seeder => "Generate a database seeder in database/seeders",
Self::Notification => "Generate a notification in app/notifications",
Self::Mail => "Generate a mailable in app/mail",
Self::View => "Generate a view in app/views and its template",
Self::Upload => "Generate an upload controller in app/controllers",
Self::Auth => "Generate a sign-in flow in app/auth and its migration",
}
}
fn from_subcommand_name(name: &str) -> Option<Self> {
let kind = name.strip_prefix("make:")?;
Self::ALL.iter().copied().find(|k| k.as_str() == kind)
}
}
#[must_use]
pub fn command() -> Command {
let mut cmd = Command::new("arc")
.about("Arcature: an opinionated full-stack Rust web framework")
.disable_version_flag(true)
.arg_required_else_help(true)
.arg(
Arg::new("version")
.short('V')
.long("version")
.action(ArgAction::SetTrue)
.help("Print the framework version"),
)
.subcommand(new_subcommand())
.subcommand(Command::new("version").about("Print the framework version"))
.subcommand(
Command::new("serve")
.about("Run the current application")
.arg(
Arg::new("bind")
.long("bind")
.value_name("ADDR")
.help("Address to bind (forwarded as ARCATURE_BACKEND_BIND)"),
)
.arg(
Arg::new("port")
.long("port")
.value_name("PORT")
.value_parser(clap::value_parser!(u16))
.help("Port to bind (forwarded as ARCATURE_BACKEND_PORT)"),
),
)
.subcommand(
Command::new("migrate")
.about("Run pending migrations")
.arg(dsn_arg()),
)
.subcommand(
Command::new("schedule")
.about("Run the job scheduler")
.arg(dsn_arg()),
)
.subcommand(
Command::new("storage:link").about("Link storage/app/public into public/storage"),
)
.subcommand(db_subcommand(
DbAction::Seed,
"Run the application's seeders",
))
.subcommand(db_subcommand(
DbAction::Fresh,
"Drop every table, re-migrate, then seed (destructive)",
))
.subcommand(db_subcommand(
DbAction::Reset,
"Roll every migration back (destructive)",
))
.subcommand(
Command::new("dev")
.about("Run the development server: one port, Vite and the app behind it")
.arg(
Arg::new("port")
.long("port")
.value_name("PORT")
.value_parser(clap::value_parser!(u16))
.help("The one TCP port to serve on (default 3000)"),
)
.arg(
Arg::new("host")
.long("host")
.value_name("ADDR")
.help("Address to bind (default 127.0.0.1)"),
)
.arg(
Arg::new("open")
.long("open")
.action(ArgAction::SetTrue)
.help("Open a browser once the first build is serving"),
),
);
#[cfg(feature = "uag")]
{
cmd = cmd
.subcommand(
Command::new("routes")
.about("List every route the application declares")
.arg(
Arg::new("json")
.long("json")
.action(ArgAction::SetTrue)
.help("Emit the route list as JSON instead of a table"),
),
)
.subcommand(Command::new("typegen").about("Emit TypeScript from the application graph"))
.subcommand(
Command::new("build").about("Build for production: typegen, cargo, then Vite"),
);
}
for kind in MakeKind::ALL {
cmd = cmd.subcommand(
Command::new(kind.subcommand_name())
.about(kind.about())
.arg(
Arg::new("name")
.required(true)
.help("The artifact name (e.g. `user`, `User`, `users/show`)"),
),
);
}
#[cfg(feature = "auth")]
{
cmd = cmd.subcommand(
Command::new("key:generate")
.about("Generate a 64-byte application key")
.arg(
Arg::new("show")
.long("show")
.action(ArgAction::SetTrue)
.help("Print the key instead of writing it to .env"),
),
);
}
#[cfg(all(feature = "database", feature = "jobs"))]
{
cmd = cmd.subcommand(
Command::new("queue")
.about("Operate on the job queue")
.arg(
Arg::new("action")
.required(true)
.value_parser(["work", "drain", "stats"])
.help("The queue action"),
)
.arg(dsn_arg()),
);
}
#[cfg(feature = "database")]
{
cmd = cmd.subcommand(Command::new("doctor").about("Diagnose the local environment"));
}
cmd
}
fn new_subcommand() -> Command {
let stacks: Vec<&'static str> = Stack::ALL.iter().map(|s| s.as_str()).collect();
let drivers: Vec<&'static str> = Database::ALL.iter().map(|d| d.as_str()).collect();
Command::new("new")
.about("Generate a new application")
.arg(Arg::new("name").required(true).help("The project name"))
.arg(
Arg::new("dest")
.long("dest")
.value_name("PATH")
.value_parser(clap::value_parser!(PathBuf))
.help("Where to write the project (defaults to ./<name>)"),
)
.arg(
Arg::new("stack")
.long("stack")
.value_name("STACK")
.value_parser(stacks)
.default_value(Stack::default().as_str())
.help("The frontend stack"),
)
.arg(
Arg::new("db")
.long("db")
.value_name("DRIVER")
.value_parser(drivers)
.default_value(Database::default().as_str())
.help("The database driver"),
)
}
fn dsn_arg() -> Arg {
Arg::new("dsn")
.long("dsn")
.value_name("URL")
.help("Database URL (defaults to DATABASE_URL)")
}
fn db_subcommand(action: DbAction, about: &'static str) -> Command {
Command::new(action.as_str())
.about(about)
.arg(dsn_arg())
.arg(
Arg::new("force")
.long("force")
.action(ArgAction::SetTrue)
.help("Confirm a destructive operation (never prompts)"),
)
}
pub fn parse(args: &[OsString]) -> Result<Subcommand, clap::Error> {
let matches = command().try_get_matches_from(args)?;
from_matches(&matches)
}
fn from_matches(matches: &ArgMatches) -> Result<Subcommand, clap::Error> {
if matches.get_flag("version") {
return Ok(Subcommand::Version);
}
let Some((name, sub)) = matches.subcommand() else {
return Err(command().error(
clap::error::ErrorKind::MissingSubcommand,
"a subcommand is required",
));
};
if let Some(kind) = MakeKind::from_subcommand_name(name) {
return Ok(Subcommand::Make {
kind,
name: string_of(sub, "name"),
});
}
Ok(match name {
"new" => Subcommand::New {
name: string_of(sub, "name"),
dest: sub.get_one::<PathBuf>("dest").cloned(),
stack: Stack::parse(&string_of(sub, "stack")).unwrap_or_default(),
database: Database::parse(&string_of(sub, "db")).unwrap_or_default(),
},
"version" => Subcommand::Version,
"serve" => Subcommand::Serve {
bind: sub.get_one::<String>("bind").cloned(),
port: sub.get_one::<u16>("port").copied(),
},
"dev" => Subcommand::Dev {
port: sub.get_one::<u16>("port").copied(),
host: sub.get_one::<String>("host").cloned(),
open: sub.get_flag("open"),
},
#[cfg(feature = "uag")]
"routes" => Subcommand::Routes {
json: sub.get_flag("json"),
},
#[cfg(feature = "uag")]
"typegen" => Subcommand::Typegen,
#[cfg(feature = "uag")]
"build" => Subcommand::Build,
"migrate" => Subcommand::Migrate { dsn: dsn_of(sub) },
"schedule" => Subcommand::Schedule { dsn: dsn_of(sub) },
"storage:link" => Subcommand::StorageLink,
"db:seed" => db_of(DbAction::Seed, sub),
"db:fresh" => db_of(DbAction::Fresh, sub),
"db:reset" => db_of(DbAction::Reset, sub),
#[cfg(feature = "auth")]
"key:generate" => Subcommand::KeyGenerate {
show: sub.get_flag("show"),
},
#[cfg(all(feature = "database", feature = "jobs"))]
"queue" => Subcommand::Queue {
action: match string_of(sub, "action").as_str() {
"work" => QueueAction::Work,
"drain" => QueueAction::Drain,
_ => QueueAction::Stats,
},
dsn: dsn_of(sub),
},
#[cfg(feature = "database")]
"doctor" => Subcommand::Doctor,
other => {
return Err(command().error(
clap::error::ErrorKind::InvalidSubcommand,
format!("unhandled subcommand: {other}"),
));
}
})
}
fn string_of(matches: &ArgMatches, id: &str) -> String {
matches
.get_one::<String>(id)
.cloned()
.unwrap_or_else(|| unreachable!("`{id}` is required or defaulted in `command()`"))
}
fn dsn_of(matches: &ArgMatches) -> Option<String> {
matches.get_one::<String>("dsn").cloned()
}
fn db_of(action: DbAction, matches: &ArgMatches) -> Subcommand {
Subcommand::Db {
action,
dsn: dsn_of(matches),
force: matches.get_flag("force"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(argv: &[&str]) -> Vec<OsString> {
std::iter::once("arc")
.chain(argv.iter().copied())
.map(OsString::from)
.collect()
}
#[test]
fn the_command_surface_is_internally_consistent() {
command().debug_assert();
}
#[test]
fn a_bare_version_flag_reports_the_framework_version() {
assert!(matches!(
parse(&args(&["--version"])).expect("parses"),
Subcommand::Version
));
assert!(matches!(
parse(&args(&["-V"])).expect("parses"),
Subcommand::Version
));
assert!(matches!(
parse(&args(&["version"])).expect("parses"),
Subcommand::Version
));
}
#[test]
fn new_defaults_to_the_certified_stack_and_driver() {
let Subcommand::New {
name,
dest,
stack,
database,
} = parse(&args(&["new", "blog"])).expect("parses")
else {
panic!("expected `new`");
};
assert_eq!(name, "blog");
assert_eq!(dest, None);
assert_eq!(stack, Stack::React);
assert_eq!(database, Database::Postgres);
}
#[test]
fn new_accepts_an_explicit_stack_and_driver() {
let Subcommand::New {
stack, database, ..
} = parse(&args(&[
"new", "blog", "--stack", "svelte", "--db", "sqlite",
]))
.expect("parses")
else {
panic!("expected `new`");
};
assert_eq!(stack, Stack::Svelte);
assert_eq!(database, Database::Sqlite);
}
#[test]
fn new_rejects_a_stack_the_framework_does_not_ship() {
assert!(parse(&args(&["new", "blog", "--stack", "angular"])).is_err());
}
#[test]
fn serve_rejects_a_port_outside_the_u16_range() {
assert!(parse(&args(&["serve", "--port", "70000"])).is_err());
let Subcommand::Serve { port, bind } =
parse(&args(&["serve", "--port", "8080", "--bind", "0.0.0.0"])).expect("parses")
else {
panic!("expected `serve`");
};
assert_eq!(port, Some(8080));
assert_eq!(bind.as_deref(), Some("0.0.0.0"));
}
#[test]
fn every_make_kind_has_a_subcommand() {
for kind in MakeKind::ALL {
let parsed = parse(&args(&[kind.subcommand_name(), "widget"])).expect("parses");
let Subcommand::Make { kind: got, name } = parsed else {
panic!("expected `make`");
};
assert_eq!(got, *kind);
assert_eq!(name, "widget");
}
}
#[test]
fn a_make_command_without_a_name_is_a_parse_error() {
assert!(parse(&args(&["make:controller"])).is_err());
}
#[test]
fn the_destructive_db_commands_carry_their_force_flag() {
let Subcommand::Db { action, force, .. } =
parse(&args(&["db:fresh", "--force"])).expect("parses")
else {
panic!("expected `db`");
};
assert_eq!(action, DbAction::Fresh);
assert!(force);
assert!(action.is_destructive());
let Subcommand::Db { action, force, .. } = parse(&args(&["db:seed"])).expect("parses")
else {
panic!("expected `db`");
};
assert_eq!(action, DbAction::Seed);
assert!(!force);
assert!(!action.is_destructive());
}
#[test]
fn dev_runs_on_one_port_that_defaults_rather_than_being_asked_for() {
let Subcommand::Dev { port, host, open } = parse(&args(&["dev"])).expect("parses") else {
panic!("expected `dev`");
};
assert_eq!(port, None, "the default belongs to the command, not clap");
assert_eq!(host, None);
assert!(!open);
}
#[test]
fn dev_takes_the_port_the_developer_names() {
let Subcommand::Dev { port, host, open } = parse(&args(&[
"dev", "--port", "4173", "--host", "0.0.0.0", "--open",
]))
.expect("parses") else {
panic!("expected `dev`");
};
assert_eq!(port, Some(4173));
assert_eq!(host.as_deref(), Some("0.0.0.0"));
assert!(open);
}
#[cfg(feature = "uag")]
#[test]
fn routes_prints_a_table_unless_json_is_asked_for() {
let Subcommand::Routes { json } = parse(&args(&["routes"])).expect("parses") else {
panic!("expected `routes`");
};
assert!(!json, "the human table is the default");
let Subcommand::Routes { json } = parse(&args(&["routes", "--json"])).expect("parses")
else {
panic!("expected `routes`");
};
assert!(json);
}
#[cfg(feature = "uag")]
#[test]
fn typegen_and_build_take_no_arguments() {
assert!(matches!(
parse(&args(&["typegen"])).expect("parses"),
Subcommand::Typegen
));
assert!(matches!(
parse(&args(&["build"])).expect("parses"),
Subcommand::Build
));
}
#[cfg(all(feature = "database", feature = "jobs"))]
#[test]
fn queue_takes_its_action_in_either_position() {
for argv in [
vec!["queue", "drain", "--dsn", "postgres://x"],
vec!["queue", "--dsn", "postgres://x", "drain"],
] {
let Subcommand::Queue { action, dsn } = parse(&args(&argv)).expect("parses") else {
panic!("expected `queue`");
};
assert_eq!(action, QueueAction::Drain);
assert_eq!(dsn.as_deref(), Some("postgres://x"));
}
}
#[cfg(all(feature = "database", feature = "jobs"))]
#[test]
fn queue_rejects_an_action_it_does_not_have() {
assert!(parse(&args(&["queue", "explode"])).is_err());
}
#[test]
fn help_is_reported_as_a_stdout_render_not_a_failure() {
let error = parse(&args(&["--help"])).expect_err("help short-circuits");
assert!(!error.use_stderr());
}
#[test]
fn an_unknown_subcommand_is_a_stderr_failure() {
let error = parse(&args(&["nope"])).expect_err("unknown subcommand");
assert!(error.use_stderr());
}
}