1use 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
19static QUIET_MODE: OnceLock<bool> = OnceLock::new();
22
23pub 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
46fn 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#[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
78pub 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
90pub fn print_health_line(s: &str) {
94 print_line(s);
95}
96
97#[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
109pub 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 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 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
161pub fn print_library_not_found_hint() {
166 eprintln!("{}", t(Message::LibraryNotFoundApi).yellow());
167}
168
169pub 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
195fn 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
230pub 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
262pub 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
270pub fn print_no_keys() {
272 print_line(&t(Message::NoStoredKey).yellow().to_string());
273 print_line(&t(Message::UseKeysAdd).cyan().to_string());
274}
275
276pub fn print_no_keys_to_remove() {
278 print_line(&t(Message::NoKeysToRemove).yellow().to_string());
279}
280
281pub 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
290pub 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
299pub fn print_key_already_existed() {
301 print_line(&t(Message::KeyAlreadyExisted).yellow().to_string());
302}
303
304pub fn print_invalid_empty_key() {
306 eprintln!("{}", t(Message::EmptyOrInvalidKey).red());
307}
308
309pub fn print_key_format_warning() {
311 eprintln!("{}", t(Message::KeyFormatWarning).yellow());
312}
313
314pub fn print_key_removed(masked_key: &str) {
316 print_line(&format!(
317 "{} {}",
318 masked_key.bold(),
319 t(Message::KeyRemovedSuccess)
320 ));
321}
322
323pub fn print_operation_cancelled() {
325 print_line(&t(Message::OperationCancelled).yellow().to_string());
326}
327
328pub fn print_keys_removed() {
330 print_line(&t(Message::AllKeysRemoved).green().to_string());
331}
332
333pub fn print_xdg_unsupported() {
335 print_line(&t(Message::XdgSystemNotSupported).red().to_string());
336}
337
338pub fn print_empty_json_array() {
340 print_line("[]");
341}
342
343pub fn print_raw_json(json: &str) {
345 print_line(json);
346}
347
348pub fn print_config_path(path: &std::path::Path) {
350 print_line(&path.display().to_string());
351}
352
353pub fn print_exported_key(value: &str) {
355 print_line(&format!("CONTEXT7_API={}", value));
356}
357
358pub fn print_json_results(json: &str) {
360 print_line(json);
361}
362
363pub fn print_plain_text(text: &str) {
365 print_line(text);
366}
367
368pub 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
391pub 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 let result = format_added_at_display("2026-04-09T10:00:00-03:00");
437 assert_eq!(result, "2026-04-09 10:00:00");
439 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 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}