sdd-layer 0.20.0

Spec-Driven Development CLI and agent harness
//! Componentes de trace de execução para o TUI (T-12): linha de tool-call com
//! dot de estado e blocos de "thought", ao estilo Antigravity/Claude Code.
//! Funções puras — sem estado, sem I/O.

#![allow(dead_code)]

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

// ---------------------------------------------------------------------------
// Estado de um trace
// ---------------------------------------------------------------------------

/// Estado visual de uma linha de tool-call/trace.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TraceState {
    /// Aguardando início — dot em `text_muted`.
    Pending,
    /// Em execução — dot em `warning`.
    Running,
    /// Concluído com sucesso — dot em `success`.
    Done,
    /// Encerrado com erro — dot em `error`.
    Error,
}

// ---------------------------------------------------------------------------
// trace_line
// ---------------------------------------------------------------------------

/// Retorna uma [`Line`] representando uma chamada de tool com dot de estado.
///
/// Formato: `"● <texto>"` + sufixo `" (ctrl+o expandir)"` quando `expandable`.
///
/// - Pending → dot em `text_muted`
/// - Running → dot em `warning`
/// - Done    → dot em `success`
/// - Error   → dot em `error`
pub fn trace_line(state: TraceState, text: &str, expandable: bool, theme: &Theme) -> Line<'static> {
    let dot_color = match state {
        TraceState::Pending => theme.text_muted,
        TraceState::Running => theme.warning,
        TraceState::Done => theme.success,
        TraceState::Error => theme.error,
    };

    let mut spans = vec![
        Span::styled("", Style::default().fg(dot_color)),
        Span::styled(text.to_owned(), Style::default().fg(theme.text_strong)),
    ];

    if expandable {
        spans.push(Span::styled(
            " (ctrl+o expandir)".to_owned(),
            Style::default().fg(theme.text_muted),
        ));
    }

    Line::from(spans)
}

// ---------------------------------------------------------------------------
// thought_block
// ---------------------------------------------------------------------------

/// Retorna um bloco de linhas representando um "thought" do agente.
///
/// Formato:
/// ```text
/// ▸ Thought for {seconds}s, {tokens} tokens
///   <linha 1 do summary>
///   <linha 2 do summary>
///   ...
/// ```
///
/// Todas as linhas são coloridas com `text_muted`. As linhas de conteúdo
/// recebem recuo de dois espaços.
pub fn thought_block(
    seconds: u64,
    tokens: usize,
    summary: &str,
    theme: &Theme,
) -> Vec<Line<'static>> {
    let muted = Style::default().fg(theme.text_muted);

    let header = Line::from(Span::styled(
        format!("▸ Thought for {}s, {} tokens", seconds, tokens),
        muted,
    ));

    let mut lines: Vec<Line<'static>> = vec![header];

    for raw in summary.split('\n') {
        lines.push(Line::from(Span::styled(format!("  {}", raw), muted)));
    }

    lines
}

// ---------------------------------------------------------------------------
// working_line
// ---------------------------------------------------------------------------

