fundaia 0.55.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::io::Read;

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

use crate::api::client::ApiClient;
use crate::api::models::{DataStatus, ExecutionOutcome, QueryOutcome};
use crate::exit::Refused;
use crate::output::{self, Format};
use crate::render::table::{truncate, Cell, Table};
use crate::render::theme;

const CELL_WIDTH: usize = 48;

pub async fn tables(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let overview = client.data_overview(&service.id).await?;

    if format.answered_in_json(&overview) {
        return Ok(());
    }

    if !overview.status.reachable {
        return Err(unreachable(&service.name, &overview.status).into());
    }

    let documents = overview.status.engine.as_deref() == Some("mongodb");

    if overview.relations.is_empty() {
        println!(
            "`{}` está vacía: todavía no tiene {}.",
            service.name,
            if documents {
                "ninguna colección"
            } else {
                "ninguna tabla (ninguna migración se ha ejecutado contra ella)"
            }
        );
        return Ok(());
    }

    let mut table = Table::new(&[
        if documents { "colección" } else { "tabla" },
        "tipo",
        "columnas",
        if documents { "documentos" } else { "filas" },
    ]);
    for relation in &overview.relations {
        table.push(vec![
            Cell::styled(relation.name.clone(), theme::strong()),
            Cell::styled(
                match (relation.kind.as_str(), documents) {
                    ("view", _) => "vista",
                    (_, true) => "colección",
                    _ => "tabla",
                },
                theme::muted(),
            ),
            Cell::plain(if documents {
                "—".to_owned()
            } else {
                relation.column_count.to_string()
            }),
            Cell::plain(relation.estimated_rows.map_or_else(
                || "—".to_owned(),
                |rows| format!("~{}", rows.round() as u64),
            )),
        ]);
    }

    table.print();
    Ok(())
}

pub async fn query(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    sql: &str,
    limit: Option<u64>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let outcome = client
        .query_data(&service.id, &statement_from(sql)?, limit)
        .await?;

    if format.is_json() {
        output::emit(&outcome);
        return refused_or_ok(outcome.failure.as_deref());
    }

    if let Some(failure) = &outcome.failure {
        return Err(refused(failure).into());
    }

    print_rows(&outcome.columns, &outcome.rows);
    print_query_footer(&outcome);
    Ok(())
}

pub async fn run(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    sql: &str,
    preview: bool,
    confirmation: Option<&str>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;

    if !preview
        && confirmation != Some(service.name.as_str())
        && confirmation != Some(service.slug.as_str())
    {
        bail!(
            "esto cambia los datos de `{}` y no se puede deshacer: pruébalo con --preview, y para ejecutarlo repite el nombre con --confirm {}",
            service.name,
            service.slug,
        );
    }

    let outcome = client
        .execute_sql(&service.id, &statement_from(sql)?, preview)
        .await?;

    if format.is_json() {
        output::emit(&outcome);
        return refused_or_ok(outcome.failure.as_deref());
    }

    if let Some(failure) = &outcome.failure {
        return Err(refused(failure).into());
    }

    print_execution(&outcome);
    Ok(())
}

fn statement_from(argument: &str) -> Result<String> {
    if argument != "-" {
        return Ok(argument.to_owned());
    }

    let mut read = String::new();
    std::io::stdin().read_to_string(&mut read)?;
    Ok(read)
}

fn print_rows(columns: &[String], rows: &[Vec<Option<String>>]) {
    if rows.is_empty() {
        return;
    }

    let headers: Vec<&str> = columns.iter().map(String::as_str).collect();
    let mut table = Table::new(&headers);

    for row in rows {
        table.push(
            row.iter()
                .map(|cell| match cell {
                    None => Cell::styled("NULL", theme::muted()),
                    Some(text) => Cell::plain(truncate(text, CELL_WIDTH)),
                })
                .collect(),
        );
    }

    table.print();
}

fn print_query_footer(outcome: &QueryOutcome) {
    let count = outcome.rows.len();
    let mut line = format!("{count} {}", if count == 1 { "fila" } else { "filas" });
    if outcome.has_more {
        line.push_str(&format!(
            " · hay más, se muestran las primeras {}",
            outcome.limit
        ));
    }
    println!("  {}", line.style(theme::muted()));
    print_truncations(outcome.cells_truncated, outcome.rows_dropped);
}

fn print_execution(outcome: &ExecutionOutcome) {
    let command = outcome.command.as_deref().unwrap_or("OK");
    let touched = match outcome.affected_rows {
        Some(1) => "1 fila".to_owned(),
        Some(rows) => format!("{rows} filas"),
        None => String::new(),
    };

    if outcome.committed {
        println!(
            "  {} {} {}",
            theme::CHECK.style(theme::success()),
            command.style(theme::strong()),
            touched.style(theme::muted()),
        );
    } else if outcome.rehearsal.as_deref() == Some("unavailable") {
        println!(
            "  {} {}",
            "sin ensayo".style(theme::warning()),
            "· esta base de datos no puede deshacer esta sentencia, así que no se ha ejecutado nada; comprueba qué tocaría con `fundaia data query` y ejecútala con --confirm"
                .style(theme::muted()),
        );
        return;
    } else {
        println!(
            "  {} {} {} {}",
            "ensayo".style(theme::warning()),
            command.style(theme::strong()),
            touched.style(theme::muted()),
            "· deshecho, nada ha cambiado".style(theme::muted()),
        );
    }

    print_rows(&outcome.columns, &outcome.rows);
    print_truncations(outcome.cells_truncated, outcome.rows_dropped);
}

fn print_truncations(cells: bool, rows: bool) {
    if cells {
        println!(
            "  {}",
            "los valores muy largos se han cortado (acaban en …)".style(theme::muted())
        );
    }
    if rows {
        println!(
            "  {}",
            "la respuesta llegó a su tamaño máximo y quedaron filas fuera".style(theme::muted())
        );
    }
}

fn refused(failure: &str) -> Refused {
    Refused::invalid(format!("la base de datos lo rechazó: {failure}"))
}

fn refused_or_ok(failure: Option<&str>) -> Result<()> {
    match failure {
        Some(failure) => Err(refused(failure).into()),
        None => Ok(()),
    }
}

fn unreachable(service: &str, status: &DataStatus) -> Refused {
    let words = match status.reason.as_deref() {
        Some("not-a-database") => "no es una base de datos que la plataforma sepa leer: solo los componentes PostgreSQL, MySQL, SQLite, MongoDB y ClickHouse del catálogo".to_owned(),
        Some("never-deployed") => "todavía no se ha desplegado".to_owned(),
        Some("asleep") => "está durmiendo, y este comando no la despierta a propósito: arráncala con `fundaia deploy`".to_owned(),
        Some("stopped") => "no tiene el contenedor en marcha".to_owned(),
        Some("unconfigured") if status.missing_variables.is_empty() => {
            "no tiene en sus variables las credenciales que sembró su plantilla".to_owned()
        }
        Some("unconfigured") => format!(
            "no tiene {} en sus variables, y sin ellas no hay con qué conectarse",
            status.missing_variables.join(", ")
        ),
        _ => "no se puede leer ahora mismo".to_owned(),
    };
    Refused::invalid(format!("`{service}` {words}"))
}