fundaia 0.7.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use anyhow::Result;
use owo_colors::OwoColorize;

use crate::api::client::ApiClient;
use crate::api::models::InfraEvent;
use crate::render::theme;

/// What the platform is doing, as it does it.
///
/// The terminal half of the notices the web interface shows. A push to a
/// followed branch has always deployed on its own; what nobody had was a way to
/// watch it happen without keeping a browser tab open, which is the wrong tool
/// for somebody who is already in a shell waiting for a build.
///
/// Scoped by name like everything else here. Unscoped it is every project this
/// token can reach, which is the useful default for one person with one
/// instance and far too much for a shared one.
pub async fn run(
    client: &ApiClient,
    project_reference: Option<&str>,
    service_reference: Option<&str>,
) -> Result<()> {
    let project = match project_reference {
        Some(reference) => Some(client.find_project(reference).await?),
        None => None,
    };

    let service = match (&project, service_reference) {
        (Some(project), Some(reference)) => {
            Some(client.find_service(&project.id, reference).await?)
        }
        _ => None,
    };

    let scope = match (&project, &service) {
        (Some(project), Some(service)) => format!("{} · {}", project.name, service.name),
        (Some(project), None) => project.name.clone(),
        _ => "todo".to_string(),
    };

    println!(
        "  {} escuchando {} · Ctrl-C para salir",
        theme::ARROW.style(theme::accent()),
        scope.style(theme::strong()),
    );

    let project_id = project.map(|one| one.id);
    let service_id = service.map(|one| one.id);

    client
        .stream_events(|event| {
            if !in_scope(&event, project_id.as_deref(), service_id.as_deref()) {
                return;
            }
            if let Some(line) = describe(&event) {
                println!("{line}");
            }
        })
        .await
}

fn in_scope(event: &InfraEvent, project_id: Option<&str>, service_id: Option<&str>) -> bool {
    if project_id.is_some_and(|wanted| event.project_id != wanted) {
        return false;
    }

    // An event that belongs to no service — the envelope allows it — is out of
    // scope the moment a service was asked for.
    service_id.is_none_or(|wanted| event.service_environment_id.as_deref() == Some(wanted))
}

/// One line, or nothing when there is nothing worth a line.
fn describe(event: &InfraEvent) -> Option<String> {
    match event.kind.as_str() {
        "deployment-status" => Some(describe_deployment(event)),
        "variable-changed" => Some(format!(
            "  {} {} {}",
            theme::PENDING.style(theme::muted()),
            event
                .key
                .as_deref()
                .unwrap_or("una variable")
                .style(theme::strong()),
            match event.action.as_deref() {
                Some("removed") => "eliminada",
                Some("sealed") => "sellada",
                _ => "actualizada",
            },
        )),
        // The state of a service follows from its deployment, and printing both
        // is the same news twice a second apart.
        _ => None,
    }
}

fn describe_deployment(event: &InfraEvent) -> String {
    let name = event.service_name.as_deref().unwrap_or("un servicio");
    let status = event.status.as_deref().unwrap_or("");
    let (mark, word) = mark_for(status);

    // Coloured by the same table the rest of the tool uses, so a status means
    // the same thing here as it does in `status` and in `history`.
    let symbol = mark.style(theme::state_style(status)).to_string();
    let subject = name.style(theme::strong()).to_string();

    match reason_for(event) {
        Some(because) => format!("  {symbol} {subject} {word} · {because}"),
        None => format!("  {symbol} {subject} {word}"),
    }
}

/// The symbol and the verb for a deployment status.
///
/// Its own words rather than `state_word`: that one names what a *service* is
/// ("dormido"), and these name what just happened to it ("se ha dormido"). An
/// unknown status is passed through rather than replaced, so a newer server
/// saying something this build has never heard of still prints it.
fn mark_for(status: &str) -> (&'static str, &str) {
    match status {
        "queued" => (theme::ARROW, "desplegando"),
        "building" => (theme::PENDING, "construyendo"),
        "deploying" => (theme::PENDING, "arrancando"),
        "active" => (theme::CHECK, "en marcha"),
        "failed" | "crashed" => (theme::CROSS, "ha fallado"),
        "stopped" => (theme::SKIP, "se ha dormido"),
        "cancelled" | "superseded" => (theme::SKIP, "reemplazado"),
        other => (theme::PENDING, other),
    }
}

/// Why this deployment exists, when the answer is not "somebody pressed deploy".
fn reason_for(event: &InfraEvent) -> Option<String> {
    if event.status.as_deref() != Some("queued") {
        return None;
    }

    match event.trigger.as_deref() {
        Some("git-push") => Some(match (&event.commit_subject, &event.commit_sha) {
            (Some(subject), _) => format!("nuevo commit: {subject}"),
            (None, Some(sha)) => format!("nuevo commit {}", &sha[..sha.len().min(7)]),
            _ => "la rama se movió".to_string(),
        }),
        Some("variable-change") => Some("cambiaron las variables".to_string()),
        Some("rollback") => Some("vuelta atrás".to_string()),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn event(kind: &str) -> InfraEvent {
        InfraEvent {
            kind: kind.to_string(),
            project_id: "prj_1".to_string(),
            service_environment_id: Some("sve_1".to_string()),
            deployment_id: Some("dep_1".to_string()),
            status: None,
            state: None,
            trigger: None,
            number: None,
            service_name: Some("api".to_string()),
            commit_sha: None,
            commit_subject: None,
            key: None,
            action: None,
        }
    }

    #[test]
    fn it_should_keep_an_event_from_the_project_being_watched() {
        assert!(in_scope(&event("deployment-status"), Some("prj_1"), None));
    }

    #[test]
    fn it_should_drop_an_event_from_another_project() {
        assert!(!in_scope(&event("deployment-status"), Some("prj_2"), None));
    }

    #[test]
    fn it_should_drop_an_event_from_another_service() {
        assert!(!in_scope(
            &event("deployment-status"),
            Some("prj_1"),
            Some("sve_2")
        ));
    }

    #[test]
    fn it_should_keep_everything_when_nothing_was_asked_for() {
        assert!(in_scope(&event("deployment-status"), None, None));
    }

    #[test]
    fn it_should_say_which_commit_started_a_deployment() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("git-push".to_string());
        one.commit_subject = Some("fix the health check".to_string());

        assert_eq!(
            reason_for(&one).unwrap(),
            "nuevo commit: fix the health check"
        );
    }

    #[test]
    fn it_should_fall_back_to_the_short_sha_when_there_is_no_subject() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("git-push".to_string());
        one.commit_sha = Some("abc1234567890".to_string());

        assert_eq!(reason_for(&one).unwrap(), "nuevo commit abc1234");
    }

    #[test]
    fn it_should_give_no_reason_for_a_deployment_somebody_started_by_hand() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("manual".to_string());

        assert!(reason_for(&one).is_none());
    }

    #[test]
    fn it_should_name_the_variable_that_changed() {
        let mut one = event("variable-changed");
        one.key = Some("DATABASE_URL".to_string());
        one.action = Some("set".to_string());

        assert!(describe(&one).unwrap().contains("DATABASE_URL"));
    }

    #[test]
    fn it_should_print_nothing_for_a_kind_it_does_not_draw() {
        assert!(describe(&event("usage")).is_none());
    }
}