context7-cli 0.4.1

Search library documentation from your terminal — zero runtime, bilingual (EN/PT), multi-key rotation
Documentation
/// Terminal output formatting.
///
/// This is the **only** module allowed to call `println!` or `eprintln!`.
/// All coloured formatting via the `colored` crate is centralised here.
/// All user-facing strings are resolved via [`crate::i18n::t`].
use anyhow::Context;
use colored::Colorize;

use crate::api::{DocumentationSnippet, LibrarySearchResult, RespostaDocumentacao};
use crate::i18n::{idioma_atual, t, Idioma, Mensagem};
use crate::storage::ChaveArmazenada;

// ─── BIBLIOTECA ───────────────────────────────────────────────────────────────

/// Prints the list of libraries returned by the search endpoint.
///
/// Displays index, title bold with trust score inline, library ID (dimmed),
/// and optional description (italic).
pub fn exibir_bibliotecas_formatado(resultados: &[LibrarySearchResult]) {
    if resultados.is_empty() {
        println!("{}", t(Mensagem::NenhumaBibliotecaEncontrada).yellow());
        return;
    }

    println!("{}", t(Mensagem::BibliotecasEncontradas).green().bold());
    println!("{}", "".repeat(60).dimmed());

    for (i, lib) in resultados.iter().enumerate() {
        let numero = format!("{}.", i + 1);

        // Title bold with trust score inline
        let titulo = if let Some(score) = lib.trust_score {
            format!(
                "{} {} ({} {:.1}/10)",
                numero.cyan(),
                lib.title.bold(),
                t(Mensagem::ConfiancaScore),
                score
            )
        } else {
            format!("{} {}", numero.cyan(), lib.title.bold())
        };
        println!("{}", titulo);

        // ID secondary (dimmed)
        println!("   {}", lib.id.dimmed());

        if let Some(desc) = &lib.description {
            println!("   {}", desc.italic());
        }

        println!();
    }
}

/// Prints a user-friendly hint when the requested library was not found.
///
/// Called from dispatchers in `cli.rs` before propagating the error,
/// so the user sees the hint on stderr before the error message.
pub fn exibir_dica_biblioteca_nao_encontrada() {
    eprintln!("{}", t(Mensagem::BibliotecaNaoEncontradaApi).yellow());
}

// ─── DOCUMENTAÇÃO ─────────────────────────────────────────────────────────────

/// Prints structured documentation from the docs endpoint.
///
/// Iterates over `snippets`. Shows a "no documentation found" message if empty.
pub fn exibir_documentacao_formatada(doc: &RespostaDocumentacao) {
    let snippets = match &doc.snippets {
        Some(s) if !s.is_empty() => s,
        _ => {
            println!("{}", t(Mensagem::NenhumaDocumentacaoEncontrada).yellow());
            return;
        }
    };

    println!("{}", t(Mensagem::TituloDocumentacao).green().bold());
    println!("{}", "".repeat(60).dimmed());

    for snippet in snippets {
        exibir_snippet(snippet);
    }
}

/// Prints a single documentation snippet with formatted fields.
///
/// Display order: page_title → code_title → code_description → code_list blocks → code_id (source)
fn exibir_snippet(snippet: &DocumentationSnippet) {
    if let Some(titulo_pagina) = &snippet.page_title {
        println!("{}", format!("## {}", titulo_pagina).green().bold());
    }

    if let Some(titulo_codigo) = &snippet.code_title {
        println!("{}", format!("{}", titulo_codigo).cyan());
    }

    if let Some(descricao) = &snippet.code_description {
        println!("  {}", descricao.dimmed().italic());
    }

    if let Some(blocos) = &snippet.code_list {
        for bloco in blocos {
            println!("```{}", bloco.language);
            println!("{}", bloco.code);
            println!("```");
        }
    }

    if let Some(source) = &snippet.code_id {
        println!("{}", source.blue().bold().dimmed());
    }

    println!();
}

// ─── CHAVES ───────────────────────────────────────────────────────────────────

