fundaia 0.55.3

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::{FeedDeployment, StatusPageAnswer, StatusPageChange};
use crate::output::Format;
use crate::render::pipeline;
use crate::render::sanitise;
use crate::render::table::{Cell, Table};
use crate::render::theme;
use crate::render::time;

pub fn feed_scope(active: bool, failed: bool) -> &'static str {
    match (active, failed) {
        (true, _) => "active",
        (_, true) => "failed",
        _ => "all",
    }
}

fn duration_word(seconds: Option<u64>) -> String {
    seconds.map_or_else(
        || "—".to_owned(),
        |seconds| time::format_millis((seconds * 1_000) as i64),
    )
}

fn where_of(deployment: &FeedDeployment) -> String {
    if deployment.environment.is_default {
        return format!("{}/{}", deployment.project.slug, deployment.service.name);
    }
    format!(
        "{}/{} · {}",
        deployment.project.slug, deployment.service.name, deployment.environment.slug
    )
}

pub async fn deployments(
    client: &ApiClient,
    active: bool,
    failed: bool,
    limit: usize,
    format: Format,
) -> Result<()> {
    let scope = feed_scope(active, failed);
    let answer = client.deployment_feed(scope, limit).await?;

    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    let mut table = Table::new(&[
        "servicio",
        "estado",
        "etapas",
        "motivo",
        "autor",
        "commit",
        "duración",
        "cuándo",
    ]);
    for deployment in &answer.body.deployments {
        table.push(vec![
            Cell::styled(where_of(deployment), theme::strong()),
            Cell::styled(
                deployment.status.clone(),
                theme::deployment_status_style(&deployment.status),
            ),
            Cell::styled(
                pipeline::track(&deployment.pipeline),
                theme::deployment_status_style(&deployment.status),
            ),
            Cell::styled(deployment.trigger.clone(), theme::muted()),
            Cell::plain(
                deployment
                    .author
                    .as_ref()
                    .map_or_else(|| "—".to_owned(), |author| author.display_name.clone()),
            ),
            Cell::styled(
                deployment
                    .commit_sha
                    .as_deref()
                    .map_or_else(|| "—".to_owned(), |sha| sha.chars().take(7).collect()),
                theme::muted(),
            ),
            Cell::plain(duration_word(deployment.duration_seconds)),
            Cell::styled(time::when(&deployment.created_at), theme::muted()),
        ]);
    }

    let nothing = match scope {
        "active" => "No hay ningún despliegue en curso.",
        "failed" => "Ningún despliegue ha fallado en las últimas 24 horas.",
        _ => "Nada en curso, y nada desplegado en las últimas 24 horas.",
    };
    table.print_or(nothing.style(theme::muted()));
    Ok(())
}

pub struct StatusPageRequest<'a> {
    pub project: &'a str,
    pub switch: Option<bool>,
    pub title: Option<String>,
    pub services: Option<Vec<String>>,
    pub rotate: bool,
}

impl StatusPageRequest<'_> {
    fn change(&self) -> Option<StatusPageChange> {
        let change = StatusPageChange {
            enabled: self.switch,
            title: self.title.clone(),
            services: self.services.clone(),
            rotate_token: self.rotate.then_some(true),
        };
        let asks = change.enabled.is_some()
            || change.title.is_some()
            || change.services.is_some()
            || change.rotate_token.is_some();
        asks.then_some(change)
    }
}

pub fn services_of(list: &str) -> Vec<String> {
    list.split(',')
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(str::to_owned)
        .collect()
}

pub async fn status_page(
    client: &ApiClient,
    request: StatusPageRequest<'_>,
    format: Format,
) -> Result<()> {
    let project = client.find_project(request.project).await?;
    let answer = match request.change() {
        Some(change) => client.set_status_page(&project.id, &change).await?,
        None => client.status_page(&project.id).await?,
    };

    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    print_status_page(&answer.body, &project.slug);
    Ok(())
}

fn health_word(status: &str) -> &str {
    match status {
        "operational" => "operativo",
        "degraded" => "degradado",
        "down" => "caído",
        other => other,
    }
}

fn health_style(status: &str) -> owo_colors::Style {
    match status {
        "operational" => theme::success(),
        "degraded" => theme::warning(),
        _ => theme::danger(),
    }
}

fn uptime_word(percent: Option<f64>) -> String {
    percent.map_or_else(|| "sin datos".to_owned(), |value| format!("{value:.2} %"))
}

fn print_status_page(answer: &StatusPageAnswer, slug: &str) {
    let page = &answer.status_page;
    if !page.enabled {
        println!(
            "  {}",
            format!("{slug} no publica página de estado. fundaia status-page {slug} on")
                .style(theme::muted())
        );
        return;
    }

    println!(
        "  {} {}",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&page.shown_title).style(theme::strong())
    );
    if let Some(url) = &page.url {
        println!("  {}", sanitise::plain(url).style(theme::accent()));
    }

    let Some(view) = &answer.view else {
        return;
    };
    println!(
        "  {} · {} en 30 días",
        health_word(&view.status).style(health_style(&view.status)),
        uptime_word(view.uptime_percent)
    );
    println!();

    let mut table = Table::new(&["servicio", "estado", "disponibilidad"]);
    for service in &view.services {
        table.push(vec![
            Cell::styled(service.name.clone(), theme::strong()),
            Cell::styled(
                health_word(&service.status).to_owned(),
                health_style(&service.status),
            ),
            Cell::plain(uptime_word(service.uptime_percent)),
        ]);
    }
    table.print_or("");
}

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

    #[test]
    fn it_should_ask_for_everything_without_a_flag() {
        assert_eq!(feed_scope(false, false), "all");
    }

    #[test]
    fn it_should_ask_for_failures_with_failed() {
        assert_eq!(feed_scope(false, true), "failed");
    }

    #[test]
    fn it_should_split_a_list_of_services_on_commas() {
        assert_eq!(services_of(" web, api ,,"), vec!["web", "api"]);
    }

    #[test]
    fn it_should_only_read_when_nothing_changes() {
        let request = StatusPageRequest {
            project: "tienda",
            switch: None,
            title: None,
            services: None,
            rotate: false,
        };
        assert!(request.change().is_none());
    }

    #[test]
    fn it_should_say_a_down_service_is_down() {
        assert_eq!(health_word("down"), "caído");
    }
}