fundaia 0.53.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
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());
    }
}