/// Prints all stored keys with 1-based indices and masked values.
pub fn exibir_chaves_mascaradas(chaves: &[ChaveArmazenada], mascarar: impl Fn(&str) -> String) {
    println!(
        "{}",
        format!("{} {}", chaves.len(), t(Mensagem::ContadorChaves))
            .green()
            .bold()
    );
    println!("{}", "".repeat(60).dimmed());

    let rotulo_adicionada = match idioma_atual() {
        Idioma::English => "added:",
        Idioma::Portugues => "adicionada:",
    };

    for (i, chave) in chaves.iter().enumerate() {
        println!(
            "  {}  {}  {}",
            format!("[{}]", i + 1).cyan(),
            mascarar(&chave.value).bold(),
            format!(
                "({} {})",
                rotulo_adicionada,
                formatar_added_at_display(&chave.added_at)
            )
            .dimmed()
        );
    }
}

/// Formata uma string RFC3339 para exibição compacta: `YYYY-MM-DD HH:MM:SS`.
///
/// Retorna a string original se o parse falhar (robustez).
pub fn formatar_added_at_display(iso: &str) -> String {
    chrono::DateTime::parse_from_rfc3339(iso)
        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
        .unwrap_or_else(|_| iso.to_string())
}

/// Prints the "no keys stored" hint message.
pub fn exibir_nenhuma_chave() {
    println!("{}", t(Mensagem::NenhumaChaveArmazenada).yellow());
    println!("{}", t(Mensagem::UsarKeysAdd).cyan());
}

/// Prints the "no keys to remove" message.
pub fn exibir_nenhuma_chave_para_remover() {
    println!("{}", t(Mensagem::NenhumaChaveParaRemover).yellow());
}

/// Prints an invalid index error.
pub fn exibir_indice_invalido(_indice: usize, total: usize) {
    println!(
        "{}",
        format!("{} {}.", t(Mensagem::IndiceInvalido), total).red()
    );
}

/// Prints the success message for `keys add`.
pub fn exibir_chave_adicionada(caminho: &std::path::Path) {
    println!(
        "{} {}",
        t(Mensagem::ChaveAdicionada),
        caminho.display().to_string().green()
    );
}

/// Prints the warning message when a key already exists (dedupe).
pub fn exibir_chave_ja_existia() {
    println!("{}", t(Mensagem::ChaveJaExistia).yellow());
}

/// Displays an error when the user tries to add an empty API key.
pub fn exibir_chave_invalida_vazia() {
    eprintln!("{}", t(Mensagem::ChaveVaziaOuInvalida).red());
}

/// Displays a warning when the key does not match the expected `ctx7sk-` format.
pub fn exibir_aviso_formato_chave() {
    eprintln!("{}", t(Mensagem::AvisoFormatoChave).yellow());
}

/// Prints the success message for `keys remove`.
pub fn exibir_chave_removida(chave_mascarada: &str) {
    println!(
        "{} {}",
        chave_mascarada.bold(),
        t(Mensagem::ChaveRemovidaSucesso)
    );
}

/// Prints the cancellation message for `keys clear`.
pub fn exibir_operacao_cancelada() {
    println!("{}", t(Mensagem::OperacaoCancelada).yellow());
}

/// Prints the success message for `keys clear`.
pub fn exibir_chaves_removidas() {
    println!("{}", t(Mensagem::TodasChavesRemovidas).green());
}

/// Prints an "XDG not supported" error for `keys path`.
pub fn exibir_xdg_nao_suportado() {
    println!("{}", t(Mensagem::SistemaXdgNaoSuportado).red());
}

/// Prints an empty JSON array `[]` to stdout.
pub fn exibir_json_array_vazio() {
    println!("[]");
}

/// Prints a raw JSON string to stdout.
pub fn exibir_json_bruto(json: &str) {
    println!("{}", json);
}

/// Prints a file path to stdout.
pub fn exibir_caminho_config(caminho: &std::path::Path) {
    println!("{}", caminho.display());
}

