fundaia 0.32.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::collections::BTreeMap;
use std::path::Path;

use anyhow::{bail, Result};
use owo_colors::OwoColorize;

use crate::api::client::ApiClient;
use crate::api::models::{WorkflowJob, WorkflowLogLine, WorkflowRun};
use crate::output::{self, Format};
use crate::render::table::{short_sha, truncate, Cell, Table};
use crate::render::time::relative_time;
use crate::render::{sanitise, theme};

/// The workflow files of a project, per branch.
pub async fn list(
    client: &ApiClient,
    project_reference: &str,
    refresh: bool,
    format: Format,
) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let workflows = client.workflows(&project.id, refresh).await?;

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

    if workflows.is_empty() {
        println!(
            "{}",
            "Ningún workflow: añade .fundaia/workflows/*.yml al repositorio y vuelve con --refresh."
                .style(theme::muted())
        );
        return Ok(());
    }

    let mut table = Table::new(&[
        "workflow", "fichero", "rama", "push", "cron", "tras", "estado",
    ]);
    for workflow in &workflows {
        let (state, style) = match &workflow.error {
            Some(error) => (truncate(error, 50), theme::danger()),
            None => ("ok".to_owned(), theme::success()),
        };
        table.push(vec![
            Cell::plain(&workflow.name),
            Cell::plain(&workflow.path),
            Cell::plain(format!("{}@{}", workflow.repo_full_name, workflow.branch)),
            Cell::plain(if workflow.runs_on_push { "sí" } else { "no" }),
            Cell::plain(workflow.schedules.join(" | ")),
            Cell::plain(workflow.runs_after.join(", ")),
            Cell::styled(state, style),
        ]);
    }
    table.print();
    Ok(())
}

/// Recent runs, newest first.
pub async fn runs(
    client: &ApiClient,
    project_reference: &str,
    workflow_path: Option<&str>,
    commit: Option<&str>,
    limit: u32,
    format: Format,
) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let runs = client
        .workflow_runs(&project.id, workflow_path, commit, limit)
        .await?;

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

    if runs.is_empty() {
        let nothing = match commit {
            Some(sha) => format!("Ninguna ejecución para {sha}."),
            None => "Ninguna ejecución todavía.".to_owned(),
        };
        println!("{}", nothing.style(theme::muted()));
        return Ok(());
    }

    let mut table = Table::new(&[
        "#", "workflow", "estado", "commit", "rama", "cómo", "cuándo", "id",
    ]);
    for run in &runs {
        table.push(run_row(run));
    }
    table.print();
    Ok(())
}

fn run_row(run: &WorkflowRun) -> Vec<Cell> {
    vec![
        Cell::plain(run.number.to_string()),
        Cell::plain(&run.workflow_name),
        Cell::styled(status_word(&run.status), status_style(&run.status)),
        Cell::plain(truncate(
            run.commit_subject
                .as_deref()
                .unwrap_or(short_sha(&run.commit_sha)),
            40,
        )),
        Cell::plain(&run.branch),
        Cell::plain(trigger_and_actor(run)),
        Cell::plain(relative_time(&run.created_at).unwrap_or_default()),
        Cell::plain(&run.id),
    ]
}

/// How the run started and who asked for it, in one column.
///
/// A schedule has nobody behind it, and neither does a run another run started
/// when it ended (`workflow_run`), so naming the platform there would be a word
/// that adds nothing to the trigger. A binary older than the field — or one
/// talking to a platform that predates it — falls back to the trigger alone
/// rather than printing an id.
fn trigger_and_actor(run: &WorkflowRun) -> String {
    match (run.trigger.as_str(), run.triggered_by_name.as_deref()) {
        ("schedule", _) | ("workflow_run", _) | (_, None) => run.trigger.clone(),
        (_, Some(name)) => format!("{} · {}", run.trigger, name),
    }
}

/// Starts a workflow by hand, at the current head of its branch.
pub async fn run(
    client: &ApiClient,
    project_reference: &str,
    workflow_path: &str,
    branch: Option<&str>,
    inputs: &[String],
    format: Format,
) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let mut parsed = BTreeMap::new();
    for input in inputs {
        match input.split_once('=') {
            Some((key, value)) => {
                parsed.insert(key.to_owned(), value.to_owned());
            }
            None => bail!("--input expects NAME=value, got '{input}'"),
        }
    }

    let started = client
        .start_workflow(&project.id, workflow_path, branch, &parsed)
        .await?;

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

    println!(
        "Ejecución #{} de {} en cola ({}@{}). Sigue con: fundaia workflows show {}",
        started.number,
        started.workflow_name.style(theme::strong()),
        started.branch,
        short_sha(&started.commit_sha),
        started.id
    );
    Ok(())
}

