use std::io::IsTerminal;
use std::sync::OnceLock;
use anyhow::Context;
use chrono::Utc;
use colored::Colorize;
use serde::Serialize;
use crate::api::{DocumentationSnippet, LibrarySearchResult, DocumentationResponse};
use crate::i18n::{current_language, t, Language, Message};
use crate::storage::StoredKey;
static QUIET_MODE: OnceLock<bool> = OnceLock::new();
pub fn set_quiet(v: bool) {
let _ = QUIET_MODE.set(v);
}
fn stdout_allowed() -> bool {
!QUIET_MODE.get().copied().unwrap_or(false)
}
fn print_line(s: &str) {
if stdout_allowed() {
println!("{s}");
}
}
fn print_blank() {
if stdout_allowed() {
println!();
}
}
fn symbol_or_ascii<'a>(unicode: &'a str, ascii: &'a str) -> &'a str {
static USE_ASCII: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let use_ascii = *USE_ASCII.get_or_init(|| {
!std::io::stdout().is_terminal()
|| std::env::var("NO_COLOR").is_ok()
|| std::env::var("TERM").map(|t| t == "dumb").unwrap_or(false)
});
if use_ascii {
ascii
} else {
unicode
}
}
#[derive(Serialize)]
struct NdjsonEvent<'a, T: Serialize> {
#[serde(rename = "type")]
event_type: &'a str,
timestamp: String,
#[serde(flatten)]
data: T,
}
pub fn emit_ndjson<T: Serialize>(event_type: &str, data: &T) {
let event = NdjsonEvent {
event_type,
timestamp: Utc::now().to_rfc3339(),
data,
};
if let Ok(json) = serde_json::to_string(&event) {
print_line(&json);
}
}
pub fn print_health_line(s: &str) {
print_line(s);
}
#[must_use]
pub fn health_symbol(ok: bool) -> colored::ColoredString {
if ok {
symbol_or_ascii("✔", "[OK]").green()
} else {
symbol_or_ascii("✘", "[FAIL]").red()
}
}
pub fn print_libraries_formatted(results: &[LibrarySearchResult]) {
if results.is_empty() {
print_line(
&t(Message::NoLibraryFound)
.yellow()
.to_string(),
);
return;
}
print_line(
&t(Message::LibrariesFound)
.green()
.bold()
.to_string(),
);
print_line(&symbol_or_ascii("─", "-").repeat(60).dimmed().to_string());
for (i, library) in results.iter().enumerate() {
let number = format!("{}.", i + 1);
let title = if let Some(score) = library.trust_score {
format!(
"{} {} ({} {:.1}/10)",
number.cyan(),
library.title.bold(),
t(Message::TrustScore),
score
)
} else {
format!("{} {}", number.cyan(), library.title.bold())
};
print_line(&title);
print_line(&format!(" {}", library.id.dimmed()));
if let Some(description) = &library.description {
print_line(&format!(" {}", description.italic()));
}
print_blank();
}
}
pub fn print_library_not_found_hint() {
eprintln!("{}", t(Message::LibraryNotFoundApi).yellow());
}
pub fn print_documentation_formatted(doc: &DocumentationResponse) {
let snippets = match &doc.snippets {
Some(s) if !s.is_empty() => s,
_ => {
print_line(
&t(Message::NoDocumentationFound)
.yellow()
.to_string(),
);
return;
}
};
print_line(&t(Message::DocumentationTitle).green().bold().to_string());
print_line(&symbol_or_ascii("─", "-").repeat(60).dimmed().to_string());
for snippet in snippets {
display_snippet(snippet);
}
}
fn display_snippet(snippet: &DocumentationSnippet) {
if let Some(page_title) = &snippet.page_title {
print_line(&format!("## {}", page_title).green().bold().to_string());
}
if let Some(code_title) = &snippet.code_title {
print_line(
&format!("{} {}", symbol_or_ascii("▸", ">"), code_title)
.cyan()
.to_string(),
);
}
if let Some(description) = &snippet.code_description {
print_line(&format!(" {}", description.dimmed().italic()));
}
if let Some(blocks) = &snippet.code_list {
for block in blocks {
print_line(&format!("```{}", block.language));
print_line(&block.code);
print_line("```");
}
}
if let Some(source) = &snippet.code_id {
print_line(&source.blue().bold().dimmed().to_string());
}
print_blank();
}
pub fn print_masked_keys(keys: &[StoredKey], mask: impl Fn(&str) -> String) {
print_line(
&format!("{} {}", keys.len(), t(Message::KeysCount))
.green()
.bold()
.to_string(),
);
print_line(&symbol_or_ascii("─", "-").repeat(60).dimmed().to_string());
let added_label = match current_language() {
Language::English => "added:",
Language::Portuguese => "adicionada:",
};
for (i, key) in keys.iter().enumerate() {
print_line(&format!(
" {} {} {}",
format!("[{}]", i + 1).cyan(),
mask(&key.value).bold(),
format!(
"({} {})",
added_label,
format_added_at_display(&key.added_at)
)
.dimmed()
));
}
}
pub fn format_added_at_display(iso: &str) -> String {
chrono::DateTime::parse_from_rfc3339(iso)
.map_or_else(|_| iso.to_string(), |dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
}
pub fn print_no_keys() {
print_line(&t(Message::NoStoredKey).yellow().to_string());
print_line(&t(Message::UseKeysAdd).cyan().to_string());
}
pub fn print_no_keys_to_remove() {
print_line(&t(Message::NoKeysToRemove).yellow().to_string());
}
pub fn print_invalid_index(_index: usize, total: usize) {
print_line(
&format!("{} {}.", t(Message::InvalidIndex), total)
.red()
.to_string(),
);
}
pub fn print_key_added(path: &std::path::Path) {
print_line(&format!(
"{} {}",
t(Message::KeyAdded),
path.display().to_string().green()
));
}
pub fn print_key_already_existed() {
print_line(&t(Message::KeyAlreadyExisted).yellow().to_string());
}
pub fn print_invalid_empty_key() {
eprintln!("{}", t(Message::EmptyOrInvalidKey).red());
}
pub fn print_key_format_warning() {
eprintln!("{}", t(Message::KeyFormatWarning).yellow());
}
pub fn print_key_removed(masked_key: &str) {
print_line(&format!(
"{} {}",
masked_key.bold(),
t(Message::KeyRemovedSuccess)
));
}
pub fn print_operation_cancelled() {
print_line(&t(Message::OperationCancelled).yellow().to_string());
}
pub fn print_keys_removed() {
print_line(&t(Message::AllKeysRemoved).green().to_string());
}
pub fn print_xdg_unsupported() {
print_line(&t(Message::XdgSystemNotSupported).red().to_string());
}
pub fn print_empty_json_array() {
print_line("[]");
}
pub fn print_raw_json(json: &str) {
print_line(json);
}
pub fn print_config_path(path: &std::path::Path) {
print_line(&path.display().to_string());
}
pub fn print_exported_key(value: &str) {
print_line(&format!("CONTEXT7_API={}", value));
}
pub fn print_json_results(json: &str) {
print_line(json);
}
pub fn print_plain_text(text: &str) {
print_line(text);
}
pub fn confirm_clear() -> anyhow::Result<bool> {
use std::io::Write;
if stdout_allowed() {
print!("{}", t(Message::ConfirmRemoveAll));
std::io::stdout()
.flush()
.context("Failed to flush stdout buffer")?;
}
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.context("Failed to read user confirmation")?;
Ok(matches!(
input.trim().to_lowercase().as_str(),
"s" | "sim" | "y" | "yes"
))
}
pub fn print_import_completed(imported: usize, total: usize) {
print_line(
&format!(
"{}/{} {}",
imported,
total,
t(Message::KeysImportedSuccess)
)
.green()
.to_string(),
);
}
#[cfg(test)]
mod tests {
use super::format_added_at_display;
#[test]
fn test_format_added_at_rfc3339_with_nanoseconds() {
let result = format_added_at_display("2026-04-09T13:34:59.060818734+00:00");
assert_eq!(result, "2026-04-09 13:34:59");
assert!(
!result.contains('T'),
"Result must not contain 'T': {result}"
);
assert!(
!result.contains('.'),
"Result must not contain nanoseconds: {result}"
);
assert!(
!result.contains("+00:00"),
"Result must not contain timezone offset: {result}"
);
}
#[test]
fn test_format_added_at_rfc3339_without_nanoseconds() {
let result = format_added_at_display("2026-01-01T00:00:00+00:00");
assert_eq!(result, "2026-01-01 00:00:00");
}
#[test]
fn test_format_added_at_rfc3339_non_utc_offset() {
let result = format_added_at_display("2026-04-09T10:00:00-03:00");
assert_eq!(result, "2026-04-09 10:00:00");
assert!(
!result.contains("-03:00"),
"Result must not contain timezone offset: {result}"
);
}
#[test]
fn test_format_added_at_fallback_invalid_string() {
let result = format_added_at_display("lixo-nao-e-data");
assert_eq!(
result, "lixo-nao-e-data",
"Invalid string must be returned unmodified"
);
}
#[test]
fn test_format_added_at_empty_string() {
let result = format_added_at_display("");
assert_eq!(
result, "",
"Empty string must be returned unmodified"
);
}
#[test]
fn test_format_added_at_output_format_legible() {
let result = format_added_at_display("2026-04-09T13:34:59.123456789+00:00");
assert_eq!(
result.len(),
19,
"Output format must be 19 characters, got: '{result}'"
);
assert!(
result.contains(' '),
"Result must contain a space separating date and time: {result}"
);
}
}