use std::collections::BTreeMap;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{bail, Result};
use owo_colors::OwoColorize;
use crate::api::client::ApiClient;
use crate::api::models::{WorkflowJob, WorkflowLogLine, WorkflowRun};
use crate::exit::DeploymentFailed;
use crate::output::{self, Format};
use crate::render::progress::ProgressLine;
use crate::render::table::{short_sha, truncate, Cell, Table};
use crate::render::time::{format_millis, 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<Job>(run: &WorkflowRun<Job>) -> 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(branch_of(run)),
Cell::plain(trigger_and_actor(run)),
Cell::plain(relative_time(&run.created_at).unwrap_or_default()),
Cell::plain(&run.id),
]
}
fn branch_of<Job>(run: &WorkflowRun<Job>) -> String {
match &run.repo_full_name {
Some(repository) => format!("{repository}@{}", run.branch),
None => run.branch.clone(),
}
}
fn trigger_and_actor<Job>(run: &WorkflowRun<Job>) -> 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(());
}
print_run(&run);
Ok(())
}
fn print_run(run: &WorkflowRun) {
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();
}
pub async fn wait(client: &ApiClient, run_id: &str, timeout: u64, format: Format) -> Result<()> {
let began = Instant::now();
let deadline = began + Duration::from_secs(timeout);
let mut progress = ProgressLine::new(format.is_json());
let (answer, ended) = loop {
let answer = client.workflow_run_answer(run_id).await?;
let ended = !is_pending(&answer.body.run.status);
if ended || Instant::now() >= deadline {
break (answer, ended);
}
progress.draw(&waiting_line(
&answer.body.run,
began.elapsed().as_millis() as i64,
));
tokio::time::sleep(Duration::from_secs(2)).await;
};
progress.clear();
let run = &answer.body.run;
let failed_job = run.jobs.iter().find(|job| job.status == "failed");
let tail = match failed_job {
Some(job) => {
let page = client.workflow_job_log(&job.id, 0).await?;
let start = page.lines.len().saturating_sub(FAILED_TAIL);
page.lines.into_iter().skip(start).collect()
}
None => Vec::new(),
};
if format.is_json() {
let mut body = serde_json::json!({ "run": answer.raw["run"], "ended": ended });
if !ended {
body["hint"] = format!(
"Still running: run `fundaia workflows wait {}` again.",
run.id
)
.into();
}
if failed_job.is_some() {
body["failedJobTail"] = serde_json::to_value(&tail)?;
}
output::emit(&body);
} else {
print_run(run);
if let Some(job) = failed_job {
println!();
println!(
"{}",
format!("Final de «{}»:", job.name).style(theme::muted())
);
let mut out = output::Lines::lock();
for line in tail.iter().skip(tail.len().saturating_sub(20)) {
out.line(job_log_line(line));
}
out.finish();
}
}
if !ended {
bail!(
"run #{} is still {} after {timeout}s: `fundaia workflows wait {}` again",
run.number,
run.status,
run.id
);
}
if run.status != "succeeded" {
return Err(DeploymentFailed(format!(
"run #{} of {} {}{}",
run.number,
run.workflow_name,
run.status,
run.failure_message
.as_deref()
.map_or_else(String::new, |message| format!(": {message}"))
))
.into());
}
Ok(())
}
const FAILED_TAIL: usize = 60;
fn is_pending(status: &str) -> bool {
matches!(status, "queued" | "running")
}
fn waiting_line(run: &WorkflowRun, waited_millis: i64) -> String {
let done = run
.jobs
.iter()
.filter(|job| !is_pending(&job.status))
.count();
format!(
" {} #{} {} · {} · {done}/{} jobs · {}",
theme::PENDING.style(theme::accent()),
run.number,
sanitise::plain(&run.workflow_name),
status_word(&run.status),
run.jobs.len(),
format_millis(waited_millis),
)
}
pub async fn stats(
client: &ApiClient,
project_reference: &str,
limit: u32,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.workflow_stats(&project.id, limit).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let mut table = Table::new(&[
"workflow",
"ejecuciones",
"éxito",
"mediana",
"más lenta",
"última",
"racha",
"inestable",
]);
for stat in &answer.body.stats {
let last = stat.last_status.as_deref().unwrap_or("—");
table.push(vec![
Cell::plain(&stat.name),
Cell::plain(stat.runs.to_string()),
Cell::plain(percent(stat.success_rate)),
Cell::plain(seconds(stat.median_seconds)),
Cell::plain(seconds(stat.slowest_seconds)),
Cell::styled(status_word(last), status_style(last)),
match stat.failing_streak {
0 => Cell::plain("—"),
streak => Cell::styled(format!("{streak} en rojo"), theme::danger()),
},
if stat.flaky {
Cell::styled("sí", theme::warning())
} else {
Cell::plain("")
},
]);
}
table.print_or("Ninguna ejecución todavía.".style(theme::muted()));
Ok(())
}
fn percent(rate: Option<f64>) -> String {
rate.map_or_else(|| "—".to_owned(), |rate| format!("{:.0}%", rate * 100.0))
}
fn seconds(value: Option<f64>) -> String {
value.map_or_else(
|| "—".to_owned(),
|value| format_millis((value * 1000.0) as i64),
)
}
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_print_a_success_rate_as_a_whole_percentage() {
assert_eq!(percent(Some(0.875)), "88%");
}
#[test]
fn it_should_print_no_rate_for_a_workflow_that_never_finished() {
assert_eq!(percent(None), "—");
}
#[test]
fn it_should_print_a_duration_in_seconds_as_the_logs_do() {
assert_eq!(seconds(Some(90.0)), "1m 30s");
}
#[test]
fn it_should_keep_waiting_on_a_queued_run() {
assert!(is_pending("queued"));
}
#[test]
fn it_should_stop_waiting_on_a_cancelled_run() {
assert!(!is_pending("cancelled"));
}
#[test]
fn it_should_keep_the_colour_a_job_printed() {
assert!(printed("\u{1b}[32mok\u{1b}[0m").contains("\u{1b}[32mok"));
}
const A_LISTED_RUN: &str = r#"{"runs":[{"id":"wfr_1","projectId":"prj_1","number":3,
"repoFullName":"alex28042/tako-web","branch":"main","commitSha":"e53ab1d0",
"commitSubject":"CI","workflowPath":".fundaia/workflows/web-ci.yml","workflowName":"CI Web",
"trigger":"push","triggeredBy":"usr_1","triggeredByName":"Alex","parentRunId":null,
"status":"running","inputs":{},"concurrencyGroup":"web-ci-main","failureMessage":null,
"createdAt":"2026-09-23T16:20:00.000Z","startedAt":null,"finishedAt":null,
"jobs":[{"key":"check","name":"Tipos, lint, tests, idiomas y build","status":"running"}]}]}"#;
fn listed_run() -> WorkflowRun<crate::api::models::WorkflowJobSummary> {
let page: crate::api::models::WorkflowRunsPage =
serde_json::from_str(A_LISTED_RUN).expect("the list the server sends parses");
page.runs.into_iter().next().expect("one run")
}
#[test]
fn it_should_read_a_listed_run_whose_jobs_carry_no_id() {
assert_eq!(listed_run().jobs.len(), 1);
}
#[test]
fn it_should_name_the_repository_beside_the_branch() {
assert_eq!(branch_of(&listed_run()), "alex28042/tako-web@main");
}
}