sdd-layer 0.26.0

Spec-Driven Development CLI and agent harness
//! Componente de barras horizontais para o dashboard de uso de tokens (T-14).
//!
//! Funções puras, sem estado nem I/O. Retornam [`Line`] prontas para renderização
//! com `ratatui`. O estilo visual é inspirado no idioma "YOU BURNED THIS MANY TOKENS":
//! blocos preenchidos (`█`) indicam o consumo real; pontos (`░`) indicam o espaço
//! restante — ou toda a barra em modo comparativo (`filled = false`).

#![allow(dead_code)]

use crate::tui::theme::Theme;
use ratatui::{
    style::{Modifier, Style},
    text::{Line, Span},
};

// ---------------------------------------------------------------------------
// Barra individual
// ---------------------------------------------------------------------------

/// Gera uma linha de barra horizontal para um único valor.
///
/// # Parâmetros
/// - `label`     — rótulo exibido à esquerda, alinhado em 14 caracteres.
/// - `value`     — valor a representar (clampado em `max`).
/// - `max`       — valor máximo da escala; se `0`, a fração é tratada como `0`.
/// - `bar_width` — largura total da barra em células do terminal (mínimo efetivo: 1).
/// - `filled`    — `true` = barras preenchidas (modo principal);
///   `false` = toda a barra usa `░` (modo comparativo/referência).
/// - `theme`     — tokens semânticos de cor.
///
/// # Formato de saída
/// ```text
/// "label          [████░░░░░░░░░░] 1234"
/// ```
pub fn bar(
    label: &str,
    value: u64,
    max: u64,
    bar_width: u16,
    filled: bool,
    theme: &Theme,
) -> Line<'static> {
    // Largura mínima de 1 para evitar strings vazias.
    let width = bar_width.max(1) as usize;

    // Fração clampada a [0, 1]; max == 0 → fração 0 (sem divisão por zero).
    let fraction: f64 = if max == 0 {
        0.0
    } else {
        // Clamp: value pode ultrapassar max.
        (value.min(max) as f64) / (max as f64)
    };

    // Número de células preenchidas, garantindo que não ultrapasse `width`.
    let filled_cells = ((fraction * width as f64).round() as usize).min(width);
    let empty_cells = width - filled_cells;

    // Estilo da parte preenchida: acento primário, negrito.
    let style_filled = Style::default()
        .fg(theme.primary)
        .add_modifier(Modifier::BOLD);

    // Estilo da parte vazia / modo comparativo: texto atenuado.
    let style_muted = Style::default().fg(theme.text_muted);

    // Construção dos segmentos da barra.
    let (filled_str, empty_str): (String, String) = if filled {
        ("".repeat(filled_cells), "".repeat(empty_cells))
    } else {
        // Modo comparativo: toda a barra é pontilhada.
        (String::new(), "".repeat(width))
    };

    // Rótulo alinhado à esquerda em 14 caracteres.
    let label_padded = format!("{:<14}", label);

    let mut spans: Vec<Span> = Vec::with_capacity(6);
    spans.push(Span::raw(label_padded));
    spans.push(Span::raw(" ["));

    if filled && !filled_str.is_empty() {
        spans.push(Span::styled(filled_str, style_filled));
    }
    if !empty_str.is_empty() {
        spans.push(Span::styled(empty_str, style_muted));
    }

    spans.push(Span::raw("] "));
    spans.push(Span::styled(
        value.to_string(),
        Style::default().fg(theme.text_strong),
    ));

    Line::from(spans)
}

// ---------------------------------------------------------------------------
// Dashboard (múltiplas barras)
// ---------------------------------------------------------------------------

/// Gera um bloco de linhas prontas para o dashboard de tokens.
///
/// # Parâmetros
/// - `title`     — título da seção, exibido em negrito na cor de acento.
/// - `rows`      — fatias `(label, value)` a exibir.
/// - `max`       — escala comum para todas as barras.
/// - `bar_width` — largura da barra em células (repassada a [`bar`]).
/// - `theme`     — tokens semânticos de cor.
///
/// # Retorno
/// `Vec<Line>` com: 1 linha de título + 1 linha por entrada em `rows`.
pub fn dashboard(
    title: &str,
    rows: &[(String, u64)],
    max: u64,
    bar_width: u16,
    theme: &Theme,
) -> Vec<Line<'static>> {
    let mut lines: Vec<Line<'static>> = Vec::with_capacity(rows.len() + 1);

    // Linha de título: acento + negrito.
    lines.push(Line::from(Span::styled(
        title.to_owned(),
        Style::default()
            .fg(theme.section_header)
            .add_modifier(Modifier::BOLD),
    )));

    for (label, value) in rows {
        lines.push(bar(label, *value, max, bar_width, true, theme));
    }

    lines
}

