fundaia 0.9.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use serde::Serialize;

use crate::api::client::ApiError;

/// 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)
    }
}

/// 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 || wants_json() {
        Format::Json
    } else {
        Format::Human
    }
}

fn wants_json() -> bool {
    std::env::var("FUNDAIA_JSON").is_ok_and(|value| truthy(&value))
}

/// `=1` turns it on; `=0` and an empty value do not — the same reading every
/// other switch in this tool gives the environment.
fn truthy(value: &str) -> bool {
    let value = value.trim();
    !value.is_empty() && value != "0"
}

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

/// 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_for` keys on it.
pub fn emit_error(error: &anyhow::Error, hint: Option<&str>) {
    let mut body = serde_json::Map::new();
    body.insert("message".into(), error.to_string().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(), "cli_error".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());
    }

    eprintln!(
        "{}",
        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(""));
    }
}