fundaia 0.4.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! Everything that changes something.
//!
//! Each of these prints one line saying what happened, and that line names the
//! thing it happened to. `deploy` is not here — it is long-lived enough to have
//! a view of its own.

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

use crate::api::client::{ApiClient, ApiError};
use crate::api::models::NewService;
use crate::render::theme;

pub async fn set_variable(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    key: &str,
    value: &str,
    secret: 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).await?;

    println!(
        "  {} {} en {}{}",
        theme::CHECK.style(theme::success()),
        key.style(theme::strong()),
        service.name.style(theme::strong()),
        if secret { " (secreto)" } else { "" },
    );
    println!(
        "  {}",
        "El próximo despliegue la recogerá; el contenedor actual no la ve.".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(())
}

/// Wires one service to another, letting the server decide what that means.
///
/// The CLI never invents the variables. Which keys a Postgres hands over is a
/// rule that lives in `domain/connectors.ts`, and duplicating it here would be
/// two implementations of one convention, drifting.
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,
) -> 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 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(())
}

/// What `fundaia new` was asked for, before any of it is resolved.
///
/// A struct rather than eight parameters, and not only to satisfy a lint: the
/// three sources are mutually exclusive and the check that says so reads far
/// better against one value than against a signature nobody can call without
/// consulting it.
pub struct ServiceRequest<'a> {
    pub project: &'a str,
    pub name: &'a str,
    pub repo: Option<&'a str>,
    pub image: Option<&'a str>,
    /// A component from the catalogue: `postgres-16`, `bucket`, `claude-code`.
    pub template: Option<&'a str>,
    pub branch: &'a str,
    /// Services it should be able to talk to, by whatever name a person types.
    pub connect_to: &'a [String],
}

/// Creates a service, and optionally wires it up in the same breath.
///
/// `--connect-to` accepts the names a person can see, resolved here rather than
/// on the server, because the server keys on ids and a CLI that demanded ids
/// would be a CLI nobody types twice.
pub async fn create_service(client: &ApiClient, request: ServiceRequest<'_>) -> Result<()> {
    let project = client.find_project(request.project).await?;

    let source_kind = match (request.repo, request.image, request.template) {
        (Some(_), None, None) => "github",
        (None, Some(_), None) => "image",
        (None, None, Some(_)) => "template",
        (None, None, None) => bail!("say where it comes from: --repo, --image or --template"),
        _ => bail!("--repo, --image and --template 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 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),
        // Every catalogue component is created as `database`, Claude Code
        // included, because that is what the web sends and two clients that
        // disagree produce two kinds of the same service. Nothing keys on it
        // anyway: a connector recognises its provider by the template key.
        kind: request.template.map(|_| "database".to_owned()),
        connect_to: providers,
    };

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

    Ok(())
}

/// Says what a `not_found` on a template can mean, because the server will not.
///
/// A component only the instance owner may add — `claude-code` today — answers
/// exactly like a key that does not exist. That is deliberate on the server's
/// side: which components an instance offers somebody else is not a stranger's
/// business. It makes a good answer and a useless message, so the tool names
/// both readings rather than printing a 404 nobody can interpret.
///
/// The original stays in the chain as the cause, and the exit code is unchanged:
/// this reworded a failure, it did not forgive one.
fn explain_template_refusal(error: anyhow::Error, key: &str) -> anyhow::Error {
    let missing = error
        .downcast_ref::<ApiError>()
        .is_some_and(|api| api.code == "not_found");

    if !missing {
        return error;
    }

    error.context(format!(
        "el componente `{key}` no está disponible: o no existe en esta instancia, \
         o solo su dueño puede añadirlo — `fundaia templates` lista los que \
         esta cuenta sí puede crear"
    ))
}

pub async fn templates(client: &ApiClient) -> Result<()> {
    for template in client.templates().await? {
        println!(
            "  {}  {}  {}",
            template.key.style(theme::strong()),
            template.display_name,
            template.image.style(theme::muted()),
        );
    }
    Ok(())
}

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

    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("solo su dueño 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"));
    }
}