sdd-layer 0.24.0

Spec-Driven Development CLI and agent harness
//! Parser de um subconjunto de Markdown → `ratatui::text::Text` (T-14, RF-07).
//! Sem crate externa. Cobre cabeçalhos, listas, blocos de código, ênfase e tabelas.
//! Conteúdo não reconhecido é emitido como texto plano (nunca entra em panic).

use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};

/// Converte o Markdown inteiro de uma vez (chamado ao entrar no Viewer, não por frame).
pub fn parse(input: &str) -> Text<'static> {
    let mut lines: Vec<Line<'static>> = Vec::new();
    let mut in_code = false;

    for raw in input.lines() {
        let line = raw.trim_end_matches(['\r']);

        // Bloco de código cercado por ```
        if line.trim_start().starts_with("```") {
            in_code = !in_code;
            lines.push(Line::from(Span::styled(
                line.to_string(),
                Style::default().fg(Color::DarkGray),
            )));
            continue;
        }
        if in_code {
            lines.push(Line::from(Span::styled(
                line.to_string(),
                Style::default().fg(Color::Cyan),
            )));
            continue;
        }

        // Cabeçalhos
        if let Some(rest) = line.strip_prefix("### ") {
            lines.push(Line::from(Span::styled(
                rest.to_string(),
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            )));
            continue;
        }
        if let Some(rest) = line.strip_prefix("## ") {
            lines.push(Line::from(Span::styled(
                rest.to_string(),
                Style::default()
                    .fg(Color::Magenta)
                    .add_modifier(Modifier::BOLD),
            )));
            continue;
        }
        if let Some(rest) = line.strip_prefix("# ") {
            lines.push(Line::from(Span::styled(
                rest.to_string(),
                Style::default()
                    .fg(Color::Green)
                    .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
            )));
            continue;
        }

        // Regra horizontal
        if line.trim() == "---" || line.trim() == "***" {
            lines.push(Line::from(Span::styled(
                "────────────────────".to_string(),
                Style::default().fg(Color::DarkGray),
            )));
            continue;
        }

        // Tabela (linha com pipes)
        let trimmed = line.trim_start();
        if trimmed.starts_with('|') && trimmed.matches('|').count() >= 2 {
            // Linha separadora |---|---| vira régua fina
            if trimmed.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
                lines.push(Line::from(Span::styled(
                    trimmed.to_string(),
                    Style::default().fg(Color::DarkGray),
                )));
            } else {
                lines.push(Line::from(Span::styled(
                    trimmed.to_string(),
                    Style::default().fg(Color::White),
                )));
            }
            continue;
        }

        // Listas
        if let Some(rest) = list_item(trimmed) {
            let indent = line.len() - trimmed.len();
            let prefix = format!("{}", " ".repeat(indent));
            let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::Blue))];
            spans.extend(inline(rest));
            lines.push(Line::from(spans));
            continue;
        }

        // Parágrafo comum com ênfase inline
        if line.is_empty() {
            lines.push(Line::from(String::new()));
        } else {
            lines.push(Line::from(inline(line)));
        }
    }

    Text::from(lines)
}

fn list_item(trimmed: &str) -> Option<&str> {
    for marker in ["- ", "* ", "+ "] {
        if let Some(rest) = trimmed.strip_prefix(marker) {
            return Some(rest);
        }
    }
    None
}

/// Processa ênfase inline `**bold**` e `*italic*`/`_italic_` e ``code``.
fn inline(text: &str) -> Vec<Span<'static>> {
    let mut spans: Vec<Span<'static>> = Vec::new();
    let chars: Vec<char> = text.chars().collect();
    let mut i = 0;
    let mut plain = String::new();

    let flush = |plain: &mut String, spans: &mut Vec<Span<'static>>| {
        if !plain.is_empty() {
            spans.push(Span::raw(std::mem::take(plain)));
        }
    };

    while i < chars.len() {
        // **bold**
        if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
            if let Some(end) = find_delim(&chars, i + 2, "**") {
                flush(&mut plain, &mut spans);
                let content: String = chars[i + 2..end].iter().collect();
                spans.push(Span::styled(
                    content,
                    Style::default().add_modifier(Modifier::BOLD),
                ));
                i = end + 2;
                continue;
            }
        }
        // `code`
        if chars[i] == '`' {
            if let Some(end) = find_char(&chars, i + 1, '`') {
                flush(&mut plain, &mut spans);
                let content: String = chars[i + 1..end].iter().collect();
                spans.push(Span::styled(content, Style::default().fg(Color::Cyan)));
                i = end + 1;
                continue;
            }
        }
        // *italic*
        if chars[i] == '*' {
            if let Some(end) = find_char(&chars, i + 1, '*') {
                flush(&mut plain, &mut spans);
                let content: String = chars[i + 1..end].iter().collect();
                spans.push(Span::styled(
                    content,
                    Style::default().add_modifier(Modifier::ITALIC),
                ));
                i = end + 1;
                continue;
            }
        }
        plain.push(chars[i]);
        i += 1;
    }
    flush(&mut plain, &mut spans);
    if spans.is_empty() {
        spans.push(Span::raw(String::new()));
    }
    spans
}

fn find_delim(chars: &[char], start: usize, delim: &str) -> Option<usize> {
    let d: Vec<char> = delim.chars().collect();
    let mut i = start;
    while i + d.len() <= chars.len() {
        if chars[i..i + d.len()] == d[..] {
            return Some(i);
        }
        i += 1;
    }
    None
}

fn find_char(chars: &[char], start: usize, target: char) -> Option<usize> {
    (start..chars.len()).find(|&i| chars[i] == target)
}

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

    fn has_bold(text: &Text) -> bool {
        text.lines.iter().any(|l| {
            l.spans
                .iter()
                .any(|s| s.style.add_modifier.contains(Modifier::BOLD))
        })
    }

    #[test]
    fn heading_is_bold() {
        let t = parse("## Visão técnica");
        assert!(has_bold(&t));
    }

    #[test]
    fn plain_line_no_panic() {
        let t = parse("uma linha qualquer sem sintaxe especial <>&%");
        assert_eq!(t.lines.len(), 1);
    }

    #[test]
    fn list_gets_bullet() {
        let t = parse("- item um");
        let first = &t.lines[0];
        let joined: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(joined.contains(""));
    }

    #[test]
    fn bold_inline_detected() {
        let t = parse("texto **forte** aqui");
        assert!(has_bold(&t));
    }

    #[test]
    fn code_fence_toggles() {
        let t = parse("```\ncodigo\n```");
        assert_eq!(t.lines.len(), 3);
    }

    #[test]
    fn multiline_count() {
        let input = "# Título\n\n## Seção\n- a\n- b\n";
        let t = parse(input);
        assert_eq!(t.lines.len(), 5);
    }
}