use serde::Serialize;
use crate::api::client::ApiError;
use crate::exit::classify;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Format {
Human,
Json,
}
impl Format {
pub fn is_json(self) -> bool {
matches!(self, Format::Json)
}
}
pub fn chosen(flag: bool) -> Format {
if flag || wants_json() {
Format::Json
} else {
Format::Human
}
}
fn wants_json() -> bool {
std::env::var("FUNDAIA_JSON").is_ok_and(|value| truthy(&value))
}
fn truthy(value: &str) -> bool {
let value = value.trim();
!value.is_empty() && value != "0"
}
pub fn emit<T: Serialize>(value: &T) {
println!(
"{}",
serde_json::to_string(value).expect("the DTOs serialise to JSON by construction")
);
}
pub fn emit_error(error: &anyhow::Error, hint: Option<&str>) {
eprintln!("{}", described(error, hint));
}
fn described(error: &anyhow::Error, hint: Option<&str>) -> serde_json::Value {
let mut body = serde_json::Map::new();
body.insert("message".into(), error.to_string().into());
let exit = classify(error);
body.insert("exit".into(), exit.code().into());
body.insert("retryable".into(), exit.retryable().into());
match error.downcast_ref::<ApiError>() {
Some(api) => {
body.insert("code".into(), api.code.clone().into());
body.insert("status".into(), api.status.as_u16().into());
}
None => {
body.insert("code".into(), exit.name().into());
}
}
let causes: Vec<String> = error
.chain()
.skip(1)
.map(|cause| cause.to_string())
.collect();
if !causes.is_empty() {
body.insert("causes".into(), causes.into());
}
if let Some(hint) = hint {
body.insert("hint".into(), hint.into());
}
serde_json::Value::Object(serde_json::Map::from_iter([(
"error".to_string(),
serde_json::Value::Object(body),
)]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_answer_json_when_the_flag_is_given() {
assert!(chosen(true).is_json());
}
#[test]
fn it_should_be_turned_on_by_a_one() {
assert!(truthy("1"));
}
#[test]
fn it_should_not_be_turned_on_by_a_zero() {
assert!(!truthy("0"));
}
#[test]
fn it_should_not_be_turned_on_by_an_empty_value() {
assert!(!truthy(""));
}
fn refused() -> anyhow::Error {
ApiError {
status: reqwest::StatusCode::CONFLICT,
code: "conflict".to_owned(),
message: "there is a deployment running".to_owned(),
}
.into()
}
#[test]
fn it_should_carry_the_server_s_own_code() {
assert_eq!(described(&refused(), None)["error"]["code"], "conflict");
}
#[test]
fn it_should_carry_the_exit_code_the_process_will_leave() {
assert_eq!(described(&refused(), None)["error"]["exit"], 7);
}
#[test]
fn it_should_say_whether_repeating_the_call_is_worth_anything() {
assert_eq!(described(&refused(), None)["error"]["retryable"], false);
}
#[test]
fn it_should_name_the_class_when_no_server_answered() {
let failed: anyhow::Error = crate::exit::DeploymentFailed("exit 137".to_owned()).into();
assert_eq!(
described(&failed, None)["error"]["code"],
"deployment_failed"
);
}
#[test]
fn it_should_leave_the_hint_out_when_there_is_nothing_to_advise() {
assert!(described(&anyhow::anyhow!("a bug"), None)["error"]
.get("hint")
.is_none());
}
}