fundaia 0.55.3

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use crate::api::client::ApiError;

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Exit {
    Ok = 0,
    Internal = 1,
    NotFound = 3,
    Invalid = 4,
    Unauthorized = 5,
    Forbidden = 6,
    Conflict = 7,
    RateLimited = 8,
    Upstream = 9,
    Unreachable = 10,
    DeploymentFailed = 11,
}

impl Exit {
    pub fn retryable(self) -> bool {
        matches!(
            self,
            Exit::RateLimited | Exit::Upstream | Exit::Unreachable | Exit::Internal
        )
    }

    pub fn code(self) -> i32 {
        self as i32
    }

    pub fn name(self) -> &'static str {
        match self {
            Exit::Ok => "ok",
            Exit::Internal => "internal_error",
            Exit::NotFound => "not_found",
            Exit::Invalid => "validation_failed",
            Exit::Unauthorized => "unauthorized",
            Exit::Forbidden => "forbidden",
            Exit::Conflict => "conflict",
            Exit::RateLimited => "rate_limited",
            Exit::Upstream => "external_service_failed",
            Exit::Unreachable => "unreachable",
            Exit::DeploymentFailed => "deployment_failed",
        }
    }

    pub fn meaning(self) -> &'static str {
        match self {
            Exit::Ok => "It worked.",
            Exit::Internal => {
                "A bug here, or a platform that broke rather than refused. Read the deployment or the activity log before assuming nothing happened: a call can fail after it has written."
            }
            Exit::NotFound => "Nothing goes by that name — or nothing this token can reach.",
            Exit::Invalid => "Understood and refused. The identical call is refused identically.",
            Exit::Unauthorized => "No token, or one the platform no longer knows.",
            Exit::Forbidden => "A real token without reach into that project.",
            Exit::Conflict => {
                "Something already has that name, or a deployment is already running on that service."
            }
            Exit::RateLimited => "Too many calls in a row. Nothing about the call was wrong.",
            Exit::Upstream => {
                "Something the platform does not control misbehaved: GitHub, a registry, the machine."
            }
            Exit::Unreachable => "The instance was never reached: no DNS, no route, no answer.",
            Exit::DeploymentFailed => {
                "The command worked and the deployment it started did not. The pipeline says which stage stopped."
            }
        }
    }

    pub fn all() -> [Exit; 11] {
        [
            Exit::Ok,
            Exit::Internal,
            Exit::NotFound,
            Exit::Invalid,
            Exit::Unauthorized,
            Exit::Forbidden,
            Exit::Conflict,
            Exit::RateLimited,
            Exit::Upstream,
            Exit::Unreachable,
            Exit::DeploymentFailed,
        ]
    }
}

#[derive(Debug)]
pub struct Refused {
    message: String,
    exit: Exit,
}

impl Refused {
    pub fn not_signed_in(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            exit: Exit::Unauthorized,
        }
    }

    pub fn not_found(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            exit: Exit::NotFound,
        }
    }

    pub fn invalid(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            exit: Exit::Invalid,
        }
    }

    pub fn upstream(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            exit: Exit::Upstream,
        }
    }
}

impl std::fmt::Display for Refused {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for Refused {}

#[derive(Debug)]
pub struct DeploymentFailed(pub String);

impl std::fmt::Display for DeploymentFailed {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl std::error::Error for DeploymentFailed {}

pub fn classify(error: &anyhow::Error) -> Exit {
    if let Some(refused) = error.downcast_ref::<Refused>() {
        return refused.exit;
    }

    if error.downcast_ref::<DeploymentFailed>().is_some() {
        return Exit::DeploymentFailed;
    }

    if let Some(api) = error.downcast_ref::<ApiError>() {
        return match api.code.as_str() {
            "not_found" => Exit::NotFound,
            "validation_failed" => Exit::Invalid,
            "unauthorized" => Exit::Unauthorized,
            "forbidden" => Exit::Forbidden,
            "conflict" | "deploy_frozen" => Exit::Conflict,
            "rate_limited" => Exit::RateLimited,
            "external_service_failed" => Exit::Upstream,
            _ => Exit::Internal,
        };
    }

    if unreachable(error) {
        return Exit::Unreachable;
    }

    Exit::Internal
}

fn unreachable(error: &anyhow::Error) -> bool {
    error.chain().any(|cause| {
        cause
            .downcast_ref::<reqwest::Error>()
            .is_some_and(|transport| {
                transport.is_connect() || transport.is_timeout() || transport.is_request()
            })
    })
}

pub fn hint(exit: Exit) -> Option<&'static str> {
    match exit {
        Exit::NotFound => Some(
            "Ese nombre no existe aquí: `fundaia status` dice qué hay. Puede ser de otra cuenta.",
        ),
        Exit::Invalid => Some("Cambia la llamada: repetirla igual se rechaza igual."),
        Exit::Unauthorized => Some("Ejecuta `fundaia login` para volver a entrar."),
        Exit::Forbidden => {
            Some("El token es válido pero no alcanza ese proyecto: pídelo a quien lo comparte.")
        }
        Exit::Conflict => Some(
            "Ya hay algo con ese nombre, hay un despliegue en curso o el entorno está congelado (--force).",
        ),
        Exit::RateLimited => Some("Demasiadas peticiones seguidas: espera un minuto y repite."),
        Exit::Upstream => {
            Some("Ha fallado algo de fuera (GitHub, un registro, la máquina): repite una vez.")
        }
        Exit::Unreachable => {
            Some("No se ha llegado a la instancia: comprueba `fundaia whoami` y la red.")
        }
        Exit::DeploymentFailed => {
            Some("El despliegue ha fallado: `fundaia logs --step` en el paso que paró.")
        }
        Exit::Internal | Exit::Ok => None,
    }
}

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