// ---------------------------------------------------------------------------
// Testes
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::theme::{theme, ColorDepth};

    fn mk_theme() -> Theme {
        theme(ColorDepth::Ansi16)
    }

    // Extrai o conteúdo textual de uma Line concatenando todos os spans.
    fn line_text(line: &Line) -> String {
        line.spans.iter().map(|s| s.content.as_ref()).collect()
    }

    // --- bar: casos básicos ---

    #[test]
    fn bar_value_zero_sem_celulas_preenchidas() {
        let t = mk_theme();
        let line = bar("input", 0, 1000, 20, true, &t);
        let text = line_text(&line);
        // Deve conter apenas `░` na barra, sem `█`.
        assert!(
            !text.contains(''),
            "value=0 não deve ter células preenchidas"
        );
        assert!(text.contains(''), "value=0 deve ter células vazias");
    }

    #[test]
    fn bar_value_igual_max_preenche_tudo() {
        let t = mk_theme();
        let line = bar("output", 500, 500, 10, true, &t);
        let text = line_text(&line);
        assert!(
            text.contains(''),
            "value==max deve ter células preenchidas"
        );
        // Não deve haver células vazias dentro dos colchetes.
        // O texto de rodapé "500" pode conter zeros, então verificamos
        // que não há `░` na porção da barra.
        let barra = extrair_barra(&text);
        assert!(
            !barra.contains(''),
            "value==max não deve ter células vazias"
        );
    }

    #[test]
    fn bar_value_maior_que_max_clampado() {
        let t = mk_theme();
        // value > max → deve ser tratado como se fosse max (barra cheia).
        let line = bar("cached", 9999, 100, 10, true, &t);
        let text = line_text(&line);
        let barra = extrair_barra(&text);
        assert!(
            !barra.contains(''),
            "value > max deve ser clampado — barra deve ser completamente preenchida"
        );
    }

    #[test]
    fn bar_max_zero_nao_causa_panic() {
        let t = mk_theme();
        // max == 0 deve retornar sem panic (fração 0 → barra vazia).
        let line = bar("total", 42, 0, 10, true, &t);
        let text = line_text(&line);
        assert!(text.contains(''), "max=0 deve produzir barra vazia");
        assert!(
            !text.contains(''),
            "max=0 não deve ter células preenchidas"
        );
    }

    // --- bar: modo comparativo (filled = false) ---

    #[test]
    fn bar_filled_false_usa_apenas_pontilhado() {
        let t = mk_theme();
        let line = bar("ref", 750, 1000, 20, false, &t);
        let text = line_text(&line);
        let barra = extrair_barra(&text);
        assert!(
            !barra.contains(''),
            "filled=false não deve ter '█' na barra"
        );
        assert!(barra.contains(''), "filled=false deve ter '░' na barra");
    }

    // --- bar: formato de saída ---

    #[test]
    fn bar_formato_contem_colchetes_e_valor() {
        let t = mk_theme();
        let line = bar("tokens", 123, 1000, 10, true, &t);
        let text = line_text(&line);
        assert!(text.contains('['), "deve conter '[' ");
        assert!(text.contains(']'), "deve conter ']' ");
        assert!(text.contains("123"), "deve conter o valor numérico");
    }

    #[test]
    fn bar_largura_pequena_nao_causa_panic() {
        let t = mk_theme();
        // bar_width = 1 — mínimo prático.
        let line = bar("x", 1, 10, 1, true, &t);
        let text = line_text(&line);
        assert!(!text.is_empty());
    }

    #[test]
    fn bar_largura_zero_clampada_para_um() {
        let t = mk_theme();
        // bar_width = 0 deve ser tratado como 1 (clamp interno).
        let line = bar("y", 0, 10, 0, true, &t);
        let text = line_text(&line);
        assert!(!text.is_empty());
    }

    // --- bar: label alinhado ---

    #[test]
    fn bar_label_curto_e_preenchido_com_espacos() {
        let t = mk_theme();
        let line = bar("ab", 0, 100, 5, true, &t);
        let text = line_text(&line);
        // Label "ab" alinhado em 14 deve ter 12 espaços de padding.
        assert!(
            text.starts_with("ab            "),
            "label deve ter padding de 14 chars: {:?}",
            text
        );
    }

    // --- dashboard ---

    #[test]
    fn dashboard_retorna_titulo_mais_linhas() {
        let t = mk_theme();
        let rows = vec![
            ("input".to_string(), 100u64),
            ("output".to_string(), 200u64),
            ("cache".to_string(), 50u64),
        ];
        let lines = dashboard("Tokens Queimados", &rows, 500, 20, &t);
        assert_eq!(lines.len(), 4, "deve retornar título + 3 linhas de dados");
    }

    #[test]
    fn dashboard_titulo_esta_na_primeira_linha() {
        let t = mk_theme();
        let rows = vec![("x".to_string(), 10u64)];
        let lines = dashboard("Meu Título", &rows, 100, 10, &t);
        let titulo = line_text(&lines[0]);
        assert!(titulo.contains("Meu Título"));
    }

    #[test]
    fn dashboard_vazio_retorna_apenas_titulo() {
        let t = mk_theme();
        let lines = dashboard("Vazio", &[], 100, 10, &t);
        assert_eq!(lines.len(), 1, "sem rows, deve retornar apenas o título");
    }

    #[test]
    fn dashboard_com_max_zero_nao_causa_panic() {
        let t = mk_theme();
        let rows = vec![("a".to_string(), 0u64), ("b".to_string(), 5u64)];
        // max = 0 → todas as barras devem ser vazias (sem panic).
        let lines = dashboard("Zero", &rows, 0, 10, &t);
        assert_eq!(lines.len(), 3);
    }

    // --- helper local ---

    /// Extrai apenas o conteúdo entre `[` e `]` (a barra propriamente dita).
    fn extrair_barra(text: &str) -> &str {
        if let (Some(a), Some(b)) = (text.find('['), text.find(']')) {
            &text[a + 1..b]
        } else {
            ""
        }
    }
}