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")));
}
}