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