Skip to main content

context7_cli/
output.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Terminal output formatting.
3//!
4//! This is the **only** module allowed to call `println!` or `eprintln!`.
5/// All coloured formatting via the `colored` crate is centralised here.
6/// All user-facing strings are resolved via [`crate::i18n::t`].
7use std::io::IsTerminal;
8use std::sync::OnceLock;
9
10use anyhow::Context;
11use chrono::Utc;
12use colored::Colorize;
13use serde::Serialize;
14
15use crate::api::{DocumentationSnippet, LibrarySearchResult, DocumentationResponse};
16use crate::i18n::{current_language, t, Language, Message};
17use crate::storage::StoredKey;
18
19// ─── QUIET MODE ──────────────────────────────────────────────────────────────
20
21static QUIET_MODE: OnceLock<bool> = OnceLock::new();
22
23/// Enables or disables stdout suppression (`--quiet` flag).
24///
25/// Subsequent calls are silently ignored (OnceLock semantics).
26pub fn set_quiet(v: bool) {
27    let _ = QUIET_MODE.set(v);
28}
29
30fn stdout_allowed() -> bool {
31    !QUIET_MODE.get().copied().unwrap_or(false)
32}
33
34fn print_line(s: &str) {
35    if stdout_allowed() {
36        println!("{s}");
37    }
38}
39
40fn print_blank() {
41    if stdout_allowed() {
42        println!();
43    }
44}
45
46/// Returns the Unicode symbol or its ASCII fallback based on terminal capability.
47///
48/// Uses ASCII when stdout is not an interactive TTY (pipe, redirection),
49/// when `NO_COLOR` is set, or when the `TERM` variable is `dumb`.
50fn symbol_or_ascii<'a>(unicode: &'a str, ascii: &'a str) -> &'a str {
51    static USE_ASCII: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
52    let use_ascii = *USE_ASCII.get_or_init(|| {
53        !std::io::stdout().is_terminal()
54            || std::env::var("NO_COLOR").is_ok()
55            || std::env::var("TERM").map(|t| t == "dumb").unwrap_or(false)
56    });
57    if use_ascii {
58        ascii
59    } else {
60        unicode
61    }
62}
63
64// ─── NDJSON ──────────────────────────────────────────────────────────────────
65
66/// NDJSON envelope for structured output consumable by LLMs.
67///
68/// Each event is emitted as a single JSON line with `type` and `timestamp`.
69#[derive(Serialize)]
70struct NdjsonEvent<'a, T: Serialize> {
71    #[serde(rename = "type")]
72    event_type: &'a str,
73    timestamp: String,
74    #[serde(flatten)]
75    data: T,
76}
77
78/// Emits an NDJSON event (one JSON line) to stdout.
79pub fn emit_ndjson<T: Serialize>(event_type: &str, data: &T) {
80    let event = NdjsonEvent {
81        event_type,
82        timestamp: Utc::now().to_rfc3339(),
83        data,
84    };
85    if let Ok(json) = serde_json::to_string(&event) {
86        print_line(&json);
87    }
88}
89
90// ─── HEALTH ──────────────────────────────────────────────────────────────────
91
92/// Prints a health check status line to stdout, respecting `--quiet`.
93pub fn print_health_line(s: &str) {
94    print_line(s);
95}
96
97/// Returns a colored check/cross symbol for health check results.
98///
99/// Uses Unicode `✔`/`✘` in TTY mode and ASCII `[OK]`/`[FAIL]` in pipes.
100#[must_use]
101pub fn health_symbol(ok: bool) -> colored::ColoredString {
102    if ok {
103        symbol_or_ascii("✔", "[OK]").green()
104    } else {
105        symbol_or_ascii("✘", "[FAIL]").red()
106    }
107}
108
109// ─── LIBRARY ─────────────────────────────────────────────────────────────────
110
111/// Prints the list of libraries returned by the search endpoint.
112///
113/// Displays index, title bold with trust score inline, library ID (dimmed),
114/// and optional description (italic).
115pub fn print_libraries_formatted(results: &[LibrarySearchResult]) {
116    if results.is_empty() {
117        print_line(
118            &t(Message::NoLibraryFound)
119                .yellow()
120                .to_string(),
121        );
122        return;
123    }
124
125    print_line(
126        &t(Message::LibrariesFound)
127            .green()
128            .bold()
129            .to_string(),
130    );
131    print_line(&symbol_or_ascii("─", "-").repeat(60).dimmed().to_string());
132
133    for (i, library) in results.iter().enumerate() {
134        let number = format!("{}.", i + 1);
135
136        // Title bold with trust score inline
137        let title = if let Some(score) = library.trust_score {
138            format!(
139                "{} {} ({} {:.1}/10)",
140                number.cyan(),
141                library.title.bold(),
142                t(Message::TrustScore),
143                score
144            )
145        } else {
146            format!("{} {}", number.cyan(), library.title.bold())
147        };
148        print_line(&title);
149
150        // ID secondary (dimmed)
151        print_line(&format!("   {}", library.id.dimmed()));
152
153        if let Some(description) = &library.description {
154            print_line(&format!("   {}", description.italic()));
155        }
156
157        print_blank();
158    }
159}
160
161/// Prints a user-friendly hint when the requested library was not found.
162///
163/// Called from dispatchers in `cli.rs` before propagating the error,
164/// so the user sees the hint on stderr before the error message.
165pub fn print_library_not_found_hint() {
166    eprintln!("{}", t(Message::LibraryNotFoundApi).yellow());
167}
168
169// ─── DOCUMENTATION ───────────────────────────────────────────────────────────
170
171/// Prints structured documentation from the docs endpoint.
172///
173/// Iterates over `snippets`. Shows a "no documentation found" message if empty.
174pub fn print_documentation_formatted(doc: &DocumentationResponse) {
175    let snippets = match &doc.snippets {
176        Some(s) if !s.is_empty() => s,
177        _ => {
178            print_line(
179                &t(Message::NoDocumentationFound)
180                    .yellow()
181                    .to_string(),
182            );
183            return;
184        }
185    };
186
187    print_line(&t(Message::DocumentationTitle).green().bold().to_string());
188    print_line(&symbol_or_ascii("─", "-").repeat(60).dimmed().to_string());
189
190    for snippet in snippets {
191        display_snippet(snippet);
192    }
193}
194
195/// Prints a single documentation snippet with formatted fields.
196///
197/// Display order: page_title → code_title → code_description → code_list blocks → code_id (source)
198fn display_snippet(snippet: &DocumentationSnippet) {
199    if let Some(page_title) = &snippet.page_title {
200        print_line(&format!("## {}", page_title).green().bold().to_string());
201    }
202
203    if let Some(code_title) = &snippet.code_title {
204        print_line(
205            &format!("{} {}", symbol_or_ascii("▸", ">"), code_title)
206                .cyan()
207                .to_string(),
208        );
209    }
210
211    if let Some(description) = &snippet.code_description {
212        print_line(&format!("  {}", description.dimmed().italic()));
213    }
214
215    if let Some(blocks) = &snippet.code_list {
216        for block in blocks {
217            print_line(&format!("```{}", block.language));
218            print_line(&block.code);
219            print_line("```");
220        }
221    }
222
223    if let Some(source) = &snippet.code_id {
224        print_line(&source.blue().bold().dimmed().to_string());
225    }
226
227    print_blank();
228}
229
230// ─── KEYS ────────────────────────────────────────────────────────────────────
231
232/// Prints all stored keys with 1-based indices and masked values.
233pub fn print_masked_keys(keys: &[StoredKey], mask: impl Fn(&str) -> String) {
234    print_line(
235        &format!("{} {}", keys.len(), t(Message::KeysCount))
236            .green()
237            .bold()
238            .to_string(),
239    );
240    print_line(&symbol_or_ascii("─", "-").repeat(60).dimmed().to_string());
241
242    let added_label = match current_language() {
243        Language::English => "added:",
244        Language::Portuguese => "adicionada:",
245    };
246
247    for (i, key) in keys.iter().enumerate() {
248        print_line(&format!(
249            "  {}  {}  {}",
250            format!("[{}]", i + 1).cyan(),
251            mask(&key.value).bold(),
252            format!(
253                "({} {})",
254                added_label,
255                format_added_at_display(&key.added_at)
256            )
257            .dimmed()
258        ));
259    }
260}
261
262/// Formats an RFC3339 string for compact display: `YYYY-MM-DD HH:MM:SS`.
263///
264/// Returns the original string if parsing fails (robustness).
265pub fn format_added_at_display(iso: &str) -> String {
266    chrono::DateTime::parse_from_rfc3339(iso)
267        .map_or_else(|_| iso.to_string(), |dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
268}
269
270/// Prints the "no keys stored" hint message.
271pub fn print_no_keys() {
272    print_line(&t(Message::NoStoredKey).yellow().to_string());
273    print_line(&t(Message::UseKeysAdd).cyan().to_string());
274}
275
276/// Prints the "no keys to remove" message.
277pub fn print_no_keys_to_remove() {
278    print_line(&t(Message::NoKeysToRemove).yellow().to_string());
279}
280
281/// Prints an invalid index error.
282pub fn print_invalid_index(_index: usize, total: usize) {
283    print_line(
284        &format!("{} {}.", t(Message::InvalidIndex), total)
285            .red()
286            .to_string(),
287    );
288}
289
290/// Prints the success message for `keys add`.
291pub fn print_key_added(path: &std::path::Path) {
292    print_line(&format!(
293        "{} {}",
294        t(Message::KeyAdded),
295        path.display().to_string().green()
296    ));
297}
298
299/// Prints the warning message when a key already exists (dedupe).
300pub fn print_key_already_existed() {
301    print_line(&t(Message::KeyAlreadyExisted).yellow().to_string());
302}
303
304/// Displays an error when the user tries to add an empty API key.
305pub fn print_invalid_empty_key() {
306    eprintln!("{}", t(Message::EmptyOrInvalidKey).red());
307}
308
309/// Displays a warning when the key does not match the expected `ctx7sk-` format.
310pub fn print_key_format_warning() {
311    eprintln!("{}", t(Message::KeyFormatWarning).yellow());
312}
313
314/// Prints the success message for `keys remove`.
315pub fn print_key_removed(masked_key: &str) {
316    print_line(&format!(
317        "{} {}",
318        masked_key.bold(),
319        t(Message::KeyRemovedSuccess)
320    ));
321}
322
323/// Prints the cancellation message for `keys clear`.
324pub fn print_operation_cancelled() {
325    print_line(&t(Message::OperationCancelled).yellow().to_string());
326}
327
328/// Prints the success message for `keys clear`.
329pub fn print_keys_removed() {
330    print_line(&t(Message::AllKeysRemoved).green().to_string());
331}
332
333/// Prints an "XDG not supported" error for `keys path`.
334pub fn print_xdg_unsupported() {
335    print_line(&t(Message::XdgSystemNotSupported).red().to_string());
336}
337
338/// Prints an empty JSON array `[]` to stdout.
339pub fn print_empty_json_array() {
340    print_line("[]");
341}
342
343/// Prints a raw JSON string to stdout.
344pub fn print_raw_json(json: &str) {
345    print_line(json);
346}
347
348/// Prints a file path to stdout.
349pub fn print_config_path(path: &std::path::Path) {
350    print_line(&path.display().to_string());
351}
352
353/// Prints a key in `CONTEXT7_API=<value>` format to stdout.
354pub fn print_exported_key(value: &str) {
355    print_line(&format!("CONTEXT7_API={}", value));
356}
357
358/// Prints raw JSON results to stdout (used by Library and Docs JSON mode).
359pub fn print_json_results(json: &str) {
360    print_line(json);
361}
362
363/// Prints plain text to stdout (used by Docs text mode).
364pub fn print_plain_text(text: &str) {
365    print_line(text);
366}
367
368/// Asks for interactive confirmation before clearing all keys.
369///
370/// Returns `true` if the user confirms with `s`/`sim` (Portuguese) or `y`/`yes` (English).
371pub fn confirm_clear() -> anyhow::Result<bool> {
372    use std::io::Write;
373    if stdout_allowed() {
374        print!("{}", t(Message::ConfirmRemoveAll));
375        std::io::stdout()
376            .flush()
377            .context("Failed to flush stdout buffer")?;
378    }
379
380    let mut input = String::new();
381    std::io::stdin()
382        .read_line(&mut input)
383        .context("Failed to read user confirmation")?;
384
385    Ok(matches!(
386        input.trim().to_lowercase().as_str(),
387        "s" | "sim" | "y" | "yes"
388    ))
389}
390
391/// Prints the success message for `keys import`.
392pub fn print_import_completed(imported: usize, total: usize) {
393    print_line(
394        &format!(
395            "{}/{} {}",
396            imported,
397            total,
398            t(Message::KeysImportedSuccess)
399        )
400        .green()
401        .to_string(),
402    );
403}
404
405#[cfg(test)]
406mod tests {
407    use super::format_added_at_display;
408
409    #[test]
410    fn test_format_added_at_rfc3339_with_nanoseconds() {
411        let result = format_added_at_display("2026-04-09T13:34:59.060818734+00:00");
412        assert_eq!(result, "2026-04-09 13:34:59");
413        assert!(
414            !result.contains('T'),
415            "Result must not contain 'T': {result}"
416        );
417        assert!(
418            !result.contains('.'),
419            "Result must not contain nanoseconds: {result}"
420        );
421        assert!(
422            !result.contains("+00:00"),
423            "Result must not contain timezone offset: {result}"
424        );
425    }
426
427    #[test]
428    fn test_format_added_at_rfc3339_without_nanoseconds() {
429        let result = format_added_at_display("2026-01-01T00:00:00+00:00");
430        assert_eq!(result, "2026-01-01 00:00:00");
431    }
432
433    #[test]
434    fn test_format_added_at_rfc3339_non_utc_offset() {
435        // RFC3339 with -03:00 offset (Brazil) — displays local time (no conversion to UTC)
436        let result = format_added_at_display("2026-04-09T10:00:00-03:00");
437        // The function preserves the local time of the timestamp, does not convert to UTC
438        assert_eq!(result, "2026-04-09 10:00:00");
439        // Must remove the timezone offset from the display
440        assert!(
441            !result.contains("-03:00"),
442            "Result must not contain timezone offset: {result}"
443        );
444    }
445
446    #[test]
447    fn test_format_added_at_fallback_invalid_string() {
448        let result = format_added_at_display("lixo-nao-e-data");
449        assert_eq!(
450            result, "lixo-nao-e-data",
451            "Invalid string must be returned unmodified"
452        );
453    }
454
455    #[test]
456    fn test_format_added_at_empty_string() {
457        let result = format_added_at_display("");
458        assert_eq!(
459            result, "",
460            "Empty string must be returned unmodified"
461        );
462    }
463
464    #[test]
465    fn test_format_added_at_output_format_legible() {
466        let result = format_added_at_display("2026-04-09T13:34:59.123456789+00:00");
467        // Must have exactly the YYYY-MM-DD HH:MM:SS format (19 chars)
468        assert_eq!(
469            result.len(),
470            19,
471            "Output format must be 19 characters, got: '{result}'"
472        );
473        assert!(
474            result.contains(' '),
475            "Result must contain a space separating date and time: {result}"
476        );
477    }
478}