Skip to main content

context7_cli/
output.rs

1/// Terminal output formatting.
2///
3/// This is the **only** module allowed to call `println!` or `eprintln!`.
4/// All coloured formatting via the `colored` crate is centralised here.
5/// All user-facing strings are resolved via [`crate::i18n::t`].
6use std::io::IsTerminal;
7
8use anyhow::Context;
9use chrono::Utc;
10use colored::Colorize;
11use serde::Serialize;
12
13use crate::api::{DocumentationSnippet, LibrarySearchResult, RespostaDocumentacao};
14use crate::i18n::{idioma_atual, t, Idioma, Mensagem};
15use crate::storage::ChaveArmazenada;
16
17/// Retorna o simbolo Unicode ou seu fallback ASCII conforme capacidade do terminal.
18///
19/// Usa ASCII quando stdout nao e TTY interativo (pipe, redirecionamento),
20/// quando `NO_COLOR` esta setado, ou quando a variavel `TERM` e `dumb`.
21fn simbolo_ou_ascii<'a>(unicode: &'a str, ascii: &'a str) -> &'a str {
22    static USAR_ASCII: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
23    let usar_ascii = *USAR_ASCII.get_or_init(|| {
24        !std::io::stdout().is_terminal()
25            || std::env::var("NO_COLOR").is_ok()
26            || std::env::var("TERM").map(|t| t == "dumb").unwrap_or(false)
27    });
28    if usar_ascii {
29        ascii
30    } else {
31        unicode
32    }
33}
34
35// ─── NDJSON ──────────────────────────────────────────────────────────────────
36
37/// Envelope NDJSON para saída estruturada consumível por LLMs.
38///
39/// Cada evento é emitido como uma linha JSON com `type` e `timestamp`.
40#[derive(Serialize)]
41struct EventoNdjson<'a, T: Serialize> {
42    #[serde(rename = "type")]
43    tipo: &'a str,
44    timestamp: String,
45    #[serde(flatten)]
46    dados: T,
47}
48
49/// Emite um evento NDJSON (uma linha JSON) para stdout.
50pub fn emitir_ndjson<T: Serialize>(tipo: &str, dados: &T) {
51    let evento = EventoNdjson {
52        tipo,
53        timestamp: Utc::now().to_rfc3339(),
54        dados,
55    };
56    if let Ok(json) = serde_json::to_string(&evento) {
57        println!("{json}");
58    }
59}
60
61// ─── BIBLIOTECA ───────────────────────────────────────────────────────────────
62
63/// Prints the list of libraries returned by the search endpoint.
64///
65/// Displays index, title bold with trust score inline, library ID (dimmed),
66/// and optional description (italic).
67pub fn exibir_bibliotecas_formatado(resultados: &[LibrarySearchResult]) {
68    if resultados.is_empty() {
69        println!("{}", t(Mensagem::NenhumaBibliotecaEncontrada).yellow());
70        return;
71    }
72
73    println!("{}", t(Mensagem::BibliotecasEncontradas).green().bold());
74    println!("{}", simbolo_ou_ascii("─", "-").repeat(60).dimmed());
75
76    for (i, lib) in resultados.iter().enumerate() {
77        let numero = format!("{}.", i + 1);
78
79        // Title bold with trust score inline
80        let titulo = if let Some(score) = lib.trust_score {
81            format!(
82                "{} {} ({} {:.1}/10)",
83                numero.cyan(),
84                lib.title.bold(),
85                t(Mensagem::ConfiancaScore),
86                score
87            )
88        } else {
89            format!("{} {}", numero.cyan(), lib.title.bold())
90        };
91        println!("{}", titulo);
92
93        // ID secondary (dimmed)
94        println!("   {}", lib.id.dimmed());
95
96        if let Some(desc) = &lib.description {
97            println!("   {}", desc.italic());
98        }
99
100        println!();
101    }
102}
103
104/// Prints a user-friendly hint when the requested library was not found.
105///
106/// Called from dispatchers in `cli.rs` before propagating the error,
107/// so the user sees the hint on stderr before the error message.
108pub fn exibir_dica_biblioteca_nao_encontrada() {
109    eprintln!("{}", t(Mensagem::BibliotecaNaoEncontradaApi).yellow());
110}
111
112// ─── DOCUMENTAÇÃO ─────────────────────────────────────────────────────────────
113
114/// Prints structured documentation from the docs endpoint.
115///
116/// Iterates over `snippets`. Shows a "no documentation found" message if empty.
117pub fn exibir_documentacao_formatada(doc: &RespostaDocumentacao) {
118    let snippets = match &doc.snippets {
119        Some(s) if !s.is_empty() => s,
120        _ => {
121            println!("{}", t(Mensagem::NenhumaDocumentacaoEncontrada).yellow());
122            return;
123        }
124    };
125
126    println!("{}", t(Mensagem::TituloDocumentacao).green().bold());
127    println!("{}", simbolo_ou_ascii("─", "-").repeat(60).dimmed());
128
129    for snippet in snippets {
130        exibir_snippet(snippet);
131    }
132}
133
134/// Prints a single documentation snippet with formatted fields.
135///
136/// Display order: page_title → code_title → code_description → code_list blocks → code_id (source)
137fn exibir_snippet(snippet: &DocumentationSnippet) {
138    if let Some(titulo_pagina) = &snippet.page_title {
139        println!("{}", format!("## {}", titulo_pagina).green().bold());
140    }
141
142    if let Some(titulo_codigo) = &snippet.code_title {
143        println!(
144            "{}",
145            format!("{} {}", simbolo_ou_ascii("▸", ">"), titulo_codigo).cyan()
146        );
147    }
148
149    if let Some(descricao) = &snippet.code_description {
150        println!("  {}", descricao.dimmed().italic());
151    }
152
153    if let Some(blocos) = &snippet.code_list {
154        for bloco in blocos {
155            println!("```{}", bloco.language);
156            println!("{}", bloco.code);
157            println!("```");
158        }
159    }
160
161    if let Some(source) = &snippet.code_id {
162        println!("{}", source.blue().bold().dimmed());
163    }
164
165    println!();
166}
167
168// ─── CHAVES ───────────────────────────────────────────────────────────────────
169
170/// Prints all stored keys with 1-based indices and masked values.
171pub fn exibir_chaves_mascaradas(chaves: &[ChaveArmazenada], mascarar: impl Fn(&str) -> String) {
172    println!(
173        "{}",
174        format!("{} {}", chaves.len(), t(Mensagem::ContadorChaves))
175            .green()
176            .bold()
177    );
178    println!("{}", simbolo_ou_ascii("─", "-").repeat(60).dimmed());
179
180    let rotulo_adicionada = match idioma_atual() {
181        Idioma::English => "added:",
182        Idioma::Portugues => "adicionada:",
183    };
184
185    for (i, chave) in chaves.iter().enumerate() {
186        println!(
187            "  {}  {}  {}",
188            format!("[{}]", i + 1).cyan(),
189            mascarar(&chave.value).bold(),
190            format!(
191                "({} {})",
192                rotulo_adicionada,
193                formatar_added_at_display(&chave.added_at)
194            )
195            .dimmed()
196        );
197    }
198}
199
200/// Formata uma string RFC3339 para exibição compacta: `YYYY-MM-DD HH:MM:SS`.
201///
202/// Retorna a string original se o parse falhar (robustez).
203pub fn formatar_added_at_display(iso: &str) -> String {
204    chrono::DateTime::parse_from_rfc3339(iso)
205        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
206        .unwrap_or_else(|_| iso.to_string())
207}
208
209/// Prints the "no keys stored" hint message.
210pub fn exibir_nenhuma_chave() {
211    println!("{}", t(Mensagem::NenhumaChaveArmazenada).yellow());
212    println!("{}", t(Mensagem::UsarKeysAdd).cyan());
213}
214
215/// Prints the "no keys to remove" message.
216pub fn exibir_nenhuma_chave_para_remover() {
217    println!("{}", t(Mensagem::NenhumaChaveParaRemover).yellow());
218}
219
220/// Prints an invalid index error.
221pub fn exibir_indice_invalido(_indice: usize, total: usize) {
222    println!(
223        "{}",
224        format!("{} {}.", t(Mensagem::IndiceInvalido), total).red()
225    );
226}
227
228/// Prints the success message for `keys add`.
229pub fn exibir_chave_adicionada(caminho: &std::path::Path) {
230    println!(
231        "{} {}",
232        t(Mensagem::ChaveAdicionada),
233        caminho.display().to_string().green()
234    );
235}
236
237/// Prints the warning message when a key already exists (dedupe).
238pub fn exibir_chave_ja_existia() {
239    println!("{}", t(Mensagem::ChaveJaExistia).yellow());
240}
241
242/// Displays an error when the user tries to add an empty API key.
243pub fn exibir_chave_invalida_vazia() {
244    eprintln!("{}", t(Mensagem::ChaveVaziaOuInvalida).red());
245}
246
247/// Displays a warning when the key does not match the expected `ctx7sk-` format.
248pub fn exibir_aviso_formato_chave() {
249    eprintln!("{}", t(Mensagem::AvisoFormatoChave).yellow());
250}
251
252/// Prints the success message for `keys remove`.
253pub fn exibir_chave_removida(chave_mascarada: &str) {
254    println!(
255        "{} {}",
256        chave_mascarada.bold(),
257        t(Mensagem::ChaveRemovidaSucesso)
258    );
259}
260
261/// Prints the cancellation message for `keys clear`.
262pub fn exibir_operacao_cancelada() {
263    println!("{}", t(Mensagem::OperacaoCancelada).yellow());
264}
265
266/// Prints the success message for `keys clear`.
267pub fn exibir_chaves_removidas() {
268    println!("{}", t(Mensagem::TodasChavesRemovidas).green());
269}
270
271/// Prints an "XDG not supported" error for `keys path`.
272pub fn exibir_xdg_nao_suportado() {
273    println!("{}", t(Mensagem::SistemaXdgNaoSuportado).red());
274}
275
276/// Prints an empty JSON array `[]` to stdout.
277pub fn exibir_json_array_vazio() {
278    println!("[]");
279}
280
281/// Prints a raw JSON string to stdout.
282pub fn exibir_json_bruto(json: &str) {
283    println!("{}", json);
284}
285
286/// Prints a file path to stdout.
287pub fn exibir_caminho_config(caminho: &std::path::Path) {
288    println!("{}", caminho.display());
289}
290
291/// Prints a key in `CONTEXT7_API=<value>` format to stdout.
292pub fn exibir_chave_exportada(valor: &str) {
293    println!("CONTEXT7_API={}", valor);
294}
295
296/// Prints raw JSON results to stdout (used by Library and Docs JSON mode).
297pub fn exibir_json_resultados(json: &str) {
298    println!("{}", json);
299}
300
301/// Prints plain text to stdout (used by Docs text mode).
302pub fn exibir_texto_plano(texto: &str) {
303    println!("{}", texto);
304}
305
306/// Asks for interactive confirmation before clearing all keys.
307///
308/// Returns `true` if the user confirms with `s`/`sim` (PT) or `y`/`yes` (EN).
309pub fn confirmar_clear() -> anyhow::Result<bool> {
310    use std::io::Write;
311    print!("{}", t(Mensagem::ConfirmarRemoverTodas));
312    std::io::stdout()
313        .flush()
314        .context("Falha ao limpar buffer de saída")?;
315
316    let mut entrada = String::new();
317    std::io::stdin()
318        .read_line(&mut entrada)
319        .context("Falha ao ler confirmação do usuário")?;
320
321    Ok(matches!(
322        entrada.trim().to_lowercase().as_str(),
323        "s" | "sim" | "y" | "yes"
324    ))
325}
326
327/// Prints the success message for `keys import`.
328pub fn exibir_importacao_concluida(importadas: usize, total: usize) {
329    println!(
330        "{}",
331        format!(
332            "{}/{} {}",
333            importadas,
334            total,
335            t(Mensagem::ChavesImportadasSucesso)
336        )
337        .green()
338    );
339}
340
341#[cfg(test)]
342mod testes {
343    use super::formatar_added_at_display;
344
345    #[test]
346    fn testa_formatar_added_at_rfc3339_com_nanossegundos() {
347        let resultado = formatar_added_at_display("2026-04-09T13:34:59.060818734+00:00");
348        assert_eq!(resultado, "2026-04-09 13:34:59");
349        assert!(
350            !resultado.contains('T'),
351            "Resultado não deve conter 'T': {resultado}"
352        );
353        assert!(
354            !resultado.contains('.'),
355            "Resultado não deve conter nanossegundos: {resultado}"
356        );
357        assert!(
358            !resultado.contains("+00:00"),
359            "Resultado não deve conter offset de timezone: {resultado}"
360        );
361    }
362
363    #[test]
364    fn testa_formatar_added_at_rfc3339_sem_nanossegundos() {
365        let resultado = formatar_added_at_display("2026-01-01T00:00:00+00:00");
366        assert_eq!(resultado, "2026-01-01 00:00:00");
367    }
368
369    #[test]
370    fn testa_formatar_added_at_rfc3339_offset_nao_utc() {
371        // RFC3339 com offset -03:00 (Brasil) — exibe hora local (sem conversão para UTC)
372        let resultado = formatar_added_at_display("2026-04-09T10:00:00-03:00");
373        // A função preserva a hora local do timestamp, não converte para UTC
374        assert_eq!(resultado, "2026-04-09 10:00:00");
375        // Deve remover o offset timezone da exibição
376        assert!(
377            !resultado.contains("-03:00"),
378            "Resultado não deve conter offset de timezone: {resultado}"
379        );
380    }
381
382    #[test]
383    fn testa_formatar_added_at_fallback_string_invalida() {
384        let resultado = formatar_added_at_display("lixo-nao-e-data");
385        assert_eq!(
386            resultado, "lixo-nao-e-data",
387            "String inválida deve ser retornada sem modificação"
388        );
389    }
390
391    #[test]
392    fn testa_formatar_added_at_string_vazia() {
393        let resultado = formatar_added_at_display("");
394        assert_eq!(
395            resultado, "",
396            "String vazia deve ser retornada sem modificação"
397        );
398    }
399
400    #[test]
401    fn testa_formatar_added_at_formato_saida_legivel() {
402        let resultado = formatar_added_at_display("2026-04-09T13:34:59.123456789+00:00");
403        // Deve ter exatamente o formato YYYY-MM-DD HH:MM:SS (19 chars)
404        assert_eq!(
405            resultado.len(),
406            19,
407            "Formato de saída deve ter 19 caracteres, obteve: '{resultado}'"
408        );
409        assert!(
410            resultado.contains(' '),
411            "Resultado deve conter espaço separando data e hora: {resultado}"
412        );
413    }
414}