use std::time::Duration;
use anyhow::{bail, Result};
use owo_colors::OwoColorize;
use crate::api::client::ApiClient;
use crate::api::models::Deployment;
use crate::output::{self, Format};
use crate::render::logs::{render_line, LineFormat};
use crate::render::pipeline::PipelineView;
use crate::render::theme;
use crate::render::time::{epoch_millis, format_millis};
pub async fn run(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
restart: bool,
follow: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let deployment = if restart {
client.restart(&service.id).await?
} else {
client.deploy(&service.id).await?
};
if format.is_json() {
if !follow {
output::emit(&deployment);
return Ok(());
}
return watch_json(client, &deployment).await;
}
println!();
println!(
" {} {} {} despliegue #{}",
theme::ARROW.style(theme::accent()),
project.name.style(theme::muted()),
service.name.style(theme::strong()),
deployment.number.style(theme::strong()),
);
println!();
if !follow {
println!(
" {}",
format!(
"Sigue el progreso con: fundaia logs {} {}",
project.slug, service.slug
)
.style(theme::muted()),
);
return Ok(());
}
watch(client, &deployment).await
}
pub async fn redeploy(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
number: u32,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let deployments = client.deployments(&service.id).await?;
let Some(target) = deployments
.iter()
.find(|deployment| deployment.number == number)
else {
bail!("`{}` has no deployment #{number}", service.name);
};
let started = client.redeploy(&target.id).await?;
if format.is_json() {
return watch_json(client, &started).await;
}
println!();
println!(
" {} Reconstruyendo el commit de #{} en {}",
theme::ARROW.style(theme::accent()),
number.style(theme::strong()),
service.name.style(theme::strong()),
);
println!();
watch(client, &started).await
}
pub async fn rollback(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
number: u32,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let deployments = client.deployments(&service.id).await?;
let Some(target) = deployments
.iter()
.find(|deployment| deployment.number == number)
else {
bail!("`{}` has no deployment #{number}", service.name);
};
let started = client.rollback(&target.id).await?;
if format.is_json() {
return watch_json(client, &started).await;
}
println!();
println!(
" {} Volviendo {} a #{}",
theme::ARROW.style(theme::accent()),
service.name.style(theme::strong()),
number.style(theme::strong()),
);
println!();
watch(client, &started).await
}
pub async fn cancel(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
number: Option<u32>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let deployments = client.deployments(&service.id).await?;
let target = deployment_to_cancel(&deployments, number, &service.name)?;
let detail = client.cancel(&target.id).await?;
if format.is_json() {
output::emit(&serde_json::json!({
"deployment": detail.deployment,
"pipeline": detail.pipeline,
}));
return Ok(());
}
println!();
println!(
" {} Despliegue #{} de {} cancelado — lo que estaba sirviendo sigue en pie",
theme::CHECK.style(theme::success()),
detail.deployment.number.style(theme::strong()),
service.name.style(theme::strong()),
);
println!();
Ok(())
}
fn deployment_to_cancel<'a>(
deployments: &'a [Deployment],
number: Option<u32>,
service_name: &str,
) -> Result<&'a Deployment> {
match number {
Some(wanted) => deployments
.iter()
.find(|deployment| deployment.number == wanted)
.ok_or_else(|| anyhow::anyhow!("`{service_name}` has no deployment #{wanted}")),
None => deployments
.iter()
.find(|deployment| is_in_flight(&deployment.status))
.ok_or_else(|| anyhow::anyhow!("`{service_name}` has no deployment in flight")),
}
}
fn is_in_flight(status: &str) -> bool {
matches!(status, "queued" | "building" | "deploying")
}
pub async fn watch(client: &ApiClient, deployment: &Deployment) -> Result<()> {
let interactive = std::io::IsTerminal::is_terminal(&std::io::stdout());
let mut view = PipelineView::new(interactive);
let logs = tokio::spawn({
let client = client.clone();
let deployment_id = deployment.id.clone();
async move { stream_logs(&client, &deployment_id).await }
});
let outcome = poll_pipeline(client, &deployment.id, &mut view).await;
logs.abort();
outcome
}
pub async fn watch_json(client: &ApiClient, deployment: &Deployment) -> Result<()> {
loop {
let detail = client.deployment(&deployment.id).await?;
if detail.pipeline.finished {
let succeeded = detail.pipeline.succeeded;
let failure = detail.deployment.failure_message.clone();
output::emit(&serde_json::json!({
"deployment": detail.deployment,
"pipeline": detail.pipeline,
}));
if !succeeded {
bail!(failure.unwrap_or_else(|| "the deployment failed".to_owned()));
}
return Ok(());
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
async fn poll_pipeline(
client: &ApiClient,
deployment_id: &str,
view: &mut PipelineView,
) -> Result<()> {
loop {
let detail = client.deployment(deployment_id).await?;
if detail.pipeline.finished {
view.finish(&detail.pipeline);
return report(&detail.deployment, detail.pipeline.succeeded);
}
view.draw(&detail.pipeline);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
fn report(deployment: &Deployment, succeeded: bool) -> Result<()> {
let elapsed = deployment
.finished_at
.as_ref()
.and_then(|finished| Some(epoch_millis(finished)? - epoch_millis(&deployment.created_at)?))
.map(format_millis)
.unwrap_or_else(|| "—".to_owned());
println!();
if succeeded {
println!(
" {} Despliegue #{} en marcha en {}",
theme::CHECK.style(theme::success()),
deployment.number,
elapsed.style(theme::strong()),
);
println!();
return Ok(());
}
println!(
" {} Despliegue #{} ha fallado tras {}",
theme::CROSS.style(theme::danger()),
deployment.number,
elapsed,
);
println!();
bail!(deployment
.failure_message
.clone()
.unwrap_or_else(|| "the deployment failed".to_owned()))
}
async fn stream_logs(client: &ApiClient, deployment_id: &str) -> Result<()> {
client
.stream_log(deployment_id, 0, None, |line| {
println!(" {}", render_line(&line, LineFormat::plain()));
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
fn deployment(number: u32, status: &str) -> Deployment {
Deployment {
id: format!("dep_{number}"),
number,
status: status.into(),
trigger: "manual".into(),
commit_sha: None,
commit_subject: None,
failure_message: None,
created_at: "2026-09-05T10:00:00.000Z".into(),
finished_at: None,
}
}
#[test]
fn it_should_pick_the_deployment_in_flight_when_no_number_is_given() {
let history = [deployment(3, "building"), deployment(2, "active")];
assert_eq!(
deployment_to_cancel(&history, None, "api").unwrap().number,
3
);
}
#[test]
fn it_should_pick_the_deployment_somebody_numbered() {
let history = [deployment(3, "building"), deployment(2, "active")];
assert_eq!(
deployment_to_cancel(&history, Some(2), "api")
.unwrap()
.number,
2
);
}
#[test]
fn it_should_refuse_when_nothing_is_in_flight() {
let history = [deployment(2, "active"), deployment(1, "superseded")];
assert!(deployment_to_cancel(&history, None, "api").is_err());
}
#[test]
fn it_should_refuse_a_number_the_service_never_had() {
let history = [deployment(1, "active")];
assert!(deployment_to_cancel(&history, Some(7), "api").is_err());
}
#[test]
fn it_should_count_a_queued_deployment_as_in_flight() {
assert!(is_in_flight("queued"));
}
#[test]
fn it_should_not_count_a_cancelled_deployment_as_in_flight() {
assert!(!is_in_flight("cancelled"));
}
}