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