use std::io::Write;
use serde::Serialize;
use crate::api::client::ApiError;
use crate::config::switched_on;
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 answered_in_json<T: Serialize>(self, value: &T) -> bool {
if self.is_json() {
emit(value);
}
self.is_json()
}
}
pub fn chosen(flag: bool) -> Format {
if flag || switched_on("FUNDAIA_JSON") {
Format::Json
} else {
Format::Human
}
}
pub fn emit<T: Serialize>(value: &T) {
println!(
"{}",
serde_json::to_string(value).expect("the DTOs serialise to JSON by construction")
);
}
pub struct Lines {
out: std::io::BufWriter<std::io::StdoutLock<'static>>,
}
impl Lines {
pub fn lock() -> Self {
Self {
out: std::io::BufWriter::with_capacity(64 * 1024, std::io::stdout().lock()),
}
}
pub fn line(&mut self, text: impl std::fmt::Display) {
written(writeln!(self.out, "{text}"));
}
pub fn json<T: Serialize>(&mut self, value: &T) {
match serde_json::to_writer(&mut self.out, value) {
Err(error) if error.is_io() => written(Err(error.into())),
outcome => outcome.expect("the DTOs serialise to JSON by construction"),
}
written(self.out.write_all(b"\n"));
}
pub fn finish(mut self) {
written(self.out.flush());
}
}
fn written(outcome: std::io::Result<()>) {
if let Err(error) = outcome {
panic!("failed printing to stdout: {error}");
}
}
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());
}
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());
}
}