/// Prints a key in `CONTEXT7_API=<value>` format to stdout.
pub fn exibir_chave_exportada(valor: &str) {
    println!("CONTEXT7_API={}", valor);
}

/// Prints raw JSON results to stdout (used by Library and Docs JSON mode).
pub fn exibir_json_resultados(json: &str) {
    println!("{}", json);
}

/// Prints plain text to stdout (used by Docs text mode).
pub fn exibir_texto_plano(texto: &str) {
    println!("{}", texto);
}

/// Asks for interactive confirmation before clearing all keys.
///
/// Returns `true` if the user confirms with `s`/`sim` (PT) or `y`/`yes` (EN).
pub fn confirmar_clear() -> anyhow::Result<bool> {
    use std::io::Write;
    print!("{}", t(Mensagem::ConfirmarRemoverTodas));
    std::io::stdout()
        .flush()
        .context("Falha ao limpar buffer de saída")?;

    let mut entrada = String::new();
    std::io::stdin()
        .read_line(&mut entrada)
        .context("Falha ao ler confirmação do usuário")?;

    Ok(matches!(
        entrada.trim().to_lowercase().as_str(),
        "s" | "sim" | "y" | "yes"
    ))
}

/// Prints the success message for `keys import`.
pub fn exibir_importacao_concluida(importadas: usize, total: usize) {
    println!(
        "{}",
        format!(
            "{}/{} {}",
            importadas,
            total,
            t(Mensagem::ChavesImportadasSucesso)
        )
        .green()
    );
}

#[cfg(test)]
mod testes {
    use super::formatar_added_at_display;

    #[test]
    fn testa_formatar_added_at_rfc3339_com_nanossegundos() {
        let resultado = formatar_added_at_display("2026-04-09T13:34:59.060818734+00:00");
        assert_eq!(resultado, "2026-04-09 13:34:59");
        assert!(
            !resultado.contains('T'),
            "Resultado não deve conter 'T': {resultado}"
        );
        assert!(
            !resultado.contains('.'),
            "Resultado não deve conter nanossegundos: {resultado}"
        );
        assert!(
            !resultado.contains("+00:00"),
            "Resultado não deve conter offset de timezone: {resultado}"
        );
    }

    #[test]
    fn testa_formatar_added_at_rfc3339_sem_nanossegundos() {
        let resultado = formatar_added_at_display("2026-01-01T00:00:00+00:00");
        assert_eq!(resultado, "2026-01-01 00:00:00");
    }

    #[test]
    fn testa_formatar_added_at_rfc3339_offset_nao_utc() {
        // RFC3339 com offset -03:00 (Brasil) — exibe hora local (sem conversão para UTC)
        let resultado = formatar_added_at_display("2026-04-09T10:00:00-03:00");
        // A função preserva a hora local do timestamp, não converte para UTC
        assert_eq!(resultado, "2026-04-09 10:00:00");
        // Deve remover o offset timezone da exibição
        assert!(
            !resultado.contains("-03:00"),
            "Resultado não deve conter offset de timezone: {resultado}"
        );
    }

    #[test]
    fn testa_formatar_added_at_fallback_string_invalida() {
        let resultado = formatar_added_at_display("lixo-nao-e-data");
        assert_eq!(
            resultado, "lixo-nao-e-data",
            "String inválida deve ser retornada sem modificação"
        );
    }

    #[test]
    fn testa_formatar_added_at_string_vazia() {
        let resultado = formatar_added_at_display("");
        assert_eq!(
            resultado, "",
            "String vazia deve ser retornada sem modificação"
        );
    }

    #[test]
    fn testa_formatar_added_at_formato_saida_legivel() {
        let resultado = formatar_added_at_display("2026-04-09T13:34:59.123456789+00:00");
        // Deve ter exatamente o formato YYYY-MM-DD HH:MM:SS (19 chars)
        assert_eq!(
            resultado.len(),
            19,
            "Formato de saída deve ter 19 caracteres, obteve: '{resultado}'"
        );
        assert!(
            resultado.contains(' '),
            "Resultado deve conter espaço separando data e hora: {resultado}"
        );
    }
}