fundaia 0.55.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::time::Duration;

use anyhow::Result;
use owo_colors::OwoColorize;

use crate::api::client::{ApiClient, TaskDraft};
use crate::api::models::{ScheduledTask, TaskRun};
use crate::exit;
use crate::output::{self, Format};
use crate::render::sanitise;
use crate::render::table::{Cell, Table};
use crate::render::theme;
use crate::render::time;

const WAIT_LIMIT: Duration = Duration::from_secs(16 * 60);
const POLL_EVERY: Duration = Duration::from_secs(1);

pub async fn list(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    format: Format,
) -> Result<()> {
    let (project, service) = client.locate(project_reference, service_reference).await?;
    let answer = client.tasks(&service.id).await?;

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

    let mut table = Table::new(&["tarea", "cuándo (UTC)", "próxima", "última", "comando"]);
    for task in &answer.body.tasks {
        table.push(vec![
            Cell::styled(
                sanitise::plain(&task.name),
                if task.enabled {
                    theme::strong()
                } else {
                    theme::muted()
                },
            ),
            Cell::plain(sanitise::plain(&task.schedule)),
            Cell::styled(next_word(task), theme::muted()),
            last_run_cell(task.last_run.as_ref()),
            Cell::styled(sanitise::plain(&task.command), theme::muted()),
        ]);
    }
    table.print_or(format_args!(
        "`{}` no tiene tareas. fundaia tasks add {} {} <nombre> \"0 3 * * *\" -- <comando>",
        service.name, project.slug, service.slug
    ));
    Ok(())
}

pub struct AddRequest<'a> {
    pub project: &'a str,
    pub service: &'a str,
    pub name: &'a str,
    pub schedule: &'a str,
    pub command: &'a [String],
    pub paused: bool,
}

pub async fn add(client: &ApiClient, request: AddRequest<'_>, format: Format) -> Result<()> {
    let (_, service) = client.locate(request.project, request.service).await?;
    let command = request.command.join(" ");
    let answer = client
        .create_task(
            &service.id,
            &TaskDraft {
                name: Some(request.name),
                command: Some(&command),
                schedule: Some(request.schedule),
                enabled: request.paused.then_some(false),
            },
        )
        .await?;

    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }
    let task = &answer.body.task;
    println!(
        "  {} {} programada: {}",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&task.name).style(theme::strong()),
        next_word(task).style(theme::muted())
    );
    Ok(())
}

pub struct EditRequest<'a> {
    pub project: &'a str,
    pub service: &'a str,
    pub task: &'a str,
    pub name: Option<&'a str>,
    pub schedule: Option<&'a str>,
    pub command: &'a [String],
    pub enabled: Option<bool>,
}

pub async fn edit(client: &ApiClient, request: EditRequest<'_>, format: Format) -> Result<()> {
    let (_, service) = client.locate(request.project, request.service).await?;
    let task = find(client, &service.id, request.task).await?;
    let command = (!request.command.is_empty()).then(|| request.command.join(" "));
    let answer = client
        .update_task(
            &task.id,
            &TaskDraft {
                name: request.name,
                command: command.as_deref(),
                schedule: request.schedule,
                enabled: request.enabled,
            },
        )
        .await?;

    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }
    let task = &answer.body.task;
    println!(
        "  {} {} {}",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&task.name).style(theme::strong()),
        next_word(task).style(theme::muted())
    );
    Ok(())
}

pub async fn remove(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    task_reference: &str,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let task = find(client, &service.id, task_reference).await?;
    client.delete_task(&task.id).await?;

    if format.is_json() {
        output::emit(&serde_json::json!({ "deleted": task.id, "name": task.name }));
        return Ok(());
    }
    println!(
        "  {} {} quitada",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&task.name).style(theme::strong())
    );
    Ok(())
}

