Skip to main content

context7_cli/
i18n.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Bilingual internationalisation (EN/PT-BR) for user-facing messages.
3//!
4//! Language resolution order:
5//! 1. CLI flag `--lang en|pt`
6//! 2. Environment variable `CONTEXT7_LANG`
7//! 3. `sys_locale::get_locale()` — locale starting with `"pt"` → Portuguese
8//! 4. Default: English
9//!
10//! Call [`set_language`] once at startup (in `run()`), then call
11//! [`current_language`] or [`t`] anywhere to retrieve localised strings.
12use std::sync::OnceLock;
13
14// ─── LANGUAGE ───────────────────────────────────────────────────────────────────
15
16/// Supported display languages.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Language {
19    /// English output.
20    English,
21    /// Brazilian Portuguese output.
22    Portuguese,
23}
24
25/// Global language setting — written once at startup, read-only thereafter.
26static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
27
28/// Returns the currently configured language.
29///
30/// Defaults to [`Language::English`] if [`set_language`] has not been called.
31pub fn current_language() -> Language {
32    *GLOBAL_LANGUAGE.get().unwrap_or(&Language::English)
33}
34
35/// Sets the global language. Silently ignored if already set (OnceLock semantics).
36pub fn set_language(language: Language) {
37    let _ = GLOBAL_LANGUAGE.set(language);
38}
39
40/// Resolves the language from CLI flag, env var, or system locale.
41///
42/// Resolution order:
43/// 1. `cli_lang` — value of `--lang` flag (if provided)
44/// 2. `CONTEXT7_LANG` environment variable
45/// 3. `sys_locale::get_locale()` — BCP 47 locale (e.g. `"pt-BR"`)
46/// 4. Default: English
47#[must_use]
48pub fn resolve_language(cli_lang: Option<&str>) -> Language {
49    // 1. CLI flag
50    if let Some(lang) = cli_lang {
51        return parse_lang_str(lang);
52    }
53
54    // 2. Environment variable
55    if let Ok(env_lang) = std::env::var("CONTEXT7_LANG") {
56        return parse_lang_str(&env_lang);
57    }
58
59    // 3. System locale
60    if let Some(locale) = sys_locale::get_locale() {
61        if locale.to_lowercase().starts_with("pt") {
62            return Language::Portuguese;
63        }
64    }
65
66    // 4. Default
67    Language::English
68}
69
70fn parse_lang_str(s: &str) -> Language {
71    match s.to_lowercase().as_str() {
72        "pt" | "pt-br" | "pt_br" | "portugues" | "português" => Language::Portuguese,
73        _ => Language::English,
74    }
75}
76
77// ─── MESSAGE ─────────────────────────────────────────────────────────────────
78
79/// All user-facing messages indexed by variant.
80///
81/// Each variant maps to a pair of `(English, Portuguese)` strings.
82#[derive(Debug, Clone, Copy)]
83pub enum Message {
84    // Keys subcommand (15 variants)
85    /// "Key added successfully at: {path}"
86    KeyAdded,
87    /// "Key already exists (skipping)."
88    KeyAlreadyExisted,
89    /// "No key stored."
90    NoStoredKey,
91    /// "Use `context7 keys add <KEY>` to add a key."
92    UseKeysAdd,
93    /// "{n} key(s) stored:"
94    KeysCount,
95    /// "No key stored to remove."
96    NoKeysToRemove,
97    /// "Index {i} invalid. Use a number between 1 and {n}."
98    InvalidIndex,
99    /// "Key {masked} removed successfully."
100    KeyRemovedSuccess,
101    /// "Operation cancelled."
102    OperationCancelled,
103    /// "All keys removed."
104    AllKeysRemoved,
105    /// "System does not support XDG directories."
106    XdgSystemNotSupported,
107    /// "{imported}/{total} key(s) imported successfully."
108    KeysImportedSuccess,
109    /// "No CONTEXT7_API= key found in: {file}"
110    NoContext7KeyInFile,
111    /// "Are you sure you want to remove ALL keys? [y/N] " / "[s/N] "
112    ConfirmRemoveAll,
113    /// Accepted confirmation responses: "y"/"yes" or "s"/"sim"
114    ConfirmationResponse,
115    /// "API key cannot be empty. Get a key at <https://context7.com>"
116    EmptyOrInvalidKey,
117    /// "Warning: key does not match expected format (ctx7sk-...). API calls may fail."
118    KeyFormatWarning,
119
120    // Library / Docs (8 variants)
121    /// "No library found."
122    NoLibraryFound,
123    /// "Libraries found:"
124    LibrariesFound,
125    /// "Trust:"
126    TrustScore,
127    /// "No documentation found."
128    NoDocumentationFound,
129    /// "Documentation:"
130    DocumentationTitle,
131    /// "Sources:"
132    SourcesTitle,
133    /// "No content available."
134    NoContentAvailable,
135    /// "Searching library: {name}"
136    SearchingLibrary,
137
138    // HTTP / Network errors (8 variants)
139    /// "Network error searching library: {err}"
140    NetworkError,
141    /// "Network error fetching documentation: {err}"
142    NetworkErrorDocs,
143    /// "Failed to deserialise JSON response: {err}"
144    DeserialiseFailure,
145    /// "Rate limit reached (429), waiting for retry…"
146    RateLimitReached,
147    /// "Server error ({status}), retrying…"
148    ServerError,
149    /// "Invalid API key (401/403), trying next…"
150    InvalidApiKey,
151    /// "Attempt {n}/{max}"
152    Attempt,
153    /// "Waiting {ms}ms before retrying…"
154    WaitingForRetry,
155
156    // Config / XDG (7 variants)
157    /// "System does not support XDG — cannot save configuration"
158    XdgPathError,
159    /// "Failed to read XDG config at: {path}"
160    ConfigReadFailure,
161    /// "Invalid TOML at: {path}"
162    InvalidTomlFailure,
163    /// "Failed to write config at: {path}"
164    ConfigWriteFailure,
165    /// "Failed to create directory: {path}"
166    DirectoryCreateFailure,
167    /// "No API key configured. Set CONTEXT7_API_KEYS or use `keys add`."
168    NoKeyConfigured,
169    /// "Failed to serialise configuration to TOML"
170    TomlSerialiseFailure,
171
172    // Logging / info (8 variants)
173    /// "Keys loaded from CONTEXT7_API_KEYS environment variable"
174    KeysLoadedFromEnvVar,
175    /// "Keys loaded from XDG configuration"
176    KeysLoadedFromXdg,
177    /// "Failed to read XDG configuration (continuing): {err}"
178    XdgReadFailureContinuing,
179    /// "Starting context7 with {n} API keys available"
180    StartingWithKeys,
181    /// "Keys loaded from compile-time CONTEXT7_API_KEYS"
182    KeysLoadedAtCompileTime,
183    /// "Failed to serialise results to JSON"
184    JsonSerialiseFailure,
185    /// "Failed to serialise documentation to JSON"
186    DocsSerialiseFailure,
187    /// "Failed to search library '{name}'"
188    LibrarySearchFailure,
189
190    // Permissions / IO (2 variants)
191    /// "Failed to read metadata of: {path}"
192    MetadataReadFailure,
193    /// "Failed to set permissions on: {path}"
194    PermissionSetFailure,
195
196    // API / Docs errors (4 variants)
197    /// "Failed to fetch documentation for: {library_id}"
198    DocsFetchFailure,
199    /// "Failed to create HTTP client"
200    HttpClientCreateFailure,
201    /// "No documentation available"
202    NoDocumentationAvailable,
203    /// "Library not found. Verify the ID via `context7 library <name>`."
204    LibraryNotFoundApi,
205
206    // Health subcommand (7 variants)
207    /// "Running health checks…"
208    HealthRunning,
209    /// "Config: OK"
210    HealthConfigOk,
211    /// "Config: FAILED"
212    HealthConfigFailed,
213    /// "API keys: {n} configured"
214    HealthKeysOk,
215    /// "API keys: none configured (exit 66)"
216    HealthKeysMissing,
217    /// "API: reachable"
218    HealthApiOk,
219    /// "API: offline or unreachable (exit 69)"
220    HealthApiOffline,
221}
222
223impl Message {
224    /// Returns the localised text for this message in the given language.
225    ///
226    /// Prefer this over the global [`t`] function when you need deterministic
227    /// translations without depending on the process-wide language setting
228    /// (useful for tests and library usage).
229    pub fn text(self, language: Language) -> &'static str {
230        match language {
231            Language::English => en(self),
232            Language::Portuguese => pt(self),
233        }
234    }
235}
236
237/// Returns the localised string for a message in the current language.
238///
239/// For parameterised messages use `format!("{} {}", t(Message::Foo), param)`.
240#[must_use]
241pub fn t(msg: Message) -> &'static str {
242    match current_language() {
243        Language::English => en(msg),
244        Language::Portuguese => pt(msg),
245    }
246}
247
248fn en(msg: Message) -> &'static str {
249    match msg {
250        // Keys
251        Message::KeyAdded => "Key added successfully at:",
252        Message::KeyAlreadyExisted => "Key already exists (skipping).",
253        Message::NoStoredKey => "No key stored.",
254        Message::UseKeysAdd => "Use `context7 keys add <KEY>` to add a key.",
255        Message::KeysCount => "key(s) stored:",
256        Message::NoKeysToRemove => "No key stored to remove.",
257        Message::InvalidIndex => "Invalid index. Use a number between 1 and",
258        Message::KeyRemovedSuccess => "Key removed successfully.",
259        Message::OperationCancelled => "Operation cancelled.",
260        Message::AllKeysRemoved => "All keys removed.",
261        Message::XdgSystemNotSupported => "System does not support XDG directories.",
262        Message::KeysImportedSuccess => "key(s) imported successfully.",
263        Message::NoContext7KeyInFile => "No CONTEXT7_API= key found in:",
264        Message::ConfirmRemoveAll => "Are you sure you want to remove ALL keys? [y/N] ",
265        Message::ConfirmationResponse => "y|yes",
266        Message::EmptyOrInvalidKey => {
267            "API key cannot be empty. Get a key at https://context7.com"
268        }
269        Message::KeyFormatWarning => {
270            "Warning: key does not match expected format (ctx7sk-...). API calls may fail."
271        }
272
273        // Library / Docs
274        Message::NoLibraryFound => "No library found.",
275        Message::LibrariesFound => "Libraries found:",
276        Message::TrustScore => "trust",
277        Message::NoDocumentationFound => "No documentation found.",
278        Message::DocumentationTitle => "Documentation:",
279        Message::SourcesTitle => "Sources:",
280        Message::NoContentAvailable => "No content available.",
281        Message::SearchingLibrary => "Searching library:",
282
283        // HTTP / Network
284        Message::NetworkError => "Network error searching library:",
285        Message::NetworkErrorDocs => "Network error fetching documentation:",
286        Message::DeserialiseFailure => "Failed to deserialise JSON response:",
287        Message::RateLimitReached => "Rate limit reached (429), waiting for retry…",
288        Message::ServerError => "Server error, retrying…",
289        Message::InvalidApiKey => "Invalid API key (401/403), trying next…",
290        Message::Attempt => "Attempt",
291        Message::WaitingForRetry => "Waiting before retrying…",
292
293        // Config / XDG
294        Message::XdgPathError => "System does not support XDG — cannot save configuration",
295        Message::ConfigReadFailure => "Failed to read XDG config at:",
296        Message::InvalidTomlFailure => "Invalid TOML at:",
297        Message::ConfigWriteFailure => "Failed to write config at:",
298        Message::DirectoryCreateFailure => "Failed to create directory:",
299        Message::NoKeyConfigured => {
300            "No API key configured. Set CONTEXT7_API_KEYS or use `context7 keys add <KEY>`."
301        }
302        Message::TomlSerialiseFailure => "Failed to serialise configuration to TOML",
303
304        // Logging / info
305        Message::KeysLoadedFromEnvVar => {
306            "Keys loaded from CONTEXT7_API_KEYS environment variable"
307        }
308        Message::KeysLoadedFromXdg => "Keys loaded from XDG configuration",
309        Message::XdgReadFailureContinuing => "Failed to read XDG configuration (continuing):",
310        Message::StartingWithKeys => "Starting context7 with",
311        Message::KeysLoadedAtCompileTime => "Keys loaded from compile-time CONTEXT7_API_KEYS",
312        Message::JsonSerialiseFailure => "Failed to serialise results to JSON",
313        Message::DocsSerialiseFailure => "Failed to serialise documentation to JSON",
314        Message::LibrarySearchFailure => "Failed to search library",
315
316        // Permissions / IO
317        Message::MetadataReadFailure => "Failed to read metadata of:",
318        Message::PermissionSetFailure => "Failed to set permissions on:",
319
320        // API / Docs errors
321        Message::DocsFetchFailure => "Failed to fetch documentation for:",
322        Message::HttpClientCreateFailure => "Failed to create HTTP client",
323        Message::NoDocumentationAvailable => "No documentation available",
324        Message::LibraryNotFoundApi => {
325            "Library not found. Verify the ID via `context7 library <name>`."
326        }
327
328        // Health
329        Message::HealthRunning => "Running health checks…",
330        Message::HealthConfigOk => "Config: OK",
331        Message::HealthConfigFailed => "Config: FAILED",
332        Message::HealthKeysOk => "API keys configured:",
333        Message::HealthKeysMissing => "API keys: none configured",
334        Message::HealthApiOk => "API: reachable",
335        Message::HealthApiOffline => "API: offline or unreachable",
336    }
337}
338
339fn pt(msg: Message) -> &'static str {
340    match msg {
341        // Keys
342        Message::KeyAdded => "Chave adicionada com sucesso em:",
343        Message::KeyAlreadyExisted => "Chave já existente (ignorando).",
344        Message::NoStoredKey => "Nenhuma chave armazenada.",
345        Message::UseKeysAdd => "Use `context7 keys add <CHAVE>` para adicionar uma chave.",
346        Message::KeysCount => "chave(s) armazenada(s):",
347        Message::NoKeysToRemove => "Nenhuma chave armazenada para remover.",
348        Message::InvalidIndex => "Índice inválido. Use um número entre 1 e",
349        Message::KeyRemovedSuccess => "Chave removida com sucesso.",
350        Message::OperationCancelled => "Operação cancelada.",
351        Message::AllKeysRemoved => "Todas as chaves foram removidas.",
352        Message::XdgSystemNotSupported => "Sistema não suporta diretórios XDG.",
353        Message::KeysImportedSuccess => "chave(s) importada(s) com sucesso.",
354        Message::NoContext7KeyInFile => "Nenhuma chave CONTEXT7_API= encontrada em:",
355        Message::ConfirmRemoveAll => "Tem certeza que deseja remover TODAS as chaves? [s/N] ",
356        Message::ConfirmationResponse => "s|sim",
357        Message::EmptyOrInvalidKey => {
358            "Chave de API não pode ser vazia. Obtenha uma em https://context7.com"
359        }
360        Message::KeyFormatWarning => {
361            "Aviso: chave não corresponde ao formato esperado (ctx7sk-...). Chamadas de API podem falhar."
362        }
363
364        // Library / Docs
365        Message::NoLibraryFound => "Nenhuma biblioteca encontrada.",
366        Message::LibrariesFound => "Bibliotecas encontradas:",
367        Message::TrustScore => "confiança",
368        Message::NoDocumentationFound => "Nenhuma documentação encontrada.",
369        Message::DocumentationTitle => "Documentação:",
370        Message::SourcesTitle => "Fontes:",
371        Message::NoContentAvailable => "Sem conteúdo disponível.",
372        Message::SearchingLibrary => "Buscando biblioteca:",
373
374        // HTTP / Network
375        Message::NetworkError => "Erro de rede ao buscar biblioteca:",
376        Message::NetworkErrorDocs => "Erro de rede ao buscar documentação:",
377        Message::DeserialiseFailure => "Falha ao desserializar resposta JSON:",
378        Message::RateLimitReached => "Rate limit atingido (429), aguardando retry…",
379        Message::ServerError => "Erro do servidor, tentando novamente…",
380        Message::InvalidApiKey => "Chave de API inválida (401/403), tentando próxima…",
381        Message::Attempt => "Attempt",
382        Message::WaitingForRetry => "Aguardando antes de tentar novamente…",
383
384        // Config / XDG
385        Message::XdgPathError => {
386            "Sistema não suporta diretórios XDG — impossível salvar configuração"
387        }
388        Message::ConfigReadFailure => "Falha ao ler configuração XDG em:",
389        Message::InvalidTomlFailure => "TOML inválido em:",
390        Message::ConfigWriteFailure => "Falha ao escrever config em:",
391        Message::DirectoryCreateFailure => "Falha ao criar diretório:",
392        Message::NoKeyConfigured => {
393            "Nenhuma chave de API encontrada. Configure CONTEXT7_API_KEYS ou use `context7 keys add <CHAVE>`."
394        }
395        Message::TomlSerialiseFailure => "Falha ao serializar configuração para TOML",
396
397        // Logging / info
398        Message::KeysLoadedFromEnvVar => {
399            "Chaves carregadas via variável de ambiente CONTEXT7_API_KEYS"
400        }
401        Message::KeysLoadedFromXdg => "Chaves carregadas via configuração XDG",
402        Message::XdgReadFailureContinuing => "Falha ao ler configuração XDG (continuando):",
403        Message::StartingWithKeys => "Iniciando context7 com",
404        Message::KeysLoadedAtCompileTime => {
405            "Chaves carregadas via compile-time CONTEXT7_API_KEYS"
406        }
407        Message::JsonSerialiseFailure => "Falha ao serializar resultados para JSON",
408        Message::DocsSerialiseFailure => "Falha ao serializar documentação para JSON",
409        Message::LibrarySearchFailure => "Falha ao buscar biblioteca",
410
411        // Permissions / IO
412        Message::MetadataReadFailure => "Falha ao ler metadados de:",
413        Message::PermissionSetFailure => "Falha ao definir permissões em:",
414
415        // API / Docs errors
416        Message::DocsFetchFailure => "Falha ao buscar documentação para:",
417        Message::HttpClientCreateFailure => "Falha ao criar cliente HTTP",
418        Message::NoDocumentationAvailable => "Nenhuma documentação disponível",
419        Message::LibraryNotFoundApi => {
420            "Biblioteca não encontrada. Verifique o ID via `context7 library <nome>`."
421        }
422
423        // Health
424        Message::HealthRunning => "Executando verificações de saúde…",
425        Message::HealthConfigOk => "Config: OK",
426        Message::HealthConfigFailed => "Config: FALHOU",
427        Message::HealthKeysOk => "Chaves de API configuradas:",
428        Message::HealthKeysMissing => "Chaves de API: nenhuma configurada",
429        Message::HealthApiOk => "API: acessível",
430        Message::HealthApiOffline => "API: offline ou inacessível",
431    }
432}
433
434// ─── TESTS ───────────────────────────────────────────────────────────────────
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn test_current_language_default_is_english() {
442        // If OnceLock has not been set in this test process, it must be English
443        let _ = current_language();
444    }
445
446    #[test]
447    fn test_resolve_language_cli_flag_pt() {
448        assert_eq!(resolve_language(Some("pt")), Language::Portuguese);
449        assert_eq!(resolve_language(Some("pt-BR")), Language::Portuguese);
450        assert_eq!(resolve_language(Some("PT_BR")), Language::Portuguese);
451    }
452
453    #[test]
454    fn test_resolve_language_cli_flag_en() {
455        assert_eq!(resolve_language(Some("en")), Language::English);
456        assert_eq!(resolve_language(Some("en-US")), Language::English);
457    }
458
459    #[test]
460    fn test_resolve_language_no_flag_no_env_returns_english_or_pt() {
461        // Without flag and without env, must return English or Portuguese (depending on the system)
462        let language = resolve_language(None);
463        assert!(language == Language::English || language == Language::Portuguese);
464    }
465
466    #[test]
467    fn test_t_message_no_key_en() {
468        let msg_en = en(Message::NoStoredKey);
469        assert!(!msg_en.is_empty());
470        assert!(
471            msg_en.to_lowercase().contains("no") || msg_en.to_lowercase().contains("key"),
472            "EN must contain 'no' or 'key', got: {}",
473            msg_en
474        );
475    }
476
477    #[test]
478    fn test_t_message_no_key_pt() {
479        let msg_pt = pt(Message::NoStoredKey);
480        assert!(!msg_pt.is_empty());
481        assert!(
482            msg_pt.to_lowercase().contains("nenhuma") || msg_pt.to_lowercase().contains("key"),
483            "PT deve conter 'nenhuma' ou 'key', obteve: {}",
484            msg_pt
485        );
486    }
487
488    #[test]
489    fn test_confirmation_response_en_contains_y() {
490        let confirmacao = en(Message::ConfirmationResponse);
491        assert!(
492            confirmacao.contains('y'),
493            "EN: confirmation response must contain 'y'"
494        );
495    }
496
497    #[test]
498    fn test_confirmation_response_pt_contains_s() {
499        let confirmacao = pt(Message::ConfirmationResponse);
500        assert!(
501            confirmacao.contains('s'),
502            "PT: confirmation response must contain 's'"
503        );
504    }
505
506    #[test]
507    fn test_all_variants_en_not_empty() {
508        let variantes = [
509            Message::KeyAdded,
510            Message::KeyAlreadyExisted,
511            Message::NoStoredKey,
512            Message::UseKeysAdd,
513            Message::KeysCount,
514            Message::NoKeysToRemove,
515            Message::InvalidIndex,
516            Message::KeyRemovedSuccess,
517            Message::OperationCancelled,
518            Message::AllKeysRemoved,
519            Message::XdgSystemNotSupported,
520            Message::KeysImportedSuccess,
521            Message::NoContext7KeyInFile,
522            Message::ConfirmRemoveAll,
523            Message::ConfirmationResponse,
524            Message::NoLibraryFound,
525            Message::LibrariesFound,
526            Message::TrustScore,
527            Message::NoDocumentationFound,
528            Message::DocumentationTitle,
529            Message::SourcesTitle,
530            Message::NoContentAvailable,
531            Message::SearchingLibrary,
532            Message::NetworkError,
533            Message::NetworkErrorDocs,
534            Message::DeserialiseFailure,
535            Message::RateLimitReached,
536            Message::ServerError,
537            Message::InvalidApiKey,
538            Message::Attempt,
539            Message::WaitingForRetry,
540            Message::XdgPathError,
541            Message::ConfigReadFailure,
542            Message::InvalidTomlFailure,
543            Message::ConfigWriteFailure,
544            Message::DirectoryCreateFailure,
545            Message::NoKeyConfigured,
546            Message::TomlSerialiseFailure,
547            Message::KeysLoadedFromEnvVar,
548            Message::KeysLoadedFromXdg,
549            Message::XdgReadFailureContinuing,
550            Message::StartingWithKeys,
551            Message::KeysLoadedAtCompileTime,
552            Message::JsonSerialiseFailure,
553            Message::DocsSerialiseFailure,
554            Message::LibrarySearchFailure,
555            Message::MetadataReadFailure,
556            Message::PermissionSetFailure,
557            Message::DocsFetchFailure,
558            Message::HttpClientCreateFailure,
559            Message::NoDocumentationAvailable,
560            Message::LibraryNotFoundApi,
561            Message::EmptyOrInvalidKey,
562            Message::KeyFormatWarning,
563            Message::HealthRunning,
564            Message::HealthConfigOk,
565            Message::HealthConfigFailed,
566            Message::HealthKeysOk,
567            Message::HealthKeysMissing,
568            Message::HealthApiOk,
569            Message::HealthApiOffline,
570        ];
571
572        for v in &variantes {
573            let msg_en = en(*v);
574            let msg_pt = pt(*v);
575            assert!(
576                !msg_en.is_empty(),
577                "EN message vazia para variante {:?}",
578                v
579            );
580            assert!(
581                !msg_pt.is_empty(),
582                "PT message vazia para variante {:?}",
583                v
584            );
585        }
586    }
587}