arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Dispatch for the `arc db` command family (Phase 4 spec §14).
//!
//! Destructive commands (`fresh`/`reset`/`refresh`) require `--force` — the
//! flag IS the explicit confirmation (Phase 4 spec §23: no interactive
//! prompt, no hostname heuristic). The check happens here, before any
//! expensive work runs (§19).

use crate::cli::DbCommand;
use crate::error::{CommandError, DbCommandError};

pub(crate) fn execute(command: DbCommand) -> Result<(), CommandError> {
    match command {
        DbCommand::Migrate => super::migrate::run_migrate(),
        DbCommand::Rollback { steps } => super::migrate::run_rollback(steps),
        DbCommand::Status => super::status::run_status(),
        DbCommand::Fresh { force } => {
            if !force {
                return Err(CommandError::Db(DbCommandError::DestructiveRequiresForce(
                    "fresh",
                )));
            }
            super::migrate::run_fresh()
        }
        DbCommand::Reset { force } => {
            if !force {
                return Err(CommandError::Db(DbCommandError::DestructiveRequiresForce(
                    "reset",
                )));
            }
            super::migrate::run_reset()
        }
        DbCommand::Refresh { force } => {
            if !force {
                return Err(CommandError::Db(DbCommandError::DestructiveRequiresForce(
                    "refresh",
                )));
            }
            super::migrate::run_refresh()
        }
        DbCommand::Prepare => super::prepare::run_prepare(),
        DbCommand::PrepareCheck => super::prepare::run_prepare_check(),
        DbCommand::Lint { format } => super::lint::run_lint(format),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Destructive commands refuse to run without `--force`. The check happens
    /// before any expensive work — before `DATABASE_URL` is read or a
    /// connection is made (Phase 4 spec §19, §23, §82).
    #[test]
    fn fresh_without_force_is_rejected() {
        let result = execute(DbCommand::Fresh { force: false });
        assert!(
            matches!(
                result,
                Err(CommandError::Db(DbCommandError::DestructiveRequiresForce(
                    "fresh"
                )))
            ),
            "fresh without --force must be rejected"
        );
    }

    #[test]
    fn reset_without_force_is_rejected() {
        let result = execute(DbCommand::Reset { force: false });
        assert!(
            matches!(
                result,
                Err(CommandError::Db(DbCommandError::DestructiveRequiresForce(
                    "reset"
                )))
            ),
            "reset without --force must be rejected"
        );
    }

    #[test]
    fn refresh_without_force_is_rejected() {
        let result = execute(DbCommand::Refresh { force: false });
        assert!(
            matches!(
                result,
                Err(CommandError::Db(DbCommandError::DestructiveRequiresForce(
                    "refresh"
                )))
            ),
            "refresh without --force must be rejected"
        );
    }

    /// The destructive-command error message must not contain the database URL
    /// or credentials — it is a static, command-name-only message.
    #[test]
    fn destructive_error_message_does_not_leak_credentials() {
        let result = execute(DbCommand::Fresh { force: false });
        let error = result.expect_err("should error");
        let message = format!("{error}");
        assert!(!message.contains("DATABASE_URL"));
        assert!(!message.contains("password"));
        assert!(!message.contains("postgres://"));
    }
}