fundaia 0.55.3

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use anyhow::Result;
use owo_colors::{OwoColorize, Style};

use crate::api::client::{ApiClient, RequestFilter};
use crate::api::models::{InspectedRequest, RequestSummary};
use crate::output::{self, Format};
use crate::render::sanitise;
use crate::render::theme;
use crate::render::time;

pub struct RequestQuery<'a> {
    pub status: Option<&'a str>,
    pub method: Option<&'a str>,
    pub path: Option<&'a str>,
    pub follow: bool,
    pub limit: usize,
}

pub async fn run(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    query: RequestQuery<'_>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let filter = RequestFilter {
        status: query.status,
        method: query.method,
        path: query.path,
        limit: Some(query.limit),
    };

    if !query.follow {
        let answer = client.requests(&service.id, filter).await?;
        if format.answered_in_json(&answer.raw) {
            return Ok(());
        }

        let window = &answer.body;
        if window.requests.is_empty() {
            println!(
                "`{}` no ha recibido ninguna petición{} desde que arrancó la plataforma.",
                sanitise::plain(&service.name),
                if filtered(&query) {
                    " que coincida"
                } else {
                    ""
                },
            );
            return Ok(());
        }

        let mut out = output::Lines::lock();
        for request in &window.requests {
            out.line(render_request(request));
        }
        out.finish();
        println!();
        println!("  {}", summary_line(&window.summary, window.capacity));
        return Ok(());
    }

    if !format.is_json() {
        println!(
            "  {} {} · peticiones en vivo · Ctrl-C para salir",
            theme::ARROW.style(theme::accent()),
            sanitise::plain(&service.name).style(theme::strong()),
        );
    }

    client
        .stream_requests(&service.id, filter, |request| {
            if format.is_json() {
                output::emit(&request);
            } else {
                println!("{}", render_request(&request));
            }
        })
        .await
}

fn filtered(query: &RequestQuery<'_>) -> bool {
    query.status.is_some() || query.method.is_some() || query.path.is_some()
}

pub fn render_request(request: &InspectedRequest) -> String {
    let status = format!("{:>3}", request.status);
    let duration = format!(
        "{:>7}",
        time::format_millis(i64::try_from(request.duration_millis).unwrap_or(i64::MAX))
    );
    let client = request
        .client_address
        .as_deref()
        .map(|address| format!("  {}", sanitise::plain(address).style(theme::muted())))
        .unwrap_or_default();

    format!(
        "{}  {:<7} {} {} {}{}{}",
        clock_of(&request.at).style(theme::muted()),
        sanitise::plain(&request.method),
        status.style(status_style(request.status)),
        duration.style(theme::muted()),
        sanitise::plain(&request.path),
        client,
        marks_of(request),
    )
}

fn summary_line(summary: &RequestSummary, capacity: u32) -> String {
    let millis = |value: Option<u64>| {
        value.map_or_else(
            || "—".to_owned(),
            |millis| time::format_millis(i64::try_from(millis).unwrap_or(i64::MAX)),
        )
    };
    let errors = summary
        .server_error_percent
        .map_or_else(|| "—".to_owned(), |percent| format!("{percent} %"));

    format!(
        "{} {} · p50 {} · p95 {} · 5xx {}",
        summary.total.style(theme::strong()),
        if summary.total == 1 {
            "petición".to_owned()
        } else {
            format!("peticiones (de las últimas {capacity})")
        },
        millis(summary.p50_millis),
        millis(summary.p95_millis),
        errors,
    )
}

fn status_style(status: u16) -> Style {
    match status / 100 {
        2 => theme::success(),
        3 => theme::info(),
        4 => theme::warning(),
        5 => theme::danger(),
        _ => theme::muted(),
    }
}

fn marks_of(request: &InspectedRequest) -> String {
    let mut marks: Vec<String> = Vec::new();
    if let Some(blocked) = request.blocked_by.as_deref() {
        marks.push(
            format!("[{}]", block_word(blocked))
                .style(theme::danger())
                .to_string(),
        );
    }
    if request.cached {
        marks.push("[caché]".style(theme::success()).to_string());
    }
    if request.woke {
        marks.push("[despertó]".style(theme::accent()).to_string());
    }
    if request.web_socket {
        marks.push("[websocket]".style(theme::info()).to_string());
    }

    if marks.is_empty() {
        String::new()
    } else {
        format!("  {}", marks.join(" "))
    }
}

fn block_word(blocked: &str) -> String {
    match blocked {
        "allowlist" => "ip no permitida".to_owned(),
        "rate-limit" => "límite por ip".to_owned(),
        "maintenance" => "mantenimiento".to_owned(),
        "password" => "sin contraseña".to_owned(),
        "redirect" => "redirección".to_owned(),
        "cors" => "preflight cors".to_owned(),
        "route" => "ruta rota".to_owned(),
        other => sanitise::plain(other),
    }
}

fn clock_of(at: &str) -> String {
    match at.split('T').nth(1) {
        Some(time) if time.len() >= 8 => time[..8].to_owned(),
        _ => format!("{at:<8.8}"),
    }
}

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

    fn request() -> InspectedRequest {
        InspectedRequest {
            sequence: 1,
            at: "2026-10-16T10:42:07.120Z".to_owned(),
            method: "GET".to_owned(),
            path: "/api/pedidos?page=…".to_owned(),
            status: 429,
            duration_millis: 3,
            client_address: Some("203.0.113.7".to_owned()),
            user_agent: None,
            cached: false,
            woke: false,
            web_socket: false,
            blocked_by: Some("rate-limit".to_owned()),
        }
    }

    #[test]
    fn it_should_draw_a_request_on_one_line() {
        let drawn = theme::silenced(|| render_request(&request()));
        assert_eq!(
            drawn,
            "10:42:07  GET     429     3ms /api/pedidos?page=…  203.0.113.7  [límite por ip]"
        );
    }

    #[test]
    fn it_should_sum_up_what_it_drew() {
        let summary = RequestSummary {
            total: 42,
            p50_millis: Some(12),
            p95_millis: Some(230),
            server_error_percent: Some(2.4),
        };
        assert_eq!(
            theme::silenced(|| summary_line(&summary, 500)),
            "42 peticiones (de las últimas 500) · p50 12ms · p95 230ms · 5xx 2.4 %"
        );
    }
}