fundaia 0.53.0

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

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

use crate::api::client::ApiClient;
use crate::api::models::{Deployment, SecurityScan};
use crate::commands::read::format_money;
use crate::exit::Refused;
use crate::output::Format;
use crate::render::progress::ProgressLine;
use crate::render::table::{truncate, Cell, Table};
use crate::render::time::{format_millis, when};
use crate::render::{sanitise, theme};

pub async fn fix_deployment(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    number: Option<u32>,
    format: Format,
) -> Result<()> {
    let (project, service) = client.locate(project_reference, service_reference).await?;
    let deployments = client.deployments(&service.id).await?;
    let target = numbered_or_newest(&deployments, number, &service.name)?;
    let answer = client.fix_deployment(&target.id).await?;
    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    println!(
        "  {} arreglando #{} de {} · {} · sigue con `fundaia autofix {} {}`",
        theme::CHECK.style(theme::success()),
        target.number,
        sanitise::plain(&service.name).style(theme::strong()),
        answer.body.run.id,
        project.slug,
        service.slug,
    );
    Ok(())
}

pub async fn scan(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    fix: bool,
    wait: bool,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let started = client.start_security_scan(&service.id, fix).await?;

    if !wait {
        if format.answered_in_json(&started.raw) {
            return Ok(());
        }
        println!(
            "  {} escaneando {} · {} · sigue con `fundaia security show {}`",
            theme::CHECK.style(theme::success()),
            sanitise::plain(&service.name).style(theme::strong()),
            started.body.scan.id,
            started.body.scan.id,
        );
        return Ok(());
    }

    let scan_id = started.body.scan.id;
    let began = Instant::now();
    let mut progress = ProgressLine::new(format.is_json());
    let finished = loop {
        let answer = client.security_scan(&scan_id).await?;
        if answer.body.scan.status != "running" {
            break answer;
        }
        progress.draw(&format!(
            "  {} escaneando {} · {} · {}",
            theme::PENDING.style(theme::accent()),
            sanitise::plain(&service.name),
            step_word(&answer.body.scan.step),
            format_millis(began.elapsed().as_millis() as i64),
        ));
        tokio::time::sleep(Duration::from_secs(2)).await;
    };
    progress.clear();

    if format.answered_in_json(&finished.raw) {
        return Ok(());
    }
    print_scan(&finished.body.scan);
    Ok(())
}

pub async fn show(client: &ApiClient, scan_id: &str, format: Format) -> Result<()> {
    let answer = client.security_scan(scan_id).await?;
    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }
    print_scan(&answer.body.scan);
    Ok(())
}

pub async fn list(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let answer = client.security_scans(&service.id).await?;
    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    let mut table = Table::new(&[
        "estado",
        "hallazgos",
        "arreglar",
        "pull request",
        "cuándo",
        "id",
    ]);
    for scan in &answer.body.scans {
        table.push(vec![
            Cell::styled(status_word(&scan.status), status_style(&scan.status)),
            Cell::plain(scan.findings.len().to_string()),
            Cell::plain(if scan.fix { "sí" } else { "no" }),
            Cell::plain(scan.pull_request_url.as_deref().unwrap_or("—")),
            Cell::plain(when(&scan.started_at)),
            Cell::styled(&scan.id, theme::muted()),
        ]);
    }
    table.print_or(
        "Ningún escaneo todavía: `fundaia security scan <proyecto> <servicio>`."
            .style(theme::muted()),
    );
    Ok(())
}

pub async fn issue(client: &ApiClient, kind: &str, id: &str, format: Format) -> Result<()> {
    let answer = client.open_issue(kind, id).await?;
    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    let issue = &answer.body.issue;
    println!(
        "  {} {}#{} · {}",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&issue.repository),
        issue.number,
        sanitise::plain(&issue.url),
    );
    Ok(())
}

