fundaia 0.52.2

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

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

use crate::api::client::ApiClient;
use crate::api::models::{Deployment, DeploymentDetail};
use crate::exit::DeploymentFailed;
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::elapsed;

pub async fn run(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    restart: bool,
    follow: bool,
    format: Format,
) -> Result<()> {
    let (project, service) = client.locate(project_reference, 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<()> {
    go_back(
        client,
        project_reference,
        service_reference,
        number,
        Back::Rebuild,
        format,
    )
    .await
}

pub async fn rollback(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    number: u32,
    format: Format,
) -> Result<()> {
    go_back(
        client,
        project_reference,
        service_reference,
        number,
        Back::Reuse,
        format,
    )
    .await
}

#[derive(Clone, Copy)]
enum Back {
    Rebuild,
    Reuse,
}

async fn go_back(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    number: u32,
    back: Back,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let deployments = client.deployments(&service.id).await?;
    let target = numbered(&deployments, number, &service.name)?;

    let started = match back {
        Back::Rebuild => client.redeploy(&target.id).await?,
        Back::Reuse => client.rollback(&target.id).await?,
    };

    if format.is_json() {
        return watch_json(client, &started).await;
    }

    println!();
    match back {
        Back::Rebuild => println!(
            "  {} Reconstruyendo el commit de #{} en {}",
            theme::ARROW.style(theme::accent()),
            number.style(theme::strong()),
            service.name.style(theme::strong()),
        ),
        Back::Reuse => println!(
            "  {} Volviendo {} a #{}",
            theme::ARROW.style(theme::accent()),
            service.name.style(theme::strong()),
            number.style(theme::strong()),
        ),
    }
    println!();

    watch(client, &started).await
}

fn numbered<'a>(
    deployments: &'a [Deployment],
    number: u32,
    service_name: &str,
) -> Result<&'a Deployment> {
    deployments
        .iter()
        .find(|deployment| deployment.number == number)
        .ok_or_else(|| anyhow!("`{service_name}` has no deployment #{number}"))
}

pub async fn cancel(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    number: Option<u32>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, 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() {
        emit_detail(&detail);
        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) => numbered(deployments, wanted, service_name),
        None => deployments
            .iter()
            .find(|deployment| is_in_flight(&deployment.status))
            .ok_or_else(|| 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 {
            emit_detail(&detail);

            if !detail.pipeline.succeeded {
                return Err(failed(&detail.deployment));
            }
            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 = elapsed(&deployment.created_at, deployment.finished_at.as_deref());

    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!();

    Err(failed(deployment))
}

fn failed(deployment: &Deployment) -> anyhow::Error {
    DeploymentFailed(
        deployment
            .failure_message
            .clone()
            .unwrap_or_else(|| "the deployment failed".to_owned()),
    )
    .into()
}

fn emit_detail(detail: &DeploymentDetail) {
    output::emit(&serde_json::json!({
        "deployment": detail.deployment,
        "pipeline": detail.pipeline,
    }));
}

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"));
    }
}