use std::ffi::OsString;
use std::path::PathBuf;
use clap::{Arg, ArgAction, Command, value_parser};
use super::{CliCommand, DbCommand, FrontendArg, MakeOptions, NewOptions, OutputFormat};
pub(crate) fn parse<I, T>(arguments: I) -> Result<CliCommand, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let matches = command().try_get_matches_from(arguments)?;
match matches.subcommand() {
Some(("new", values)) => {
let destination = values
.get_one::<PathBuf>("destination")
.cloned()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::MissingRequiredArgument))?;
let frontend = match values.get_one::<String>("frontend").map(String::as_str) {
Some("vue") => FrontendArg::Vue,
_ => FrontendArg::React,
};
let no_db = values.get_flag("no-db");
Ok(CliCommand::New(NewOptions {
destination,
frontend,
no_db,
}))
}
Some(("dev", _)) => Ok(CliCommand::Dev),
Some(("build", _)) => Ok(CliCommand::Build),
Some(("check", values)) => Ok(CliCommand::Check(output_format(values))),
Some(("doctor", values)) => Ok(CliCommand::Doctor(output_format(values))),
Some(("exposure", values)) => Ok(CliCommand::Exposure(output_format(values))),
Some(("routes", values)) => Ok(CliCommand::Routes(output_format(values))),
Some(("modules", values)) => Ok(CliCommand::Modules(output_format(values))),
Some(("services", values)) => Ok(CliCommand::Services(output_format(values))),
Some(("schedule", values)) => Ok(CliCommand::Schedule(output_format(values))),
Some(("run", values)) => {
let name = values
.get_one::<String>("name")
.cloned()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::MissingRequiredArgument))?;
let arguments = values
.get_many::<String>("arguments")
.map(|items| items.cloned().collect())
.unwrap_or_default();
Ok(CliCommand::Run { name, arguments })
}
Some(("make", values)) => {
let kind = values
.get_one::<String>("kind")
.cloned()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::MissingRequiredArgument))?;
let name = values
.get_one::<String>("name")
.cloned()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::MissingRequiredArgument))?;
let module = values.get_one::<String>("module").cloned();
Ok(CliCommand::Make(MakeOptions { kind, name, module }))
}
Some(("s", values)) => {
let name = values
.get_one::<String>("name")
.cloned()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::MissingRequiredArgument))?;
let arguments = values
.get_many::<String>("arguments")
.map(|items| items.cloned().collect())
.unwrap_or_default();
Ok(CliCommand::Script { name, arguments })
}
Some(("db", sub)) => parse_db(sub),
_ => Err(clap::Error::new(clap::error::ErrorKind::MissingSubcommand)),
}
}
fn parse_db(sub: &clap::ArgMatches) -> Result<CliCommand, clap::Error> {
match sub.subcommand() {
Some(("migrate", _)) => Ok(CliCommand::Db(DbCommand::Migrate)),
Some(("rollback", values)) => {
let steps = values.get_one::<u32>("steps").copied();
Ok(CliCommand::Db(DbCommand::Rollback { steps }))
}
Some(("status", _)) => Ok(CliCommand::Db(DbCommand::Status)),
Some(("fresh", values)) => Ok(CliCommand::Db(DbCommand::Fresh {
force: values.get_flag("force"),
})),
Some(("reset", values)) => Ok(CliCommand::Db(DbCommand::Reset {
force: values.get_flag("force"),
})),
Some(("refresh", values)) => Ok(CliCommand::Db(DbCommand::Refresh {
force: values.get_flag("force"),
})),
Some(("prepare", values)) => {
if values.get_flag("check") {
Ok(CliCommand::Db(DbCommand::PrepareCheck))
} else {
Ok(CliCommand::Db(DbCommand::Prepare))
}
}
_ => Err(clap::Error::new(clap::error::ErrorKind::MissingSubcommand)),
}
}
fn command() -> Command {
Command::new("arc")
.version(env!("CARGO_PKG_VERSION"))
.about("Develop and operate Arcature applications")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(
Command::new("new")
.about("Create an Arcature application")
.arg(
Arg::new("destination")
.required(true)
.help("Directory to create; its final component becomes the project name")
.value_parser(value_parser!(PathBuf)),
)
.arg(
Arg::new("frontend")
.long("frontend")
.value_name("react|vue")
.help("Frontend framework")
.default_value("react")
.value_parser(["react", "vue"]),
)
.arg(
Arg::new("no-db")
.long("no-db")
.help("Generate a project without the database subsystem")
.action(ArgAction::SetTrue),
),
)
.subcommand(Command::new("dev").about("Run the full development environment"))
.subcommand(Command::new("build").about("Build frontend and backend for production"))
.subcommand(report_command("check", "Check project health"))
.subcommand(report_command(
"doctor",
"Inspect the local Arcature environment",
))
.subcommand(report_command(
"exposure",
"List browser-exposed page contracts and lint secret-bearing fields",
))
.subcommand(report_command(
"routes",
"List all application routes (side-effect-free, no boot)",
))
.subcommand(report_command(
"modules",
"List application modules and their bindings (side-effect-free, no boot)",
))
.subcommand(report_command(
"services",
"List application services and their dependencies (side-effect-free, no boot)",
))
.subcommand(report_command(
"schedule",
"List scheduled jobs and their cadence (side-effect-free, no boot)",
))
.subcommand(
Command::new("run")
.about("Run a compiled application command by name")
.arg(
Arg::new("name")
.required(true)
.help("Command name (e.g. users:prune)"),
)
.arg(
Arg::new("arguments")
.help("Arguments forwarded to the command")
.num_args(0..)
.last(true)
.action(ArgAction::Append),
),
)
.subcommand(
Command::new("make")
.about("Generate a source file for the given kind")
.arg(
Arg::new("kind")
.required(true)
.help("Generator kind: module, controller, request, service, policy, middleware, event, listener, job, command, test, resource"),
)
.arg(
Arg::new("name")
.required(true)
.help("Name of the item to generate (e.g. Links, send_welcome)"),
)
.arg(
Arg::new("module")
.long("module")
.value_name("module")
.help("Module directory to place the file in (e.g. links)"),
),
)
.subcommand(
Command::new("s")
.about("Run an application-owned script")
.arg(
Arg::new("name")
.required(true)
.help("Script name from the project's s.script file"),
)
.arg(
Arg::new("arguments")
.help("Arguments forwarded as distinct argv values")
.num_args(0..)
.last(true)
.action(ArgAction::Append),
),
)
.subcommand(db_command())
}
fn db_command() -> Command {
Command::new("db")
.about("Database lifecycle: migrations, status, and schema preparation")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(Command::new("migrate").about("Apply pending database migrations"))
.subcommand(
Command::new("rollback")
.about("Roll back applied database migrations")
.arg(
Arg::new("steps")
.long("steps")
.help("Number of migrations to roll back (default: all)")
.value_parser(value_parser!(u32)),
),
)
.subcommand(Command::new("status").about("Show migration status"))
.subcommand(destructive_command(
"fresh",
"Drop all tables and reapply all migrations",
))
.subcommand(destructive_command("reset", "Roll back all migrations"))
.subcommand(destructive_command(
"refresh",
"Roll back all, then reapply all migrations",
))
.subcommand(
Command::new("prepare")
.about("Generate SQLx offline metadata (cargo sqlx prepare --workspace)")
.arg(
Arg::new("check")
.long("check")
.help("Check offline metadata is current instead of generating")
.action(ArgAction::SetTrue),
),
)
}
fn destructive_command(name: &'static str, about: &'static str) -> Command {
Command::new(name).about(about).arg(
Arg::new("force")
.long("force")
.help("Confirm the destructive operation (required)")
.action(ArgAction::SetTrue),
)
}
fn report_command(name: &'static str, about: &'static str) -> Command {
Command::new(name).about(about).arg(
Arg::new("json")
.long("json")
.help("Emit stable machine-readable JSON")
.action(ArgAction::SetTrue),
)
}
fn output_format(matches: &clap::ArgMatches) -> OutputFormat {
if matches.get_flag("json") {
OutputFormat::Json
} else {
OutputFormat::Human
}
}
#[cfg(test)]
mod tests {
use clap::error::ErrorKind;
use super::parse;
use crate::cli::{CliCommand, DbCommand, FrontendArg, OutputFormat};
#[test]
fn parses_default_and_explicit_frontends() {
let react = parse(["arc", "new", "demo"]).expect("React command should parse");
let vue =
parse(["arc", "new", "demo", "--frontend", "vue"]).expect("Vue command should parse");
assert!(
matches!(react, CliCommand::New(options) if options.frontend == FrontendArg::React)
);
assert!(matches!(vue, CliCommand::New(options) if options.frontend == FrontendArg::Vue));
}
#[test]
fn parses_no_db_flag() {
let no_db = parse(["arc", "new", "demo", "--no-db"]).expect("no-db should parse");
assert!(matches!(no_db, CliCommand::New(options) if options.no_db));
let default = parse(["arc", "new", "demo"]).expect("default should parse");
assert!(matches!(default, CliCommand::New(options) if !options.no_db));
}
#[test]
fn rejects_unknown_frontend_and_command() {
let frontend = parse(["arc", "new", "demo", "--frontend", "svelte"])
.expect_err("unsupported frontend should fail");
let command = parse(["arc", "deploy"]).expect_err("unknown command should fail");
assert_eq!(frontend.kind(), ErrorKind::InvalidValue);
assert_eq!(command.kind(), ErrorKind::InvalidSubcommand);
}
#[test]
fn reports_help_version_and_missing_arguments() {
assert_eq!(
parse(["arc", "--help"]).expect_err("help exits").kind(),
ErrorKind::DisplayHelp
);
assert_eq!(
parse(["arc", "--version"])
.expect_err("version exits")
.kind(),
ErrorKind::DisplayVersion
);
assert_eq!(
parse(["arc", "new"])
.expect_err("destination is required")
.kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn preserves_forwarded_script_arguments() {
let parsed = parse([
"arc",
"s",
"user:add",
"--",
"alice@example.com",
";touch /tmp/no",
])
.expect("script command should parse");
assert!(
matches!(parsed, CliCommand::Script { name, arguments } if name == "user:add" && arguments == ["alice@example.com", ";touch /tmp/no"])
);
}
#[test]
fn parses_machine_readable_reports() {
assert_eq!(
parse(["arc", "doctor", "--json"]).expect("doctor should parse"),
CliCommand::Doctor(OutputFormat::Json)
);
assert_eq!(
parse(["arc", "check"]).expect("check should parse"),
CliCommand::Check(OutputFormat::Human)
);
}
#[test]
fn parses_exposure_command() {
let human = parse(["arc", "exposure"]).expect("exposure should parse");
let json = parse(["arc", "exposure", "--json"]).expect("exposure --json should parse");
assert_eq!(human, CliCommand::Exposure(OutputFormat::Human));
assert_eq!(json, CliCommand::Exposure(OutputFormat::Json));
}
#[test]
fn parses_inspection_reports() {
assert_eq!(
parse(["arc", "routes"]).expect("routes should parse"),
CliCommand::Routes(OutputFormat::Human)
);
assert_eq!(
parse(["arc", "routes", "--json"]).expect("routes --json should parse"),
CliCommand::Routes(OutputFormat::Json)
);
assert_eq!(
parse(["arc", "modules"]).expect("modules should parse"),
CliCommand::Modules(OutputFormat::Human)
);
assert_eq!(
parse(["arc", "services", "--json"]).expect("services --json should parse"),
CliCommand::Services(OutputFormat::Json)
);
assert_eq!(
parse(["arc", "schedule"]).expect("schedule should parse"),
CliCommand::Schedule(OutputFormat::Human)
);
}
#[test]
fn parses_run_command_with_arguments() {
let parsed = parse(["arc", "run", "users:prune"]).expect("run should parse");
assert!(
matches!(parsed, CliCommand::Run { name, arguments } if name == "users:prune" && arguments.is_empty())
);
let parsed = parse(["arc", "run", "db:cleanup", "--", "--dry-run", "30d"])
.expect("run with args should parse");
assert!(
matches!(parsed, CliCommand::Run { name, arguments } if name == "db:cleanup" && arguments == ["--dry-run", "30d"])
);
}
#[test]
fn parses_make_command_with_and_without_module() {
let parsed = parse(["arc", "make", "controller", "Sessions"]).expect("make should parse");
assert!(
matches!(parsed, CliCommand::Make(opts) if opts.kind == "controller" && opts.name == "Sessions" && opts.module.is_none())
);
let parsed = parse(["arc", "make", "request", "Login", "--module", "accounts"])
.expect("make with module should parse");
assert!(
matches!(parsed, CliCommand::Make(opts) if opts.kind == "request" && opts.name == "Login" && opts.module.as_deref() == Some("accounts"))
);
}
#[test]
fn run_requires_name() {
let err = parse(["arc", "run"]).expect_err("run needs a name");
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}
#[test]
fn make_requires_kind_and_name() {
let err = parse(["arc", "make"]).expect_err("make needs kind + name");
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}
#[test]
fn parses_db_subcommands() {
assert_eq!(
parse(["arc", "db", "migrate"]).expect("migrate should parse"),
CliCommand::Db(DbCommand::Migrate),
);
assert_eq!(
parse(["arc", "db", "status"]).expect("status should parse"),
CliCommand::Db(DbCommand::Status),
);
assert_eq!(
parse(["arc", "db", "rollback"]).expect("rollback default should parse"),
CliCommand::Db(DbCommand::Rollback { steps: None }),
);
assert_eq!(
parse(["arc", "db", "rollback", "--steps", "3"]).expect("rollback steps should parse"),
CliCommand::Db(DbCommand::Rollback { steps: Some(3) }),
);
}
#[test]
fn parses_db_destructive_commands_require_force() {
let fresh = parse(["arc", "db", "fresh"]).expect("fresh should parse");
assert!(matches!(
fresh,
CliCommand::Db(DbCommand::Fresh { force: false })
));
let fresh_forced =
parse(["arc", "db", "fresh", "--force"]).expect("fresh --force should parse");
assert!(matches!(
fresh_forced,
CliCommand::Db(DbCommand::Fresh { force: true })
));
}
#[test]
fn parses_db_prepare_and_prepare_check() {
let prepare = parse(["arc", "db", "prepare"]).expect("prepare should parse");
assert!(matches!(prepare, CliCommand::Db(DbCommand::Prepare)));
let check =
parse(["arc", "db", "prepare", "--check"]).expect("prepare --check should parse");
assert!(matches!(check, CliCommand::Db(DbCommand::PrepareCheck)));
}
}