fn print_scan(scan: &SecurityScan) {
    println!();
    println!(
        "  {} {} · {}{}",
        "●".style(status_style(&scan.status)),
        status_word(&scan.status).style(status_style(&scan.status)),
        scan.id.as_str().style(theme::muted()),
        if scan.status == "running" {
            format!(" · {}", step_word(&scan.step))
        } else {
            String::new()
        },
    );
    if let Some(summary) = &scan.summary {
        println!("  {}", sanitise::plain(summary));
    }
    if let Some(failure) = &scan.failure_message {
        println!("  {}", sanitise::plain(failure).style(theme::danger()));
    }

    if !scan.findings.is_empty() {
        println!();
        let mut table = Table::new(&[
            "gravedad",
            "paquete",
            "instalada",
            "corregida en",
            "aviso",
            "qué",
        ]);
        for finding in &scan.findings {
            table.push(vec![
                Cell::styled(
                    severity_word(&finding.severity),
                    severity_style(&finding.severity),
                ),
                Cell::plain(&finding.package),
                Cell::plain(&finding.installed),
                Cell::plain(finding.fixed_in.as_deref().unwrap_or("—")),
                Cell::plain(finding.advisory.as_deref().unwrap_or("—")),
                Cell::plain(truncate(&finding.title, 60)),
            ]);
        }
        table.print();
    }

    println!();
    if let Some(url) = &scan.pull_request_url {
        println!("  pull request  {}", sanitise::plain(url));
    }
    println!(
        "  {}",
        format!(
            "{} · {} · {}",
            sanitise::plain(&scan.model),
            format_money(scan.cost_micros.max(0) as u64),
            when(&scan.started_at)
        )
        .style(theme::muted())
    );
    println!();
}

fn numbered_or_newest<'a>(
    deployments: &'a [Deployment],
    number: Option<u32>,
    service_name: &str,
) -> Result<&'a Deployment> {
    let found = match number {
        Some(wanted) => deployments.iter().find(|one| one.number == wanted),
        None => deployments.first(),
    };
    found.ok_or_else(|| {
        let what = number.map_or_else(|| "deployments".to_owned(), |n| format!("deployment #{n}"));
        Refused::not_found(format!("`{service_name}` has no {what}")).into()
    })
}

fn status_word(status: &str) -> &str {
    match status {
        "running" => "escaneando",
        "clean" => "limpio",
        "vulnerable" => "vulnerable",
        "fixed" => "arreglado",
        "failed" => "falló",
        other => other,
    }
}

fn status_style(status: &str) -> Style {
    match status {
        "clean" | "fixed" => theme::success(),
        "vulnerable" => theme::warning(),
        "failed" => theme::danger(),
        "running" => theme::accent(),
        _ => theme::muted(),
    }
}

fn step_word(step: &str) -> &str {
    match step {
        "audit" => "auditando",
        "fix" => "actualizando",
        "publish" => "abriendo el pull request",
        other => other,
    }
}

fn severity_word(severity: &str) -> &str {
    match severity {
        "critical" => "crítica",
        "high" => "alta",
        "moderate" => "media",
        "low" => "baja",
        "unknown" => "desconocida",
        other => other,
    }
}

fn severity_style(severity: &str) -> Style {
    match severity {
        "critical" | "high" => theme::danger(),
        "moderate" => theme::warning(),
        _ => theme::muted(),
    }
}

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

    fn deployment(number: u32) -> Deployment {
        Deployment {
            id: format!("dep_{number}"),
            number,
            status: "failed".to_owned(),
            trigger: "git-push".to_owned(),
            commit_sha: None,
            commit_subject: None,
            failure_message: None,
            created_at: "2026-09-20T10:00:00.000Z".to_owned(),
            finished_at: None,
            no_cache: false,
        }
    }

    #[test]
    fn it_should_fix_the_newest_deployment_when_no_number_is_given() {
        let history = vec![deployment(8), deployment(7)];
        assert_eq!(
            numbered_or_newest(&history, None, "api").unwrap().id,
            "dep_8"
        );
    }

    #[test]
    fn it_should_fix_the_deployment_the_number_names() {
        let history = vec![deployment(8), deployment(7)];
        assert_eq!(
            numbered_or_newest(&history, Some(7), "api").unwrap().id,
            "dep_7"
        );
    }

    #[test]
    fn it_should_refuse_a_service_never_deployed_as_not_found() {
        let refused = numbered_or_newest(&[], None, "api").unwrap_err();
        assert_eq!(crate::exit::classify(&refused).code(), 3);
    }

    #[test]
    fn it_should_call_a_high_severity_high_in_spanish() {
        assert_eq!(severity_word("high"), "alta");
    }

    #[test]
    fn it_should_pass_an_unknown_scan_status_through_unchanged() {
        assert_eq!(status_word("something-new"), "something-new");
    }
}