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};
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(())
}
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),
]
}
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),
}
}
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(())
}
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()),
}
}
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(())
}
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(())
}
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(),
})
}
#[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"));
}
}