fundaia 0.32.1

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;

/// How the answer leaves the process: drawn for a person, or serialised for a
/// program.
///
/// One switch rather than a flag per command, because the reader is a property
/// of the invocation, not of the question — an AI agent asking `status` and an
/// agent asking `history` want the same shape, and a person wants neither.
///
/// JSON is compact, one value per line. A pretty-printed object is for a
/// person, and a person asked for the table; what a machine pays for is every
/// byte it has to read back, so the machine gets the short form.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Format {
    Human,
    Json,
}

impl Format {
    pub fn is_json(self) -> bool {
        matches!(self, Format::Json)
    }

    /// Under `--json`, prints `value` as the whole answer and says so; for a
    /// person it prints nothing and the caller goes on to draw the screen.
    pub fn answered_in_json<T: Serialize>(self, value: &T) -> bool {
        if self.is_json() {
            emit(value);
        }
        self.is_json()
    }
}

/// The flag, or the environment, in that order.
///
/// `FUNDAIA_JSON=1` exists for the same reason `FUNDAIA_TOKEN` does: an agent
/// is configured once with environment variables and then types plain
/// commands, and threading `--json` into every invocation is ceremony it will
/// eventually forget somewhere.
pub fn chosen(flag: bool) -> Format {
    if flag || switched_on("FUNDAIA_JSON") {
        Format::Json
    } else {
        Format::Human
    }
}

/// One value, one line, on stdout.
pub fn emit<T: Serialize>(value: &T) {
    println!(
        "{}",
        serde_json::to_string(value).expect("the DTOs serialise to JSON by construction")
    );
}

/// Stdout locked once and buffered, for output that runs to thousands of lines.
///
/// `println!` takes the lock for every line, and stdout flushes at every
/// newline — one system call per line of a build log. This writes the same
/// bytes in a few large writes. A failed write stops the process with the words
/// `println!` would have used, so a closed pipe behaves as it always did.
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()),
        }
    }

    /// What `println!("{text}")` prints.
    pub fn line(&mut self, text: impl std::fmt::Display) {
        written(writeln!(self.out, "{text}"));
    }

    /// What `emit(value)` prints, without the intermediate string.
    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}");
    }
}

/// A failure as one JSON object on stderr, mirroring the human error block.
///
/// `code` is the server's own error code when there is one, so an agent can
/// branch on `unauthorized` or `conflict` without parsing a sentence written
/// for a person — the same reason `hint` keys on it.
///
/// `exit` and `retryable` are here so the object and the exit code cannot tell
/// different stories about the same failure: the process is about to stop with
/// that number, and an agent that read the JSON should not have to also consult
/// `$?` to learn whether repeating the call is worth anything.
pub fn emit_error(error: &anyhow::Error, hint: Option<&str>) {
    eprintln!("{}", described(error, hint));
}

/// The object, without printing it.
///
/// Split for the reason `Table::rendered` is split from `print`: what is worth
/// asserting is the shape an agent reads, and a test that had to capture stderr
/// to see it would be a test about plumbing.
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());
        }
        // No server answered — a failed deployment, an unreachable host, a bug
        // here — so the class names itself. `unreachable` and
        // `deployment_failed` rather than one `cli_error` covering both: the
        // word and the number then always agree, and every value this field can
        // take is one `fundaia schema` publishes.
        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");
    }

    /// The number the process is about to leave, in the object, so a caller
    /// that read one does not have to consult the other.
    #[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);
    }

    /// With no server to ask, the class names itself: every value this field
    /// takes is one `fundaia schema` publishes, and `cli_error` was not.
    #[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());
    }
}