arcature-cli 2026.0.0

Developer lifecycle CLI for Arcature applications.
Documentation
use std::ffi::OsString;
use std::path::PathBuf;

use clap::{Arg, ArgAction, Command, value_parser};

use super::{CliCommand, DbCommand, FrontendArg, 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(("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)),
    }
}

/// Parse the `arc db` nested subcommand tree.
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(
            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())
}

/// Build the `arc db` nested subcommand tree (Phase 4 spec §14).
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),
                ),
        )
}

/// Build a destructive `arc db` subcommand that requires `--force` (Phase 4
/// spec §23: the flag IS the explicit confirmation).
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_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() {
        // Without --force, the command parses but force is false.
        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)));
    }
}