/// Retorna uma [`Line`] com spinner animado + texto "Working…".
///
/// O frame é determinado por `frame_index % 4`; os frames são `["⠋","⠙","⠹","⠸"]`.
/// Colorido com `primary` (acento do tema).
pub fn working_line(frame_index: usize, theme: &Theme) -> Line<'static> {
    const FRAMES: [&str; 4] = ["", "", "", ""];
    let spinner = FRAMES[frame_index % 4];

    Line::from(Span::styled(
        format!("{} Working…", spinner),
        Style::default()
            .fg(theme.primary)
            .add_modifier(Modifier::BOLD),
    ))
}

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

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

    fn default_theme() -> Theme {
        theme(ColorDepth::TrueColor)
    }

    // --- trace_line ---

    #[test]
    fn trace_line_pending_nao_vazia() {
        let t = default_theme();
        let line = trace_line(TraceState::Pending, "ReadFile", false, &t);
        assert!(!line.spans.is_empty());
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("ReadFile"));
        assert!(text.contains(''));
    }

    #[test]
    fn trace_line_running_nao_vazia() {
        let t = default_theme();
        let line = trace_line(TraceState::Running, "Bash", false, &t);
        assert!(!line.spans.is_empty());
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("Bash"));
    }

    #[test]
    fn trace_line_done_nao_vazia() {
        let t = default_theme();
        let line = trace_line(TraceState::Done, "WriteFile", false, &t);
        assert!(!line.spans.is_empty());
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("WriteFile"));
    }

    #[test]
    fn trace_line_error_nao_vazia() {
        let t = default_theme();
        let line = trace_line(TraceState::Error, "Deploy", false, &t);
        assert!(!line.spans.is_empty());
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("Deploy"));
    }

    #[test]
    fn trace_line_dot_pending_usa_text_muted() {
        let t = default_theme();
        let line = trace_line(TraceState::Pending, "op", false, &t);
        let dot_span = &line.spans[0];
        assert_eq!(dot_span.style.fg, Some(t.text_muted));
    }

    #[test]
    fn trace_line_dot_running_usa_warning() {
        let t = default_theme();
        let line = trace_line(TraceState::Running, "op", false, &t);
        let dot_span = &line.spans[0];
        assert_eq!(dot_span.style.fg, Some(t.warning));
    }

    #[test]
    fn trace_line_dot_done_usa_success() {
        let t = default_theme();
        let line = trace_line(TraceState::Done, "op", false, &t);
        let dot_span = &line.spans[0];
        assert_eq!(dot_span.style.fg, Some(t.success));
    }

    #[test]
    fn trace_line_dot_error_usa_error() {
        let t = default_theme();
        let line = trace_line(TraceState::Error, "op", false, &t);
        let dot_span = &line.spans[0];
        assert_eq!(dot_span.style.fg, Some(t.error));
    }

    #[test]
    fn trace_line_expandable_contem_sufixo() {
        let t = default_theme();
        let line = trace_line(TraceState::Done, "op", true, &t);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("ctrl+o expandir"));
    }

    #[test]
    fn trace_line_nao_expandable_sem_sufixo() {
        let t = default_theme();
        let line = trace_line(TraceState::Done, "op", false, &t);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(!text.contains("ctrl+o expandir"));
    }

    // --- thought_block ---

    #[test]
    fn thought_block_linha_simples() {
        let t = default_theme();
        let lines = thought_block(3, 120, "Analisando dependências", &t);
        assert!(lines.len() >= 2); // cabeçalho + ao menos uma linha
        let header: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(header.contains("Thought for 3s, 120 tokens"));
        let body: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(body.contains("Analisando dependências"));
    }

    #[test]
    fn thought_block_summary_multilinha() {
        let t = default_theme();
        let summary = "Primeira linha\nSegunda linha\nTerceira linha";
        let lines = thought_block(10, 500, summary, &t);
        // 1 cabeçalho + 3 linhas de conteúdo
        assert_eq!(lines.len(), 4);
        let l1: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
        let l2: String = lines[2].spans.iter().map(|s| s.content.as_ref()).collect();
        let l3: String = lines[3].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(l1.contains("Primeira linha"));
        assert!(l2.contains("Segunda linha"));
        assert!(l3.contains("Terceira linha"));
    }

    #[test]
    fn thought_block_cabecalho_contem_seconds_e_tokens() {
        let t = default_theme();
        let lines = thought_block(7, 999, "resumo", &t);
        let header: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(header.contains("7s"));
        assert!(header.contains("999 tokens"));
    }

    #[test]
    fn thought_block_linhas_recuadas() {
        let t = default_theme();
        let lines = thought_block(1, 10, "recuo", &t);
        let body: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(body.starts_with("  "));
    }

    // --- working_line ---

    #[test]
    fn working_line_frame_0() {
        let t = default_theme();
        let line = working_line(0, &t);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains(""));
        assert!(text.contains("Working"));
    }

    #[test]
    fn working_line_frame_1() {
        let t = default_theme();
        let line = working_line(1, &t);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains(""));
    }

    #[test]
    fn working_line_frame_2() {
        let t = default_theme();
        let line = working_line(2, &t);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains(""));
    }

    #[test]
    fn working_line_frame_3() {
        let t = default_theme();
        let line = working_line(3, &t);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains(""));
    }

    #[test]
    fn working_line_ciclo_correto() {
        let t = default_theme();
        // frame 4 deve ciclar de volta ao frame 0 (⠋)
        let line4 = working_line(4, &t);
        let line0 = working_line(0, &t);
        let text4: String = line4.spans.iter().map(|s| s.content.as_ref()).collect();
        let text0: String = line0.spans.iter().map(|s| s.content.as_ref()).collect();
        assert_eq!(text4, text0);
    }

    #[test]
    fn working_line_nao_vazia() {
        let t = default_theme();
        for i in 0..8 {
            let line = working_line(i, &t);
            assert!(!line.spans.is_empty(), "frame {} nao deve ser vazio", i);
        }
    }

    #[test]
    fn working_line_usa_cor_primary() {
        let t = default_theme();
        let line = working_line(0, &t);
        assert_eq!(line.spans[0].style.fg, Some(t.primary));
    }
}