pub async fn run(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    task_reference: &str,
    wait: bool,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let task = find(client, &service.id, task_reference).await?;
    let started = client.run_task(&task.id).await?;

    let mut answer = started;
    if wait {
        let deadline = std::time::Instant::now() + WAIT_LIMIT;
        while answer.body.run.status == "running" && std::time::Instant::now() < deadline {
            tokio::time::sleep(POLL_EVERY).await;
            answer = client.task_run(&answer.body.run.id).await?;
        }
    }

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

pub async fn runs(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    task_reference: &str,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let task = find(client, &service.id, task_reference).await?;
    let answer = client.task_runs(&task.id).await?;

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

    let mut table = Table::new(&["resultado", "empezó", "duró", "origen", "motivo"]);
    for run in &answer.body.runs {
        table.push(vec![
            status_cell(run),
            Cell::plain(time::when(&run.started_at)),
            Cell::styled(
                run.duration_millis
                    .map(time::format_millis)
                    .unwrap_or_default(),
                theme::muted(),
            ),
            Cell::styled(
                if run.trigger == "manual" {
                    "a mano"
                } else {
                    "programada"
                },
                theme::muted(),
            ),
            Cell::styled(
                sanitise::plain(run.reason.as_deref().unwrap_or("")),
                theme::muted(),
            ),
        ]);
    }
    table.print_or("Todavía no se ha ejecutado.");
    if let Some(newest) = answer.body.runs.first() {
        if !newest.output.is_empty() {
            println!();
            print!("{}", sanitise::with_colour(&newest.output));
            if !newest.output.ends_with('\n') {
                println!();
            }
        }
    }
    Ok(())
}

async fn find(
    client: &ApiClient,
    service_environment_id: &str,
    reference: &str,
) -> Result<ScheduledTask> {
    let tasks = client.tasks(service_environment_id).await?.body.tasks;
    let wanted = reference.trim();
    let known: Vec<String> = tasks.iter().map(|task| task.name.clone()).collect();
    tasks
        .into_iter()
        .find(|task| task.id == wanted || task.name.eq_ignore_ascii_case(wanted))
        .ok_or_else(|| {
            exit::Refused::not_found(if known.is_empty() {
                format!("No hay ninguna tarea `{wanted}`: este servicio no tiene tareas")
            } else {
                format!("No hay ninguna tarea `{wanted}`. Hay: {}", known.join(", "))
            })
            .into()
        })
}

fn next_word(task: &ScheduledTask) -> String {
    if !task.enabled {
        return "en pausa".to_owned();
    }
    task.next_run_at
        .as_deref()
        .map_or_else(|| "no vuelve a tocar".to_owned(), time::until)
}

fn last_run_cell(run: Option<&TaskRun>) -> Cell {
    match run {
        None => Cell::styled("—", theme::muted()),
        Some(run) => status_cell(run),
    }
}

fn status_cell(run: &TaskRun) -> Cell {
    match run.status.as_str() {
        "succeeded" => Cell::styled("bien", theme::success()),
        "running" => Cell::styled("en marcha", theme::accent()),
        "skipped" => Cell::styled("saltada", theme::muted()),
        _ => Cell::styled(
            run.exit_code
                .map_or_else(|| "falló".to_owned(), |code| format!("falló (exit {code})")),
            theme::danger(),
        ),
    }
}

fn print_run(run: &TaskRun) {
    if !run.output.is_empty() {
        print!("{}", sanitise::with_colour(&run.output));
        if !run.output.ends_with('\n') {
            println!();
        }
    }
    let took = run
        .duration_millis
        .map(time::format_millis)
        .unwrap_or_default();
    match run.status.as_str() {
        "succeeded" => eprintln!("  {}", format!("bien · {took}").style(theme::muted())),
        "running" => eprintln!(
            "  {}",
            "Sigue en marcha: `fundaia tasks runs` dirá cómo acaba.".style(theme::muted())
        ),
        _ => {
            let why = run
                .reason
                .clone()
                .or_else(|| run.exit_code.map(|code| format!("exit {code}")))
                .unwrap_or_else(|| "falló".to_owned());
            eprintln!(
                "  {} {}",
                theme::CROSS.style(theme::danger()),
                format!("{} · {took}", sanitise::plain(&why)).style(theme::danger())
            );
        }
    }
}