use anyhow::Result;
use owo_colors::OwoColorize;
use crate::api::client::{ApiClient, AutoPromotionDraft, FreezeDraft, ScheduledActionDraft};
use crate::api::models::{AutoPromotion, Freeze, FreezeStatus, HeldDeployment, ScheduledAction};
use crate::output::{self, Format};
use crate::render::sanitise;
use crate::render::table::{short_sha, Cell, Table};
use crate::render::theme;
use crate::render::time;
const DEFAULT_ENVIRONMENT: &str = "production";
pub async fn status(client: &ApiClient, project_reference: &str, format: Format) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.freeze_status(&project.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let status = &answer.body;
println!();
println!(" {}", headline(status));
if let Some(upcoming) = &status.upcoming {
println!(
" {}",
format!(
"Próxima congelación {}: «{}»",
time::until(&upcoming.starts_at),
sanitise::plain(&upcoming.reason)
)
.style(theme::muted())
);
}
if status.protected {
println!(
" {}",
format!(
"{} está protegido: desplegar, reiniciar o cambiar variables pide --confirm {}",
sanitise::plain(&status.environment.name),
status.environment.slug
)
.style(theme::muted())
);
}
println!();
let mut table = Table::new(&["congelación", "alcance", "cuándo", "motivo"]);
for freeze in &status.everywhere {
table.push(freeze_row(freeze));
}
table.print_or(format_args!(
"Nada congelado en `{}`. fundaia freeze now {} --reason \"…\"",
project.name, project.slug
));
if !status.held.is_empty() {
println!();
let mut held = Table::new(&["retenido", "por", "desde"]);
for one in &status.held {
held.push(held_row(one));
}
held.print_or("");
}
Ok(())
}
fn headline(status: &FreezeStatus) -> String {
let name = sanitise::plain(&status.environment.name);
match &status.frozen {
None => format!(
"{} {} no está congelado",
theme::CHECK.style(theme::success()),
name.style(theme::strong())
),
Some(frozen) => format!(
"{} {} está congelado {}: «{}»",
theme::PENDING.style(theme::warning()),
name.style(theme::strong()),
frozen.until.as_deref().map_or_else(
|| "hasta que alguien lo levante".to_owned(),
|until| format!("hasta {}", time::until(until))
),
sanitise::plain(&frozen.reason)
),
}
}
fn freeze_row(freeze: &Freeze) -> Vec<Cell> {
let scope = freeze.environment.as_ref().map_or_else(
|| "todo el proyecto".to_owned(),
|environment| sanitise::plain(&environment.name),
);
let when = match (&freeze.window_description, &freeze.ends_at) {
(Some(window), _) => sanitise::plain(window),
(None, Some(ends_at)) => format!("termina {}", time::until(ends_at)),
(None, None) => "hasta que se levante".to_owned(),
};
vec![
Cell::styled(
freeze.id.clone(),
if freeze.active {
theme::warning()
} else {
theme::muted()
},
),
Cell::plain(scope),
Cell::plain(when),
Cell::styled(sanitise::plain(&freeze.reason), theme::muted()),
]
}
fn held_reason(held: &HeldDeployment) -> String {
match (&held.git_tag, &held.commit_sha, &held.hook) {
(Some(tag), _, _) => format!("la etiqueta {}", sanitise::plain(tag)),
(None, Some(sha), _) => format!("el commit {}", short_sha(sha)),
(None, None, Some(hook)) => format!("el deploy hook {}", sanitise::plain(hook)),
(None, None, None) => held.trigger.clone(),
}
}
fn held_row(held: &HeldDeployment) -> Vec<Cell> {
vec![
Cell::styled(sanitise::plain(&held.service), theme::strong()),
Cell::plain(held_reason(held)),
Cell::styled(time::when(&held.held_at), theme::muted()),
]
}
pub struct FreezeRequest<'a> {
pub project: &'a str,
pub reason: &'a str,
pub until: Option<&'a str>,
pub window: Option<&'a str>,
pub timezone: Option<&'a str>,
pub env_only: bool,
}
pub async fn freeze(client: &ApiClient, request: FreezeRequest<'_>, format: Format) -> Result<()> {
let project = client.find_project(request.project).await?;
let environment = request
.env_only
.then(|| client.environment().unwrap_or(DEFAULT_ENVIRONMENT));
let answer = client
.freeze(
&project.id,
&FreezeDraft {
reason: request.reason,
environment,
until: request.until,
window: request.window,
timezone: request.timezone,
},
)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let freeze = &answer.body.freeze;
let scope = freeze.environment.as_ref().map_or_else(
|| project.name.clone(),
|environment| environment.name.clone(),
);
let when = match (&freeze.window_description, &freeze.ends_at) {
(Some(window), _) => sanitise::plain(window),
(None, Some(ends_at)) => format!("hasta {}", time::until(ends_at)),
(None, None) => "hasta que la levantes".to_owned(),
};
println!(
" {} {} congelado {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&scope).style(theme::strong()),
when,
format!("({})", freeze.id).style(theme::muted())
);
println!(
" {}",
"Lo automático espera a que termine; a mano hace falta --force.".style(theme::muted())
);
Ok(())
}
pub async fn lift(
client: &ApiClient,
project_reference: &str,
freeze_id: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.lift_freeze(&project.id, freeze_id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
println!(
" {} Congelación {} levantada: lo retenido sale ahora",
theme::CHECK.style(theme::success()),
answer.body.freeze.id.style(theme::strong())
);
Ok(())
}
pub async fn protect(
client: &ApiClient,
project_reference: &str,
environment: &str,
protect: bool,
confirm: Option<&str>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client
.protect_environment(&project.id, environment, protect, confirm)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let changed = &answer.body;
let name = sanitise::plain(&changed.environment.name);
let sentence = match (changed.protected, changed.changed) {
(true, true) => format!(
"{} protegido: desplegar, reiniciar o cambiar variables pide --confirm {}",
name, changed.environment.slug
),
(true, false) => format!("{name} ya estaba protegido"),
(false, true) => format!("{name} ya no está protegido"),
(false, false) => format!("{name} no estaba protegido"),
};
println!(" {} {}", theme::CHECK.style(theme::success()), sentence);
Ok(())
}
pub async fn actions(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let (project, service) = client.locate(project_reference, service_reference).await?;
let answer = client.scheduled_actions(&service.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let mut table = Table::new(&["acción", "id", "próxima", "última"]);
for action in &answer.body.actions {
table.push(action_row(action));
}
table.print_or(format_args!(
"`{}` no tiene acciones programadas. fundaia schedule add {} {} restart --cron \"0 3 * * *\"",
service.name, project.slug, service.slug
));
Ok(())
}
fn action_row(action: &ScheduledAction) -> Vec<Cell> {
let last = action.last_run.as_ref().map_or_else(
|| "—".to_owned(),
|run| {
let word = match run.status.as_str() {
"started" => "lanzada",
"done" => "hecha",
"held" => "retenida por una congelación",
"missed" => "perdida",
"failed" => "falló",
other => other,
};
format!("{word} {}", time::when(&run.fired_at))
},
);
vec![
Cell::styled(
sanitise::plain(&action.description),
if action.enabled {
theme::strong()
} else {
theme::muted()
},
),
Cell::styled(action.id.clone(), theme::muted()),
Cell::plain(
action
.next_run_at
.as_deref()
.map_or_else(|| "—".to_owned(), time::until),
),
Cell::styled(last, theme::muted()),
]
}
pub struct ActionRequest<'a> {
pub project: &'a str,
pub service: &'a str,
pub action: &'a str,
pub at: Option<&'a str>,
pub cron: Option<&'a str>,
pub timezone: Option<&'a str>,
pub confirm: Option<&'a str>,
}
pub async fn schedule(
client: &ApiClient,
request: ActionRequest<'_>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(request.project, request.service).await?;
let answer = client
.schedule_action(
&service.id,
&ScheduledActionDraft {
action: request.action,
at: request.at,
cron: request.cron,
timezone: request.timezone,
confirm: request.confirm,
},
)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let action = &answer.body.action;
println!(
" {} {} en {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&action.description).style(theme::strong()),
service.name.style(theme::strong()),
format!("({})", action.id).style(theme::muted())
);
Ok(())
}
pub async fn unschedule(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
action_id: &str,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = client
.remove_scheduled_action(&service.id, action_id)
.await?;
if format.is_json() {
output::emit(&answer);
return Ok(());
}
println!(
" {} Acción {} quitada de {}",
theme::CHECK.style(theme::success()),
action_id.style(theme::strong()),
service.name.style(theme::strong())
);
Ok(())
}
pub async fn auto_promotions(
client: &ApiClient,
project_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.auto_promotions(&project.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let mut table = Table::new(&["regla", "id", "sano", "servicios", "último"]);
for rule in &answer.body.rules {
table.push(rule_row(rule));
}
table.print_or(format_args!(
"`{}` no promueve nada solo. fundaia env auto-promote add {} staging production",
project.name, project.slug
));
Ok(())
}
fn services_of(rule: &AutoPromotion) -> String {
if rule.services.is_empty() {
return "todos".to_owned();
}
rule.services
.iter()
.map(|service| sanitise::plain(&service.name))
.collect::<Vec<_>>()
.join(", ")
}
fn rule_row(rule: &AutoPromotion) -> Vec<Cell> {
let services = services_of(rule);
let last = match (&rule.last_status, &rule.last_reason) {
(Some(status), Some(reason)) => format!("{status}: {}", sanitise::plain(reason)),
(Some(status), None) => status.clone(),
(None, _) => "sin revisar todavía".to_owned(),
};
vec![
Cell::styled(
format!(
"{} → {}",
sanitise::plain(&rule.from.name),
sanitise::plain(&rule.to.name)
),
theme::strong(),
),
Cell::styled(rule.id.clone(), theme::muted()),
Cell::plain(format!("{} min", rule.healthy_minutes)),
Cell::plain(services),
Cell::styled(last, theme::muted()),
]
}
pub struct RuleRequest<'a> {
pub project: &'a str,
pub from: &'a str,
pub to: &'a str,
pub services: &'a [String],
pub minutes: Option<u32>,
pub confirm: Option<&'a str>,
}
pub async fn auto_promote(
client: &ApiClient,
request: RuleRequest<'_>,
format: Format,
) -> Result<()> {
let project = client.find_project(request.project).await?;
let answer = client
.add_auto_promotion(
&project.id,
&AutoPromotionDraft {
from: request.from,
to: request.to,
services: request.services,
healthy_minutes: request.minutes,
confirm: request.confirm,
},
)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let rule = &answer.body.rule;
println!(
" {} Cuando {} lleve {} min sano, Fundaia lo promoverá a {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&rule.from.name).style(theme::strong()),
rule.healthy_minutes,
sanitise::plain(&rule.to.name).style(theme::strong()),
format!("({})", rule.id).style(theme::muted())
);
Ok(())
}
pub async fn remove_auto_promotion(
client: &ApiClient,
project_reference: &str,
rule_id: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.remove_auto_promotion(&project.id, rule_id).await?;
if format.is_json() {
output::emit(&answer);
return Ok(());
}
println!(
" {} Regla {} quitada: ya no se promueve sola",
theme::CHECK.style(theme::success()),
rule_id.style(theme::strong())
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::models::EnvironmentGlimpse;
fn production() -> EnvironmentGlimpse {
EnvironmentGlimpse {
id: "env_1".to_owned(),
name: "production".to_owned(),
slug: "production".to_owned(),
is_default: true,
}
}
#[test]
fn it_should_name_a_hook_as_what_was_held() {
let held = HeldDeployment {
service: "web".to_owned(),
trigger: "hook".to_owned(),
commit_sha: None,
git_tag: None,
hook: Some("CMS".to_owned()),
held_at: "2026-10-16T10:00:00.000Z".to_owned(),
};
assert_eq!(held_reason(&held), "el deploy hook CMS");
}
#[test]
fn it_should_say_a_rule_without_services_promotes_all_of_them() {
let rule = AutoPromotion {
id: "apm_1".to_owned(),
from: production(),
to: production(),
services: Vec::new(),
healthy_minutes: 10,
last_status: None,
last_reason: None,
last_promoted_at: None,
};
assert_eq!(services_of(&rule), "todos");
}
}