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