use std::path::Path;
use anyhow::{Context, Result};
use owo_colors::{OwoColorize, Style};
use crate::api::client::{ApiClient, LogSearch};
use crate::api::models::{AttentionItem, AttentionProject, Deployment, NextStep};
use crate::exit::Refused;
use crate::output::{self, Format};
use crate::render::logs::{render_line, LineFormat};
use crate::render::table::{truncate, Cell, Table};
use crate::render::time::format_millis;
use crate::render::{sanitise, theme};
pub async fn attention(client: &ApiClient, format: Format) -> Result<()> {
let answer = client.attention().await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let items = &answer.body.items;
println!();
if items.is_empty() {
println!(
" {} Nada necesita atención.",
theme::CHECK.style(theme::success())
);
println!();
return Ok(());
}
for (project, grouped) in by_project(items) {
println!(
" {}",
sanitise::plain(&project.name).style(theme::strong())
);
for item in grouped {
let subject = item.service.as_ref().map_or_else(String::new, |service| {
format!("{} · ", sanitise::plain(&service.name))
});
print_finding(
&item.severity,
&format!("{subject}{}", item.message),
item.next.as_ref(),
);
}
println!();
}
Ok(())
}
pub async fn health(client: &ApiClient, project_reference: &str, format: Format) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.project_health(&project.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let health = &answer.body.health;
println!();
println!(
" {} {} · {}",
"●".style(verdict_style(&health.verdict)),
sanitise::plain(&project.name).style(theme::strong()),
verdict_word(&health.verdict).style(verdict_style(&health.verdict)),
);
println!(" {}", sanitise::plain(&health.summary));
println!();
let mut table = Table::new(&["servicio", "estado", "primer hallazgo"]);
for service in &health.services {
let first = service
.findings
.first()
.map_or(service.summary.as_str(), |finding| finding.message.as_str());
table.push(vec![
Cell::plain(&service.name),
Cell::styled(
verdict_word(&service.verdict),
verdict_style(&service.verdict),
),
Cell::plain(truncate(first, 80)),
]);
}
table.print();
if !health.workflows.failing_workflows.is_empty() {
println!();
println!(
" {} {}",
"Workflows en rojo:".style(theme::danger()),
sanitise::plain(&health.workflows.failing_workflows.join(", "))
);
}
if health.pending_approvals > 0 {
println!(
" {} {} · `fundaia approvals {}`",
"Despliegues por aprobar:".style(theme::warning()),
health.pending_approvals,
project.slug
);
}
println!();
Ok(())
}
pub async fn diagnose(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = client.diagnosis(&service.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let diagnosis = &answer.body.diagnosis;
println!();
println!(
" {} {} · {}",
"●".style(verdict_style(&diagnosis.verdict)),
sanitise::plain(&service.name).style(theme::strong()),
verdict_word(&diagnosis.verdict).style(verdict_style(&diagnosis.verdict)),
);
println!(" {}", sanitise::plain(&diagnosis.summary));
if !diagnosis.findings.is_empty() {
println!();
for finding in &diagnosis.findings {
print_finding(&finding.severity, &finding.message, finding.next.as_ref());
}
}
println!();
Ok(())
}
pub async fn audit_variables(
client: &ApiClient,
project_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.variable_audit(&project.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let problems = &answer.body.problems;
if problems.is_empty() {
println!(
" {} Todas las referencias se resuelven.",
theme::CHECK.style(theme::success())
);
return Ok(());
}
let mut table = Table::new(&[
"variable",
"servicio",
"referencia",
"problema",
"sugerencia",
]);
for problem in problems {
table.push(vec![
Cell::styled(&problem.key, theme::strong()),
Cell::plain(problem.service.as_deref().unwrap_or("compartida")),
Cell::plain(truncate(&problem.reference, 50)),
Cell::styled(problem_word(&problem.kind), theme::danger()),
Cell::plain(problem.suggestion.as_deref().unwrap_or("—")),
]);
}
table.print();
Ok(())
}
pub struct GrepQuery<'a> {
pub text: &'a str,
pub service: Option<&'a str>,
pub level: Option<&'a str>,
pub stream: Option<&'a str>,
pub hours: Option<u32>,
pub limit: Option<u32>,
}
pub async fn grep(
client: &ApiClient,
project_reference: &str,
query: GrepQuery<'_>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service_id = match query.service {
Some(reference) => Some(client.find_service(&project.id, reference).await?.id),
None => None,
};
let answer = client
.search_logs(
&project.id,
&LogSearch {
text: query.text,
service_environment_id: service_id.as_deref(),
level: query.level,
stream: query.stream,
hours: query.hours,
limit: query.limit,
},
)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let lines = &answer.body.lines;
if lines.is_empty() {
println!(
"{}",
format!(
"Ninguna línea contiene «{}» en las últimas {} horas.",
sanitise::plain(query.text),
query.hours.unwrap_or(24)
)
.style(theme::muted())
);
return Ok(());
}
let labels: Vec<String> = lines
.iter()
.map(|found| {
match_label(
found.service.as_deref().unwrap_or("?"),
found.deployment_number,
)
})
.collect();
let width = labels
.iter()
.map(|label| label.chars().count())
.max()
.unwrap_or(0);
let gutter = LineFormat::with_stream().showing_time(true);
let mut out = output::Lines::lock();
for (found, label) in lines.iter().zip(&labels) {
out.line(format!(
"{} {}",
format!("{label:<width$}").style(theme::accent()),
render_line(&found.line, gutter)
));
}
out.finish();
Ok(())
}
pub async fn dependencies(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = client.dependencies(&service.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let dependencies = &answer.body.dependencies;
let links = |links: &[crate::api::models::DependencyLink]| {
if links.is_empty() {
return "nada".style(theme::muted()).to_string();
}
links
.iter()
.map(|link| {
if link.keys.is_empty() {
sanitise::plain(&link.name)
} else {
format!(
"{} ({})",
sanitise::plain(&link.name),
sanitise::plain(&link.keys.join(", "))
)
}
})
.collect::<Vec<_>>()
.join(", ")
};
println!();
println!(
" {}",
sanitise::plain(&service.name).style(theme::strong())
);
println!(
" {} {}",
"Usa ".style(theme::muted()),
links(&dependencies.uses)
);
println!(
" {} {}",
"Lo usan ".style(theme::muted()),
links(&dependencies.used_by)
);
println!();
if dependencies.redeploy_after_change.is_empty() {
println!(
" {}",
"Nada depende de él: un cambio no obliga a redesplegar nada más.".style(theme::muted())
);
} else {
println!(
" Tras un cambio, redesplegar en este orden: {}",
sanitise::plain(&dependencies.redeploy_after_change.join(" → "))
);
}
println!();
Ok(())
}
pub async fn export(
client: &ApiClient,
project_reference: &str,
into: Option<&str>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.manifest(&project.id).await?;
let Some(target) = into else {
if !format.answered_in_json(&answer.raw) {
println!("{}", serde_json::to_string_pretty(&answer.body.manifest)?);
}
return Ok(());
};
let mut pretty = serde_json::to_string_pretty(&answer.body.manifest)?;
pretty.push('\n');
std::fs::write(Path::new(target), &pretty)
.with_context(|| format!("could not write {target}"))?;
if format.is_json() {
output::emit(&serde_json::json!({ "file": target, "bytes": pretty.len() }));
return Ok(());
}
println!("Guardado en {target} ({} KB).", pretty.len().div_ceil(1024));
Ok(())
}
pub async fn compare(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
from: Option<u32>,
to: Option<u32>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let deployments = client.deployments(&service.id).await?;
let (older, newer) = pair_to_compare(&deployments, from, to, &service.name)?;
let answer = client.compare_deployments(&older.id, &newer.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let comparison = &answer.body.comparison;
println!();
println!(
" {} #{} {} #{}",
sanitise::plain(&service.name).style(theme::strong()),
comparison.from,
theme::ARROW.style(theme::muted()),
comparison.to,
);
println!(" {}", sanitise::plain(&comparison.summary));
println!();
for (label, same) in [
("código", comparison.same_code),
("variables", comparison.same_variables),
("configuración", comparison.same_config),
] {
let (word, style) = if same {
("igual", theme::success())
} else {
("distinto", theme::warning())
};
println!(" {:<16} {}", label, word.style(style));
}
let live =
|seconds: Option<i64>| seconds.map_or_else(|| "—".to_owned(), |s| format_millis(s * 1000));
println!(
" {:<16} {} {} {}",
"hasta en marcha",
live(comparison.seconds_to_live.from),
theme::ARROW.style(theme::muted()),
live(comparison.seconds_to_live.to),
);
if !comparison.changes.is_empty() {
println!();
let mut table = Table::new(&["campo", "antes", "después"]);
for change in &comparison.changes {
table.push(vec![
Cell::plain(&change.field),
Cell::plain(truncate(change.before.as_deref().unwrap_or("—"), 50)),
Cell::plain(truncate(change.after.as_deref().unwrap_or("—"), 50)),
]);
}
table.print();
}
println!();
Ok(())
}
pub async fn probe(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
path: &str,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = client.probe(&service.id, path).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let probe = &answer.body.probe;
let request = format!(
"GET {} {}",
sanitise::plain(&service.name),
sanitise::plain(&probe.path)
);
println!();
match probe.status {
Some(status) if probe.reachable => {
let mut parts = vec![status.to_string().style(status_style(status)).to_string()];
if let Some(millis) = probe.latency_millis {
parts.push(format_millis(millis));
}
if let Some(kind) = &probe.content_type {
parts.push(sanitise::plain(kind));
}
println!(
" {} {} {}",
request.style(theme::strong()),
theme::ARROW.style(theme::muted()),
parts.join(" · ")
);
}
_ => println!(
" {} {} {} {}",
theme::CROSS.style(theme::danger()),
request.style(theme::strong()),
theme::ARROW.style(theme::muted()),
sanitise::plain(probe.error.as_deref().unwrap_or("sin respuesta"))
.style(theme::danger()),
),
}
if let Some(body) = probe
.body_preview
.as_deref()
.filter(|body| !body.is_empty())
{
println!();
for line in sanitise::plain(body).lines() {
println!(" {line}");
}
}
if let Some(hint) = &probe.hint {
println!();
println!(" {}", sanitise::plain(hint).style(theme::muted()));
}
println!();
Ok(())
}
pub async fn wake(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = client.wake(&service.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let state = &answer.body.state;
println!(
" {} {} · {}",
theme::state_dot(state),
sanitise::plain(&service.name).style(theme::strong()),
theme::state_word(state).style(theme::state_style(state)),
);
Ok(())
}
fn pair_to_compare<'a>(
deployments: &'a [Deployment],
from: Option<u32>,
to: Option<u32>,
service_name: &str,
) -> Result<(&'a Deployment, &'a Deployment)> {
let missing = |what: String| Refused::not_found(format!("`{service_name}` has no {what}"));
let numbered = |number: u32| {
deployments
.iter()
.position(|deployment| deployment.number == number)
.ok_or_else(|| missing(format!("deployment #{number}")))
};
let newer = match to {
Some(number) => numbered(number)?,
None if deployments.is_empty() => return Err(missing("deployments".to_owned()).into()),
None => 0,
};
let older = match from {
Some(number) => numbered(number)?,
None if newer + 1 < deployments.len() => newer + 1,
None => {
return Err(missing(format!("deployment before #{}", deployments[newer].number)).into())
}
};
Ok((&deployments[older], &deployments[newer]))
}
fn by_project(items: &[AttentionItem]) -> Vec<(&AttentionProject, Vec<&AttentionItem>)> {
let mut groups: Vec<(&AttentionProject, Vec<&AttentionItem>)> = Vec::new();
for item in items {
match groups
.iter_mut()
.find(|(project, _)| project.id == item.project.id)
{
Some((_, grouped)) => grouped.push(item),
None => groups.push((&item.project, vec![item])),
}
}
for (_, grouped) in &mut groups {
grouped.sort_by_key(|item| severity_rank(&item.severity));
}
groups.sort_by_key(|(_, grouped)| {
grouped
.iter()
.map(|item| severity_rank(&item.severity))
.min()
.unwrap_or(u8::MAX)
});
groups
}
fn next_hint(next: &NextStep) -> String {
let mut hint = sanitise::plain(&next.tool);
for (name, value) in &next.arguments {
let value = match value {
serde_json::Value::String(text) if text.contains(char::is_whitespace) => {
format!("\"{text}\"")
}
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
};
hint.push_str(&format!(" {name}={}", sanitise::plain(&value)));
}
hint
}
fn print_finding(severity: &str, message: &str, next: Option<&NextStep>) {
let (mark, style) = severity_mark(severity);
println!(" {} {}", mark.style(style), sanitise::plain(message));
if let Some(next) = next {
println!(
" {} {}",
theme::ARROW.style(theme::muted()),
next_hint(next).style(theme::muted())
);
}
}
fn severity_rank(severity: &str) -> u8 {
match severity {
"critical" => 0,
"warning" => 1,
"info" => 2,
_ => 3,
}
}
fn severity_mark(severity: &str) -> (&'static str, Style) {
match severity {
"critical" => (theme::CROSS, theme::danger()),
"warning" => ("▲", theme::warning()),
_ => (theme::PENDING, theme::info()),
}
}
fn verdict_word(verdict: &str) -> &str {
match verdict {
"healthy" => "sano",
"attention" => "necesita atención",
"failing" => "fallando",
"asleep" => "dormido",
"idle" => "sin desplegar",
other => other,
}
}
fn verdict_style(verdict: &str) -> Style {
match verdict {
"healthy" => theme::success(),
"attention" => theme::warning(),
"failing" => theme::danger(),
"asleep" => theme::state_style("sleeping"),
_ => theme::muted(),
}
}
fn problem_word(kind: &str) -> &str {
match kind {
"missing_service" => "no existe el servicio",
"missing_key" => "no existe la clave",
other => other,
}
}
fn status_style(status: u16) -> Style {
match status {
0..=399 => theme::success(),
400..=499 => theme::warning(),
_ => theme::danger(),
}
}
fn match_label(service: &str, deployment: Option<u32>) -> String {
match deployment {
Some(number) => format!("{} #{number}", sanitise::plain(service)),
None => sanitise::plain(service),
}
}
pub async fn import(
client: &ApiClient,
file: &str,
name: Option<&str>,
workspace_reference: Option<&str>,
deploy: bool,
format: Format,
) -> Result<()> {
let text = std::fs::read_to_string(Path::new(file))
.with_context(|| format!("could not read {file}"))?;
let manifest: serde_json::Value = serde_json::from_str(&text).map_err(|_| {
Refused::invalid(format!("{file} no es JSON: exporta el proyecto de nuevo"))
})?;
let workspace = match workspace_reference {
Some(reference) => Some(client.find_workspace(Some(reference)).await?.id),
None => None,
};
let answer = client
.import_project(&manifest, name, workspace.as_deref(), deploy)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let result = &answer.body;
println!(
" {} {} importado",
theme::CHECK.style(theme::success()),
sanitise::plain(&result.project.name).style(theme::strong())
);
for service in &result.services {
let mark = if service.status == "skipped" {
theme::CROSS.style(theme::danger()).to_string()
} else {
theme::CHECK.style(theme::success()).to_string()
};
let why = service
.reason
.as_deref()
.map(|reason| format!(" — {}", sanitise::plain(reason)))
.unwrap_or_default();
println!(
" {mark} {}{}",
sanitise::plain(&service.name),
why.style(theme::muted())
);
for note in &service.notes {
println!(" {}", sanitise::plain(note).style(theme::muted()));
}
}
if !result.missing_secrets.is_empty() {
println!();
println!(" Secretos que el manifiesto no lleva:");
for missing in &result.missing_secrets {
let command = match &missing.service {
Some(service) => format!(
"fundaia vars set {} {} {} … --secret",
result.project.slug,
sanitise::plain(service),
sanitise::plain(&missing.key)
),
None => format!(
"fundaia shared set {} {} … --secret",
result.project.slug,
sanitise::plain(&missing.key)
),
};
println!(" {}", command.style(theme::muted()));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn deployment(number: u32) -> Deployment {
Deployment {
id: format!("dep_{number}"),
number,
status: "active".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,
}
}
fn history() -> Vec<Deployment> {
vec![deployment(12), deployment(11), deployment(10)]
}
fn item(project: &str, severity: &str, message: &str) -> AttentionItem {
AttentionItem {
project: AttentionProject {
id: project.to_owned(),
name: project.to_owned(),
slug: project.to_owned(),
},
service: None,
code: "x".to_owned(),
severity: severity.to_owned(),
message: message.to_owned(),
next: None,
}
}
#[test]
fn it_should_compare_the_newest_deployment_with_the_one_before_it() {
let history = history();
let (older, newer) = pair_to_compare(&history, None, None, "api").unwrap();
assert_eq!((older.number, newer.number), (11, 12));
}
#[test]
fn it_should_compare_a_numbered_deployment_with_the_newest() {
let history = history();
let (older, newer) = pair_to_compare(&history, Some(10), None, "api").unwrap();
assert_eq!((older.number, newer.number), (10, 12));
}
#[test]
fn it_should_compare_the_two_numbers_given() {
let history = history();
let (older, newer) = pair_to_compare(&history, Some(10), Some(11), "api").unwrap();
assert_eq!((older.id.as_str(), newer.id.as_str()), ("dep_10", "dep_11"));
}
#[test]
fn it_should_refuse_a_number_the_service_never_had_as_not_found() {
let history = history();
let refused = pair_to_compare(&history, Some(99), None, "api").unwrap_err();
assert_eq!(crate::exit::classify(&refused).code(), 3);
}
#[test]
fn it_should_refuse_to_compare_a_service_with_one_deployment() {
let history = vec![deployment(1)];
assert!(pair_to_compare(&history, None, None, "api").is_err());
}
#[test]
fn it_should_put_the_project_with_the_worst_item_first() {
let items = vec![
item("tienda", "warning", "a"),
item("blog", "critical", "b"),
];
let names: Vec<&str> = by_project(&items)
.iter()
.map(|(project, _)| project.name.as_str())
.collect();
assert_eq!(names, vec!["blog", "tienda"]);
}
#[test]
fn it_should_keep_every_item_of_a_project_together_worst_first() {
let items = vec![
item("tienda", "info", "a"),
item("blog", "warning", "b"),
item("tienda", "critical", "c"),
];
let messages: Vec<&str> = by_project(&items)[0]
.1
.iter()
.map(|item| item.message.as_str())
.collect();
assert_eq!(messages, vec!["c", "a"]);
}
#[test]
fn it_should_write_the_next_call_with_its_arguments() {
let next: NextStep = serde_json::from_value(serde_json::json!({
"tool": "diagnose_service",
"arguments": { "project": "tienda", "service": "api" }
}))
.unwrap();
assert_eq!(
next_hint(&next),
"diagnose_service project=tienda service=api"
);
}
#[test]
fn it_should_quote_an_argument_with_a_space_in_it() {
let next: NextStep = serde_json::from_value(serde_json::json!({
"tool": "read_logs",
"arguments": { "project": "Mi tienda", "tail": 50 }
}))
.unwrap();
assert_eq!(next_hint(&next), "read_logs project=\"Mi tienda\" tail=50");
}
#[test]
fn it_should_label_a_line_with_its_service_and_deployment() {
assert_eq!(match_label("api", Some(12)), "api #12");
}
#[test]
fn it_should_pass_an_unknown_verdict_through_unchanged() {
assert_eq!(verdict_word("something-new"), "something-new");
}
}