/// One run, with its jobs and their steps.
pub async fn show(client: &ApiClient, run_id: &str, format: Format) -> Result<()> {
    let run = client.workflow_run(run_id).await?;

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

    println!(
        "#{} {} · {} · {}@{} · {}",
        run.number,
        run.workflow_name.style(theme::strong()),
        status_word(&run.status).style(status_style(&run.status)),
        run.branch,
        short_sha(&run.commit_sha),
        run.trigger
    );
    if let Some(message) = &run.failure_message {
        println!("{}", sanitise::plain(message).style(theme::danger()));
    }

    let mut table = Table::new(&["job", "estado", "pasos", "imagen", "artefactos", "id"]);
    for job in &run.jobs {
        table.push(vec![
            Cell::plain(&job.name),
            Cell::styled(status_word(&job.status), status_style(&job.status)),
            Cell::plain(steps_summary(job)),
            Cell::plain(truncate(&job.image, 30)),
            Cell::plain(match job.artifact_bytes {
                Some(bytes) => format!("{} KB", bytes / 1024),
                None => String::new(),
            }),
            Cell::plain(&job.id),
        ]);
    }
    table.print();
    Ok(())
}

fn steps_summary(job: &WorkflowJob) -> String {
    let done = job
        .steps
        .iter()
        .filter(|step| step.status == "succeeded")
        .count();
    match job.steps.iter().find(|step| step.status == "failed") {
        Some(failed) => format!("{done}/{} · falló «{}»", job.steps.len(), failed.name),
        None => format!("{done}/{}", job.steps.len()),
    }
}

/// What a job printed, step by step.
pub async fn logs(client: &ApiClient, job_id: &str, format: Format) -> Result<()> {
    let page = client.workflow_job_log(job_id, 0).await?;

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

    let mut out = output::Lines::lock();
    for line in &page.lines {
        out.line(job_log_line(line));
    }
    if let Some(message) = &page.job.failure_message {
        out.line(sanitise::plain(message).style(theme::danger()));
    }
    out.finish();
    Ok(())
}

/// One line a job printed, under the number of its step.
///
/// Sanitised the way a deployment's log is: the job ran whatever the
/// repository told it to, so its colour stays and every other escape goes.
fn job_log_line(line: &WorkflowLogLine) -> String {
    let message = sanitise::with_colour(&line.message);
    match line.step {
        Some(step) => format!(
            "{} {message}",
            format!("{:>2}", step + 1).style(theme::muted())
        ),
        None => format!("   {}", message.style(theme::muted())),
    }
}

pub async fn cancel(client: &ApiClient, run_id: &str, format: Format) -> Result<()> {
    let run = client.cancel_workflow_run(run_id).await?;
    if format.answered_in_json(&run) {
        return Ok(());
    }
    println!("Ejecución #{} cancelada.", run.number);
    Ok(())
}

pub async fn rerun(client: &ApiClient, run_id: &str, format: Format) -> Result<()> {
    let run = client.rerun_workflow(run_id).await?;
    if format.answered_in_json(&run) {
        return Ok(());
    }
    println!(
        "Ejecución #{} en cola: fundaia workflows show {}",
        run.number, run.id
    );
    Ok(())
}

/// The artifact archive of a job, as a `.tar.gz` on this machine.
pub async fn artifact(
    client: &ApiClient,
    job_id: &str,
    into: Option<&str>,
    format: Format,
) -> Result<()> {
    let target = into
        .map(str::to_owned)
        .unwrap_or_else(|| format!("{job_id}.tar.gz"));
    let bytes = client
        .download_workflow_artifact(job_id, Path::new(&target))
        .await?;

    if format.is_json() {
        output::emit(&serde_json::json!({ "jobId": job_id, "file": target, "bytes": bytes }));
        return Ok(());
    }
    println!("Guardado en {target} ({} KB).", bytes / 1024);
    Ok(())
}

fn status_word(status: &str) -> &str {
    match status {
        "queued" => "en cola",
        "running" => "ejecutando",
        "succeeded" => "correcto",
        "failed" => "falló",
        "cancelled" => "cancelado",
        "skipped" => "omitido",
        other => other,
    }
}

fn status_style(status: &str) -> owo_colors::Style {
    match status {
        "succeeded" => theme::success(),
        "failed" => theme::danger(),
        "running" | "queued" => theme::accent(),
        _ => theme::muted(),
    }
}

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

    fn printed(message: &str) -> String {
        job_log_line(&WorkflowLogLine {
            sequence: 1,
            at: "2026-09-20T10:00:00.000Z".to_owned(),
            step: Some(0),
            message: message.to_owned(),
        })
    }

    /// A job prints whatever its repository told it to: OSC 52 writes the
    /// clipboard of whoever reads the log.
    #[test]
    fn it_should_drop_an_escape_a_job_printed_that_is_not_colour() {
        assert!(!printed("ok \u{1b}]52;c;ZXZpbA==\u{7}").contains("52;c"));
    }

    #[test]
    fn it_should_keep_the_colour_a_job_printed() {
        assert!(printed("\u{1b}[32mok\u{1b}[0m").contains("\u{1b}[32mok"));
    }
}