use anyhow::{bail, Context, Result};
use owo_colors::OwoColorize;
use crate::api::client::{ApiClient, ApiError};
use crate::api::models::{
AgentSetup, AgentSetupPatch, CanvasPlacement, ClaudeCodeSetupRequest, NewService, Template,
TemplatesPage,
};
use crate::commands::dotenv;
use crate::output::{self, Format};
use crate::render::table::truncate;
use crate::render::theme;
pub async fn request_repository_access(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
email: Option<&str>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let sent = client.request_repository_access(&service.id, email).await?;
if format.is_json() {
output::emit(&sent);
return Ok(());
}
println!(
" enlace enviado a {} para leer {}",
sent.request
.email
.clone()
.unwrap_or_else(|| format!("@{}", sent.request.owner_login))
.style(theme::strong()),
sent.request.repo_full_name.style(theme::strong())
);
println!(" {}", sent.approve_url.style(theme::muted()));
println!();
println!(
" {}",
"ese enlace concede el acceso: trátalo como una credencial. El servicio se despliega solo en cuanto lo autorice."
.style(theme::muted())
);
Ok(())
}
pub async fn set_variable(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
key: &str,
value: &str,
secret: bool,
sealed: bool,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
client
.set_variable(&service.id, key, value, secret, sealed)
.await?;
println!(
" {} {} en {}{}",
theme::CHECK.style(theme::success()),
key.style(theme::strong()),
service.name.style(theme::strong()),
match (sealed, secret) {
(true, _) => " (sellada)",
(false, true) => " (secreto)",
_ => "",
},
);
if sealed {
println!(
" {}",
"Su valor ya no se puede leer ni cambiar en ningún sitio. Solo borrarla."
.style(theme::muted()),
);
}
println!(
" {}",
"El servicio se reinicia para recogerla. Dormido, la verá al despertar."
.style(theme::muted()),
);
Ok(())
}
pub async fn seal_variable(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
key: &str,
confirmation: Option<&str>,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let variables = client.variables(&service.id).await?;
let Some(variable) = variables.into_iter().find(|variable| variable.key == key) else {
bail!(
"`{}` no define ninguna variable llamada `{key}`",
service.name
);
};
if variable.is_sealed() {
println!(
" {} {} ya estaba sellada",
theme::CHECK.style(theme::success()),
key.style(theme::strong())
);
return Ok(());
}
if variable.kind == "reference" {
bail!("`{key}` es una referencia: no guarda ningún valor propio. Sella la variable a la que apunta.");
}
if confirmation != Some(key) {
bail!(
"escribe `--confirm {key}` para sellarla: dejará de poder verse y de poder cambiarse, y no se deshace"
);
}
client.seal_variable(&variable.id).await?;
println!(
" {} {} sellada en {}",
theme::CHECK.style(theme::success()),
key.style(theme::strong()),
service.name.style(theme::strong()),
);
println!(
" {}",
"El contenedor la sigue recibiendo. Nadie más vuelve a verla.".style(theme::muted()),
);
Ok(())
}
pub async fn unset_variable(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
key: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let variables = client.variables(&service.id).await?;
let Some(variable) = variables.into_iter().find(|variable| variable.key == key) else {
bail!(
"`{}` no define ninguna variable llamada `{key}`",
service.name
);
};
client.remove_variable(&variable.id).await?;
println!(
" {} {} eliminada",
theme::CHECK.style(theme::success()),
key.style(theme::strong())
);
Ok(())
}
pub async fn connect(
client: &ApiClient,
project_reference: &str,
consumer_reference: &str,
provider_reference: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let consumer = client.find_service(&project.id, consumer_reference).await?;
let provider = client.find_service(&project.id, provider_reference).await?;
let connection = client.connect(&consumer.id, &provider.id).await?;
println!(
" {} {} {} {}",
theme::CHECK.style(theme::success()),
consumer.name.style(theme::strong()),
theme::ARROW.style(theme::muted()),
connection.provider_name.style(theme::strong()),
);
println!(" {}", connection.summary.style(theme::muted()));
for key in &connection.keys {
println!(" {} {}", "+".style(theme::success()), key);
}
Ok(())
}
pub async fn candidates(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let candidates = client.candidates(&service.id).await?;
if format.is_json() {
output::emit(&candidates);
return Ok(());
}
if candidates.is_empty() {
println!("No hay nada a lo que `{}` pueda conectarse.", service.name);
return Ok(());
}
for candidate in &candidates {
println!(
" {} {}",
candidate.name.style(theme::strong()),
candidate.summary.style(theme::muted()),
);
}
Ok(())
}
pub async fn add_domain(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
host: Option<&str>,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let domain = client.add_domain(&service.id, host).await?;
println!(
" {} {}",
theme::CHECK.style(theme::success()),
domain.url.style(theme::accent())
);
if let Some(instructions) = &domain.dns_instructions {
println!(" {} {}", "!".style(theme::warning()), instructions);
}
Ok(())
}
pub async fn remove_domain(client: &ApiClient, domain_id: &str) -> Result<()> {
client.remove_domain(domain_id).await?;
println!(
" {} Dominio eliminado",
theme::CHECK.style(theme::success())
);
Ok(())
}
pub async fn stop(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
client.stop(&service.id).await?;
println!(
" {} {} parado — despertará con la siguiente petición",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
);
Ok(())
}
pub struct ServiceRequest<'a> {
pub project: &'a str,
pub name: &'a str,
pub repo: Option<&'a str>,
pub image: Option<&'a str>,
pub template: Option<&'a str>,
pub upload: bool,
pub branch: &'a str,
pub connect_to: &'a [String],
pub no_deploy: bool,
pub setup_token: Option<&'a str>,
pub model: Option<&'a str>,
pub effort: Option<&'a str>,
}
pub async fn create_service(
client: &ApiClient,
request: ServiceRequest<'_>,
format: Format,
) -> Result<()> {
let project = client.find_project(request.project).await?;
let source_kind = match (
request.repo,
request.image,
request.template,
request.upload,
) {
(Some(_), None, None, false) => "github",
(None, Some(_), None, false) => "image",
(None, None, Some(_), false) => "template",
(None, None, None, true) => "upload",
(None, None, None, false) => {
bail!("say where it comes from: --repo, --image, --template or --upload")
}
_ => bail!("--repo, --image, --template and --upload are alternatives, not a set"),
};
let mut providers = Vec::new();
for reference in request.connect_to {
providers.push(client.find_service(&project.id, reference).await?.id);
}
let setup_token = match (request.setup_token, request.template) {
(Some(token), _) => Some(token.to_owned()),
(None, Some(key)) => ask_setup_token(client, key, format).await?,
(None, None) => None,
};
let draft = NewService {
name: request.name.to_owned(),
source_kind: source_kind.to_owned(),
repo_full_name: request.repo.map(str::to_owned),
branch: request.repo.map(|_| request.branch.to_owned()),
image: request.image.map(str::to_owned),
template_key: request.template.map(str::to_owned),
kind: request.template.map(|_| "database".to_owned()),
connect_to: providers,
deploy_now: request.no_deploy.then_some(false),
claude: (setup_token.is_some() || request.model.is_some() || request.effort.is_some())
.then(|| ClaudeCodeSetupRequest {
setup_token,
model: request.model.map(str::to_owned),
effort: request.effort.map(str::to_owned),
}),
};
if let Err(error) = client.create_service(&project.id, &draft).await {
return Err(match request.template {
Some(key) => explain_template_refusal(error, key),
None => error,
});
}
println!(
" {} {} creado en {}",
theme::CHECK.style(theme::success()),
request.name.style(theme::strong()),
project.name.style(theme::strong()),
);
if !request.connect_to.is_empty() {
println!(
" {} conectado a {}",
theme::ARROW.style(theme::muted()),
request.connect_to.join(", "),
);
}
println!(
" {} {}",
theme::ARROW.style(theme::muted()),
if request.upload {
format!(
"sin código todavía — `fundaia up {} {}` desde el directorio del proyecto",
project.name, request.name
)
} else if request.no_deploy {
format!(
"sin desplegar — `fundaia deploy {} {}` cuando lo quieras",
project.name, request.name
)
} else {
format!("desplegando — `fundaia watch {}` para verlo", project.name)
},
);
Ok(())
}
fn explain_template_refusal(error: anyhow::Error, key: &str) -> anyhow::Error {
let Some(api) = error.downcast_ref::<ApiError>() else {
return error;
};
if api.code == "not_found" {
return error.context(format!(
"el componente `{key}` no está disponible: o no existe en esta instancia, \
o esta cuenta no puede añadirlo — `fundaia templates` lista los que \
sí puede crear"
));
}
if api.code == "validation_failed" && api.message.contains("token") {
return error.context(format!(
"el componente `{key}` gasta tu propia suscripción de Claude: pásale el token \
que imprime `claude setup-token` con --setup-token"
));
}
error
}
async fn ask_setup_token(client: &ApiClient, key: &str, format: Format) -> Result<Option<String>> {
let page = client.templates().await?;
let Some(setup) = page
.templates
.iter()
.find(|template| template.key == key)
.and_then(|template| template.setup.as_ref())
else {
return Ok(None);
};
if setup.token_optional {
return Ok(None);
}
let interactive = !format.is_json() && std::io::IsTerminal::is_terminal(&std::io::stdin());
if !interactive {
bail!(
"el componente `{key}` gasta tu propia suscripción de Claude: pásale el token \
que imprime `claude setup-token` con --setup-token"
);
}
eprintln!(
" El componente {} gasta tu propia suscripción de Claude.",
key.style(theme::strong()),
);
eprintln!(
" {}",
"Genera el token en tu ordenador con `claude setup-token` y pégalo aquí; no se ve al escribir."
.style(theme::muted()),
);
eprint!(" Setup token: ");
let token = rpassword::read_password().context("no se pudo leer el token del teclado")?;
let token = token.trim().to_owned();
if token.is_empty() {
bail!("sin token no se puede crear el componente `{key}`: pásalo con --setup-token o vuelve a intentarlo");
}
Ok(Some(token))
}
pub async fn agent(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
patch: AgentSetupPatch,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let changed = !patch.is_empty();
let setup = if changed {
client.configure_agent(&service.id, &patch).await?
} else {
client.agent_setup(&service.id).await?
};
if format.is_json() {
output::emit(&setup);
return Ok(());
}
if changed {
println!(
" {} Agente {} configurado — el contenedor se reinicia para recogerlo",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
);
} else {
println!();
println!(" {}", service.name.style(theme::strong()));
}
print_agent_setup(&setup);
Ok(())
}
fn print_agent_setup(setup: &AgentSetup) {
println!(
" {} {}{}",
"modelo".style(theme::muted()),
setup.model.style(theme::accent()),
label_of(&setup.models, &setup.model),
);
println!(
" {} {}{}",
"esfuerzo".style(theme::muted()),
setup.effort.style(theme::accent()),
label_of(&setup.efforts, &setup.effort),
);
println!(
" {} {}",
"token".style(theme::muted()),
credential_word(setup.credential.configured, setup.credential.sealed),
);
}
fn label_of(options: &[crate::api::models::ClaudeOption], id: &str) -> String {
options
.iter()
.find(|one| one.id == id)
.map(|one| format!(" · {}", one.label))
.unwrap_or_default()
}
pub fn credential_word(configured: bool, sealed: bool) -> &'static str {
match (configured, sealed) {
(false, _) => "ninguno — cada prompt fallará hasta que pegues uno",
(true, true) => "el del servidor, sellado: el contenedor lo recibe y nadie lo lee",
(true, false) => "propio, configurado",
}
}
pub fn ids_of(options: &[crate::api::models::ClaudeOption], default: &str) -> String {
options
.iter()
.map(|one| {
if one.id == default {
format!("{}*", one.id)
} else {
one.id.clone()
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn entries_of<'a>(page: &'a TemplatesPage, category: &str) -> Vec<&'a Template> {
page.templates
.iter()
.filter(|template| template.category == category)
.collect()
}
fn ungrouped(page: &TemplatesPage) -> Vec<&Template> {
let known: Vec<&str> = page.categories.iter().map(|one| one.id.as_str()).collect();
page.templates
.iter()
.filter(|template| !known.contains(&template.category.as_str()))
.collect()
}
pub async fn templates(client: &ApiClient, format: Format) -> Result<()> {
let page = client.templates().await?;
if format.is_json() {
output::emit(&page);
return Ok(());
}
for category in &page.categories {
let entries = entries_of(&page, &category.id);
if entries.is_empty() {
continue;
}
println!("\n{}", category.label.style(theme::strong()));
for template in entries {
println!(
" {} {}",
template.key.style(theme::accent()),
template.display_name,
);
if !template.summary.is_empty() {
println!(" {}", template.summary.style(theme::muted()));
}
if let Some(setup) = &template.setup {
println!(
" {} {}",
"--model".style(theme::muted()),
ids_of(&setup.models, &setup.default_model),
);
println!(
" {} {}",
"--effort".style(theme::muted()),
ids_of(&setup.efforts, &setup.default_effort),
);
println!(
" {} {}",
"--setup-token".style(theme::muted()),
if setup.token_optional {
"no hace falta: tus componentes usan el del servidor"
} else {
"obligatorio: lo imprime `claude setup-token`"
}
.style(theme::muted()),
);
}
}
}
let orphans = ungrouped(&page);
if !orphans.is_empty() {
println!("\n{}", "Otros".style(theme::strong()));
for template in orphans {
println!(
" {} {}",
template.key.style(theme::accent()),
template.display_name
);
}
}
Ok(())
}
pub async fn create_project(
client: &ApiClient,
name: &str,
description: Option<&str>,
workspace_reference: Option<&str>,
) -> Result<()> {
let workspace = match workspace_reference {
Some(reference) => Some(client.find_workspace(Some(reference)).await?),
None => None,
};
let project = client
.create_project(name, description, workspace.as_ref().map(|w| w.id.as_str()))
.await?;
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
project.name.style(theme::strong()),
format!("({})", project.slug).style(theme::muted()),
);
println!(
" {}",
format!("fundaia new {} <nombre> --repo <owner/repo>", project.slug).style(theme::muted()),
);
Ok(())
}
pub async fn attach_volume(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
mount_path: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let volume = client.attach_volume(&service.id, mount_path).await?;
println!(
" {} {} en {}",
theme::CHECK.style(theme::success()),
volume.mount_path.style(theme::strong()),
service.name.style(theme::strong()),
);
println!(
" {}",
"Estará dentro del contenedor a partir del próximo despliegue.".style(theme::muted()),
);
Ok(())
}
pub async fn resize_volume(client: &ApiClient, volume_id: &str, gigabytes: u64) -> Result<()> {
let volume = client.resize_volume(volume_id, gigabytes * 1024).await?;
println!(
" {} {} pasa a {} GB",
theme::CHECK.style(theme::success()),
volume.mount_path.style(theme::strong()),
gigabytes.style(theme::strong()),
);
println!(
" {}",
"El contenedor sigue como estaba: esto es lo que el plan reserva, no un montaje."
.style(theme::muted()),
);
Ok(())
}
pub async fn edit_recipe(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
file: &str,
) -> Result<()> {
let containerfile = read_text(file)?;
if containerfile.trim().is_empty() {
bail!("el Containerfile está vacío");
}
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
client.edit_recipe(&service.id, &containerfile).await?;
println!(
" {} Containerfile de {}",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
);
println!(
" {}",
"A partir de ahora se despliega este, no el generado.".style(theme::muted()),
);
Ok(())
}
pub async fn import_variables(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
file: &str,
secret: bool,
) -> Result<()> {
let variables = dotenv::variables_in(&read_text(file)?, secret);
if variables.is_empty() {
bail!("no hay ninguna variable en {file}");
}
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let imported = client.import_variables(&service.id, &variables).await?;
println!(
" {} {} en {}{}",
theme::CHECK.style(theme::success()),
format!("{imported} variables").style(theme::strong()),
service.name.style(theme::strong()),
if secret { " (secretas)" } else { "" },
);
for variable in variables.iter().take(5) {
println!(" {}", variable.key.style(theme::muted()));
}
if variables.len() > 5 {
println!(
" {}",
format!("y {} más", variables.len() - 5).style(theme::muted())
);
}
Ok(())
}
fn read_text(file: &str) -> Result<String> {
if file == "-" {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
.context("no se ha podido leer la entrada estándar")?;
return Ok(text);
}
std::fs::read_to_string(file).with_context(|| format!("no se ha podido leer {file}"))
}
pub async fn take_backup(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
println!(
" {} copiando el volumen de {}…",
theme::ARROW.style(theme::muted()),
service.name.style(theme::strong()),
);
let backup = client.take_backup(&service.id).await?;
if backup.status == "failed" {
bail!(
"la copia falló: {}",
backup
.failure_message
.unwrap_or_else(|| "sin motivo".to_owned())
);
}
println!(
" {} copia {} hecha",
theme::CHECK.style(theme::success()),
backup.id.style(theme::strong()),
);
if let Some(reason) = &backup.replication_error {
println!(
" {} está en el servidor, pero no se pudo subir al remoto: {}",
theme::CROSS.style(theme::warning()),
reason
);
} else if backup.replicated_at.is_some() {
println!(
" {}",
"También está fuera de la máquina.".style(theme::muted())
);
}
Ok(())
}
pub async fn schedule_backups(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
schedules: &[String],
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let applied = client.schedule_backups(&service.id, schedules).await?;
if applied.is_empty() {
println!(
" {} {} ya no se copia solo",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
);
println!(
" {}",
"Las copias que ya existen se quedan donde están.".style(theme::muted())
);
return Ok(());
}
println!(
" {} {} se copiará: {}",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
applied.join(", ").style(theme::strong()),
);
Ok(())
}
pub async fn restore_backup(
client: &ApiClient,
backup_id: &str,
confirmation: Option<&str>,
) -> Result<()> {
if confirmation != Some(backup_id) {
bail!(
"escribe `--confirm {backup_id}` para restaurarla: se sobrescribe el volumen entero y el servicio se reinicia"
);
}
println!(
" {} copia de seguridad previa, parando el servicio y restaurando…",
theme::ARROW.style(theme::muted()),
);
let backup = client.restore_backup(backup_id).await?;
println!(
" {} volumen restaurado desde {}",
theme::CHECK.style(theme::success()),
backup.id.style(theme::strong()),
);
println!(
" {}",
"Antes de sobrescribir se guardó una copia del estado anterior.".style(theme::muted()),
);
Ok(())
}
pub async fn lock_backup(client: &ApiClient, backup_id: &str, locked: bool) -> Result<()> {
let backup = client.lock_backup(backup_id, locked).await?;
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
backup.id.style(theme::strong()),
if locked {
"guardada — la retención ya no la borrará"
} else {
"vuelve a estar sujeta a la retención"
},
);
Ok(())
}
pub async fn delete_backup(client: &ApiClient, backup_id: &str) -> Result<()> {
client.delete_backup(backup_id).await?;
println!(
" {} copia {} eliminada",
theme::CHECK.style(theme::success()),
backup_id.style(theme::strong()),
);
Ok(())
}
pub async fn edit_service_config(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
file: &str,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let content = read_text(file)?;
if content.trim().is_empty() {
bail!("la configuración está vacía");
}
client.edit_service_config(&found.id, &content).await?;
println!(
" {} configuración de {}",
theme::CHECK.style(theme::success()),
found.name.style(theme::strong()),
);
println!(
" {}",
"editada a mano — el formulario ya no la sobrescribe".style(theme::muted()),
);
Ok(())
}
pub async fn decide_incident(
client: &ApiClient,
incident_id: &str,
status: &str,
format: Format,
) -> Result<()> {
let incident = client.decide_incident(incident_id, status).await?;
if format.is_json() {
output::emit(&incident);
return Ok(());
}
println!(
" {} incidencia {} · {}",
theme::CHECK.style(theme::success()),
incident.status,
truncate(&incident.shape, 60)
);
Ok(())
}
pub async fn approve_deployment(
client: &ApiClient,
approval_id: &str,
format: Format,
) -> Result<()> {
let deployment = client.approve_deployment(approval_id).await?;
if format.is_json() {
output::emit(&deployment);
return Ok(());
}
println!(
" {} aprobado · despliegue #{} · sigue con `fundaia logs`",
theme::CHECK.style(theme::success()),
deployment.number
);
Ok(())
}
pub async fn reject_deployment(
client: &ApiClient,
approval_id: &str,
format: Format,
) -> Result<()> {
let answer = client.reject_deployment(approval_id).await?;
if format.is_json() {
output::emit(&answer);
return Ok(());
}
println!(
" {} rechazado · ese commit no se desplegará",
theme::CHECK.style(theme::success())
);
Ok(())
}
pub async fn fix_incident(client: &ApiClient, incident_id: &str, format: Format) -> Result<()> {
let run = client.fix_incident(incident_id).await?;
if format.is_json() {
output::emit(&run);
return Ok(());
}
println!(
" {} arreglando · {} · sigue con `fundaia autofix`",
theme::CHECK.style(theme::success()),
run.id
);
Ok(())
}
pub async fn move_on_canvas(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
x: i64,
y: i64,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let view = client
.place_on_canvas(
&project.id,
&[CanvasPlacement {
service_id: service.service_id.clone(),
x,
y,
}],
)
.await?;
if format.is_json() {
output::emit(&view);
return Ok(());
}
let placed = view
.nodes
.iter()
.find(|node| node.service_id == service.service_id);
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
service.name.style(theme::strong()),
match placed {
Some(node) => format!("({}, {})", node.x, node.y)
.style(theme::muted())
.to_string(),
None => String::new(),
},
);
Ok(())
}
pub async fn arrange_canvas(
client: &ApiClient,
project_reference: &str,
layout: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let view = client.arrange_canvas(&project.id, layout).await?;
if format.is_json() {
output::emit(&view);
return Ok(());
}
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
project.name.style(theme::strong()),
format!("{} servicios, en {}", view.nodes.len(), layout).style(theme::muted()),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::models::TemplateCategory;
use reqwest::StatusCode;
fn template(key: &str, category: &str) -> Template {
Template {
key: key.to_owned(),
display_name: key.to_owned(),
image: String::new(),
category: category.to_owned(),
summary: String::new(),
memory_mb: 512,
setup: None,
}
}
fn catalogue() -> TemplatesPage {
TemplatesPage {
templates: vec![
template("postgres-16", "data"),
template("traefik-3", "traffic"),
template("something-new", "quantum"),
],
categories: vec![TemplateCategory {
id: "data".to_owned(),
label: "Datos".to_owned(),
summary: String::new(),
}],
}
}
#[test]
fn it_should_group_a_component_under_its_own_category() {
assert_eq!(entries_of(&catalogue(), "data").len(), 1);
}
#[test]
fn it_should_leave_a_category_with_nothing_in_it_empty() {
assert!(entries_of(&catalogue(), "agents").is_empty());
}
#[test]
fn it_should_still_list_a_component_whose_category_it_does_not_know() {
let page = catalogue();
assert_eq!(ungrouped(&page).len(), 2);
}
fn refusal(code: &str) -> anyhow::Error {
ApiError {
status: StatusCode::NOT_FOUND,
code: code.to_owned(),
message: "template 'claude-code' does not exist".to_owned(),
}
.into()
}
#[test]
fn it_should_say_who_may_add_a_component_the_server_refused() {
let explained = explain_template_refusal(refusal("not_found"), "claude-code");
assert!(explained
.to_string()
.contains("esta cuenta no puede añadirlo"));
}
#[test]
fn it_should_name_the_component_it_could_not_add() {
let explained = explain_template_refusal(refusal("not_found"), "claude-code");
assert!(explained.to_string().contains("claude-code"));
}
#[test]
fn it_should_keep_the_server_answer_as_the_cause() {
let explained = explain_template_refusal(refusal("not_found"), "claude-code");
assert!(explained.downcast_ref::<ApiError>().is_some());
}
#[test]
fn it_should_leave_a_failure_that_is_not_a_missing_component_alone() {
let explained = explain_template_refusal(refusal("conflict"), "claude-code");
assert!(!explained.to_string().contains("dueño"));
}
fn option(id: &str) -> crate::api::models::ClaudeOption {
crate::api::models::ClaudeOption {
id: id.to_owned(),
label: id.to_owned(),
summary: String::new(),
}
}
#[test]
fn it_should_mark_the_default_among_the_ids() {
let ids = ids_of(&[option("low"), option("high"), option("max")], "high");
assert_eq!(ids, "low, high*, max");
}
#[test]
fn it_should_never_print_a_token_only_whether_there_is_one() {
assert!(!credential_word(true, false).contains("sk-ant"));
}
#[test]
fn it_should_say_a_missing_token_will_fail_every_prompt() {
assert!(credential_word(false, false).contains("fallará"));
}
#[test]
fn it_should_send_nothing_about_claude_when_nothing_was_said() {
let patch = AgentSetupPatch {
model: None,
effort: None,
setup_token: None,
};
assert!(patch.is_empty());
}
}