    fn api(code: &str, status: StatusCode) -> anyhow::Error {
        ApiError {
            status,
            code: code.to_owned(),
            message: "whatever the server said".to_owned(),
        }
        .into()
    }

    #[test]
    fn it_should_reserve_two_for_the_arguments_clap_refuses() {
        assert!(!Exit::all().iter().any(|exit| exit.code() == 2));
    }

    #[test]
    fn it_should_give_every_class_a_number_of_its_own() {
        let codes: Vec<i32> = Exit::all().iter().map(|exit| exit.code()).collect();
        assert_eq!(
            codes.iter().collect::<std::collections::HashSet<_>>().len(),
            codes.len()
        );
    }

    #[test]
    fn it_should_name_every_class_it_publishes() {
        let unnamed: Vec<Exit> = Exit::all()
            .into_iter()
            .filter(|exit| exit.name().is_empty())
            .collect();
        assert_eq!(unnamed, Vec::<Exit>::new());
    }

    #[test]
    fn it_should_exit_three_when_there_is_nothing_by_that_name() {
        assert_eq!(classify(&api("not_found", StatusCode::NOT_FOUND)).code(), 3);
    }

    #[test]
    fn it_should_exit_five_when_the_token_is_not_known() {
        assert_eq!(
            classify(&api("unauthorized", StatusCode::UNAUTHORIZED)).code(),
            5
        );
    }

    #[test]
    fn it_should_exit_six_when_the_token_does_not_reach_that_project() {
        assert_eq!(classify(&api("forbidden", StatusCode::FORBIDDEN)).code(), 6);
    }

    #[test]
    fn it_should_exit_seven_when_something_already_has_the_name() {
        assert_eq!(classify(&api("conflict", StatusCode::CONFLICT)).code(), 7);
    }

    #[test]
    fn it_should_exit_seven_when_the_environment_is_frozen() {
        assert_eq!(
            classify(&api("deploy_frozen", StatusCode::LOCKED)).code(),
            7
        );
    }

    #[test]
    fn it_should_fall_back_to_one_for_a_code_it_does_not_know() {
        assert_eq!(
            classify(&api("something_invented_later", StatusCode::IM_A_TEAPOT)).code(),
            1
        );
    }

    #[test]
    fn it_should_exit_five_when_this_machine_has_no_credential() {
        let refused: anyhow::Error = Refused::not_signed_in("run `fundaia login`").into();
        assert_eq!(classify(&refused).code(), 5);
    }

    #[test]
    fn it_should_exit_four_when_the_arguments_are_a_combination_it_refuses() {
        let refused: anyhow::Error = Refused::invalid("pass --token as well").into();
        assert_eq!(classify(&refused).code(), 4);
    }

    #[test]
    fn it_should_exit_three_when_this_binary_finds_nothing_by_that_number() {
        let refused: anyhow::Error = Refused::not_found("no deployment #9").into();
        assert_eq!(classify(&refused).code(), 3);
    }

    #[test]
    fn it_should_exit_eleven_when_the_deployment_itself_failed() {
        let failed: anyhow::Error = DeploymentFailed("exit 137".to_owned()).into();
        assert_eq!(classify(&failed).code(), 11);
    }

    #[test]
    fn it_should_not_invite_a_retry_of_a_failed_deployment() {
        assert!(!Exit::DeploymentFailed.retryable());
    }

    #[test]
    fn it_should_invite_a_retry_after_a_rate_limit() {
        assert!(Exit::RateLimited.retryable());
    }

    #[test]
    fn it_should_not_invite_a_retry_of_a_refusal() {
        assert!(!Exit::Invalid.retryable());
    }

    #[test]
    fn it_should_advise_on_every_failure_it_can_name() {
        let unadvised: Vec<Exit> = Exit::all()
            .into_iter()
            .filter(|exit| !matches!(exit, Exit::Ok | Exit::Internal))
            .filter(|exit| hint(*exit).is_none())
            .collect();

        assert_eq!(unadvised, Vec::<Exit>::new());
    }

    #[test]
    fn it_should_name_the_command_that_fixes_a_missing_token() {
        assert!(hint(Exit::Unauthorized).is_some_and(|advice| advice.contains("fundaia login")));
    }

    #[test]
    fn it_should_name_the_flag_that_narrows_a_log_as_the_parser_spells_it() {
        assert!(hint(Exit::DeploymentFailed).is_some_and(|advice| advice.contains("--step")));
    }
}