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