Skip to main content

sqlite_graphrag/
i18n.rs

1//! Camada bilíngue de mensagens humanas.
2//!
3//! A CLI usa `--lang en|pt` (flag global) ou `SQLITE_GRAPHRAG_LANG` (env var) para escolher
4//! o idioma das mensagens stderr de progresso. JSON de stdout é determinístico e idêntico
5//! entre idiomas — apenas strings destinadas a humanos passam pelo módulo.
6//!
7//! Detecção (do mais para o menos prioritário):
8//! 1. Flag `--lang` explícita
9//! 2. Env var `SQLITE_GRAPHRAG_LANG`
10//! 3. Locale do SO (`LANG`, `LC_ALL`) com prefixo `pt`
11//! 4. Fallback `English`
12
13use std::sync::OnceLock;
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
16pub enum Language {
17    #[value(name = "en", aliases = ["english", "EN"])]
18    English,
19    #[value(name = "pt", aliases = ["portugues", "portuguese", "pt-BR", "pt-br", "PT"])]
20    Portugues,
21}
22
23impl Language {
24    /// Converte string de linha de comando em Language sem depender do clap.
25    /// Aceita os mesmos aliases definidos em `#[value(...)]`: "en", "pt", etc.
26    pub fn from_str_opt(s: &str) -> Option<Self> {
27        match s.to_lowercase().as_str() {
28            "en" | "english" => Some(Language::English),
29            "pt" | "pt-br" | "portugues" | "portuguese" => Some(Language::Portugues),
30            _ => None,
31        }
32    }
33
34    pub fn from_env_or_locale() -> Self {
35        if let Ok(v) = std::env::var("SQLITE_GRAPHRAG_LANG") {
36            let v = v.to_lowercase();
37            if v.starts_with("pt") {
38                return Language::Portugues;
39            }
40            if v.starts_with("en") {
41                return Language::English;
42            }
43        }
44        for var in &["LC_ALL", "LANG"] {
45            if let Ok(v) = std::env::var(var) {
46                if v.to_lowercase().starts_with("pt") {
47                    return Language::Portugues;
48                }
49            }
50        }
51        Language::English
52    }
53}
54
55static IDIOMA_GLOBAL: OnceLock<Language> = OnceLock::new();
56
57/// Inicializa o idioma global. Chamadas subsequentes são ignoradas silenciosamente
58/// (OnceLock semantics) — garantindo thread-safety e determinismo.
59pub fn init(explicit: Option<Language>) {
60    let resolved = explicit.unwrap_or_else(Language::from_env_or_locale);
61    let _ = IDIOMA_GLOBAL.set(resolved);
62}
63
64/// Retorna o idioma ativo ou fallback English se `init` nunca foi chamado.
65pub fn current() -> Language {
66    *IDIOMA_GLOBAL.get_or_init(Language::from_env_or_locale)
67}
68
69/// Traduz uma mensagem bilíngue escolhendo a variante ativa.
70pub fn tr(en: &str, pt: &str) -> &'static str {
71    // SAFETY: Retornamos uma das duas strings estáticas passadas como &str.
72    // Como não temos como provar ao borrow checker que as referências sobrevivem,
73    // usamos Box::leak para transformar em &'static str. Custo mínimo (dezenas de
74    // strings distintas durante vida do processo CLI).
75    match current() {
76        Language::English => Box::leak(en.to_string().into_boxed_str()),
77        Language::Portugues => Box::leak(pt.to_string().into_boxed_str()),
78    }
79}
80
81/// Prefixo localizado para mensagens de erro exibidas ao usuário final.
82pub fn prefixo_erro() -> &'static str {
83    match current() {
84        Language::English => "Error",
85        Language::Portugues => "Erro",
86    }
87}
88
89/// Mensagens de erro localizadas para as variantes de AppError.
90pub mod erros {
91    use super::current;
92    use crate::i18n::Language;
93
94    pub fn memoria_nao_encontrada(nome: &str, namespace: &str) -> String {
95        match current() {
96            Language::English => {
97                format!("memory '{nome}' not found in namespace '{namespace}'")
98            }
99            Language::Portugues => {
100                format!("memória '{nome}' não encontrada no namespace '{namespace}'")
101            }
102        }
103    }
104
105    pub fn banco_nao_encontrado(path: &str) -> String {
106        match current() {
107            Language::English => {
108                format!("database not found at {path}. Run 'sqlite-graphrag init' first.")
109            }
110            Language::Portugues => format!(
111                "banco de dados não encontrado em {path}. Execute 'sqlite-graphrag init' primeiro."
112            ),
113        }
114    }
115
116    pub fn entidade_nao_encontrada(nome: &str, namespace: &str) -> String {
117        match current() {
118            Language::English => {
119                format!("entity \"{nome}\" does not exist in namespace \"{namespace}\"")
120            }
121            Language::Portugues => {
122                format!("entidade \"{nome}\" não existe no namespace \"{namespace}\"")
123            }
124        }
125    }
126
127    pub fn relacionamento_nao_encontrado(
128        de: &str,
129        rel: &str,
130        para: &str,
131        namespace: &str,
132    ) -> String {
133        match current() {
134            Language::English => format!(
135                "relationship \"{de}\" --[{rel}]--> \"{para}\" does not exist in namespace \"{namespace}\""
136            ),
137            Language::Portugues => format!(
138                "relacionamento \"{de}\" --[{rel}]--> \"{para}\" não existe no namespace \"{namespace}\""
139            ),
140        }
141    }
142
143    pub fn memoria_duplicada(nome: &str, namespace: &str) -> String {
144        match current() {
145            Language::English => format!(
146                "memory '{nome}' already exists in namespace '{namespace}'. Use --force-merge to update."
147            ),
148            Language::Portugues => format!(
149                "memória '{nome}' já existe no namespace '{namespace}'. Use --force-merge para atualizar."
150            ),
151        }
152    }
153
154    pub fn conflito_optimistic_lock(expected: i64, current_ts: i64) -> String {
155        match current() {
156            Language::English => format!(
157                "optimistic lock conflict: expected updated_at={expected}, but current is {current_ts}"
158            ),
159            Language::Portugues => format!(
160                "conflito de optimistic lock: esperava updated_at={expected}, mas atual é {current_ts}"
161            ),
162        }
163    }
164
165    pub fn versao_nao_encontrada(versao: i64, nome: &str) -> String {
166        match current() {
167            Language::English => format!("version {versao} not found for memory '{nome}'"),
168            Language::Portugues => {
169                format!("versão {versao} não encontrada para a memória '{nome}'")
170            }
171        }
172    }
173
174    pub fn sem_resultados_recall(min_distance: f32, query: &str, namespace: &str) -> String {
175        match current() {
176            Language::English => format!(
177                "no results within --min-distance {min_distance} for query '{query}' in namespace '{namespace}'"
178            ),
179            Language::Portugues => format!(
180                "nenhum resultado dentro de --min-distance {min_distance} para a consulta '{query}' no namespace '{namespace}'"
181            ),
182        }
183    }
184
185    pub fn memoria_soft_deleted_nao_encontrada(nome: &str, namespace: &str) -> String {
186        match current() {
187            Language::English => {
188                format!("soft-deleted memory '{nome}' not found in namespace '{namespace}'")
189            }
190            Language::Portugues => {
191                format!("memória soft-deleted '{nome}' não encontrada no namespace '{namespace}'")
192            }
193        }
194    }
195
196    pub fn conflito_processo_concorrente() -> String {
197        match current() {
198            Language::English => {
199                "optimistic lock conflict: memory was modified by another process".to_string()
200            }
201            Language::Portugues => {
202                "conflito de optimistic lock: memória foi modificada por outro processo".to_string()
203            }
204        }
205    }
206
207    pub fn limite_entidades(max: usize) -> String {
208        match current() {
209            Language::English => format!("entities exceed limit of {max}"),
210            Language::Portugues => format!("entidades excedem o limite de {max}"),
211        }
212    }
213
214    pub fn limite_relacionamentos(max: usize) -> String {
215        match current() {
216            Language::English => format!("relationships exceed limit of {max}"),
217            Language::Portugues => format!("relacionamentos excedem o limite de {max}"),
218        }
219    }
220}
221
222/// Mensagens de validação localizadas para os campos de memória.
223pub mod validacao {
224    use super::current;
225    use crate::i18n::Language;
226
227    pub fn nome_comprimento(max: usize) -> String {
228        match current() {
229            Language::English => format!("name must be 1-{max} chars"),
230            Language::Portugues => format!("nome deve ter entre 1 e {max} caracteres"),
231        }
232    }
233
234    pub fn nome_reservado() -> String {
235        match current() {
236            Language::English => {
237                "names and namespaces starting with __ are reserved for internal use".to_string()
238            }
239            Language::Portugues => {
240                "nomes e namespaces iniciados com __ são reservados para uso interno".to_string()
241            }
242        }
243    }
244
245    pub fn nome_kebab(nome: &str) -> String {
246        match current() {
247            Language::English => format!(
248                "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
249            ),
250            Language::Portugues => {
251                format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
252            }
253        }
254    }
255
256    pub fn descricao_excede(max: usize) -> String {
257        match current() {
258            Language::English => format!("description must be <= {max} chars"),
259            Language::Portugues => format!("descrição deve ter no máximo {max} caracteres"),
260        }
261    }
262
263    pub fn body_excede(max: usize) -> String {
264        match current() {
265            Language::English => format!("body exceeds {max} chars"),
266            Language::Portugues => format!("corpo excede {max} caracteres"),
267        }
268    }
269
270    pub fn novo_nome_comprimento(max: usize) -> String {
271        match current() {
272            Language::English => format!("new-name must be 1-{max} chars"),
273            Language::Portugues => format!("novo nome deve ter entre 1 e {max} caracteres"),
274        }
275    }
276
277    pub fn novo_nome_kebab(nome: &str) -> String {
278        match current() {
279            Language::English => format!(
280                "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
281            ),
282            Language::Portugues => format!(
283                "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
284            ),
285        }
286    }
287
288    pub fn namespace_comprimento() -> String {
289        match current() {
290            Language::English => "namespace must be 1-80 chars".to_string(),
291            Language::Portugues => "namespace deve ter entre 1 e 80 caracteres".to_string(),
292        }
293    }
294
295    pub fn namespace_formato() -> String {
296        match current() {
297            Language::English => "namespace must be alphanumeric + hyphens/underscores".to_string(),
298            Language::Portugues => {
299                "namespace deve ser alfanumérico com hífens/sublinhados".to_string()
300            }
301        }
302    }
303
304    pub fn path_traversal(p: &str) -> String {
305        match current() {
306            Language::English => format!("path traversal rejected: {p}"),
307            Language::Portugues => format!("traversal de caminho rejeitado: {p}"),
308        }
309    }
310
311    pub fn tz_invalido(v: &str) -> String {
312        match current() {
313            Language::English => format!(
314                "SQLITE_GRAPHRAG_DISPLAY_TZ invalid: '{v}'; use an IANA name like 'America/Sao_Paulo'"
315            ),
316            Language::Portugues => format!(
317                "SQLITE_GRAPHRAG_DISPLAY_TZ inválido: '{v}'; use um nome IANA como 'America/Sao_Paulo'"
318            ),
319        }
320    }
321
322    pub fn config_namespace_invalido(path: &str, err: &str) -> String {
323        match current() {
324            Language::English => {
325                format!("invalid project namespace config '{path}': {err}")
326            }
327            Language::Portugues => {
328                format!("configuração de namespace de projeto inválida '{path}': {err}")
329            }
330        }
331    }
332
333    pub fn projects_mapping_invalido(path: &str, err: &str) -> String {
334        match current() {
335            Language::English => format!("invalid projects mapping '{path}': {err}"),
336            Language::Portugues => format!("mapeamento de projetos inválido '{path}': {err}"),
337        }
338    }
339
340    pub fn link_auto_referencial() -> String {
341        match current() {
342            Language::English => "--from and --to must be different entities — self-referential relationships are not supported".to_string(),
343            Language::Portugues => "--from e --to devem ser entidades diferentes — relacionamentos auto-referenciais não são suportados".to_string(),
344        }
345    }
346
347    pub fn link_peso_invalido(weight: f64) -> String {
348        match current() {
349            Language::English => {
350                format!("--weight: must be between 0.0 and 1.0 (actual: {weight})")
351            }
352            Language::Portugues => {
353                format!("--weight: deve estar entre 0.0 e 1.0 (atual: {weight})")
354            }
355        }
356    }
357
358    pub fn sync_destino_igual_fonte() -> String {
359        match current() {
360            Language::English => {
361                "destination path must differ from the source database path".to_string()
362            }
363            Language::Portugues => {
364                "caminho de destino deve ser diferente do caminho do banco de dados fonte"
365                    .to_string()
366            }
367        }
368    }
369}
370
371#[cfg(test)]
372mod testes {
373    use super::*;
374    use serial_test::serial;
375
376    #[test]
377    #[serial]
378    fn fallback_english_quando_env_ausente() {
379        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
380        std::env::set_var("LC_ALL", "C");
381        std::env::set_var("LANG", "C");
382        assert_eq!(Language::from_env_or_locale(), Language::English);
383        std::env::remove_var("LC_ALL");
384        std::env::remove_var("LANG");
385    }
386
387    #[test]
388    #[serial]
389    fn env_pt_seleciona_portugues() {
390        std::env::remove_var("LC_ALL");
391        std::env::remove_var("LANG");
392        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt");
393        assert_eq!(Language::from_env_or_locale(), Language::Portugues);
394        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
395    }
396
397    #[test]
398    #[serial]
399    fn env_pt_br_seleciona_portugues() {
400        std::env::remove_var("LC_ALL");
401        std::env::remove_var("LANG");
402        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt-BR");
403        assert_eq!(Language::from_env_or_locale(), Language::Portugues);
404        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
405    }
406
407    #[test]
408    #[serial]
409    fn locale_ptbr_utf8_seleciona_portugues() {
410        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
411        std::env::set_var("LC_ALL", "pt_BR.UTF-8");
412        assert_eq!(Language::from_env_or_locale(), Language::Portugues);
413        std::env::remove_var("LC_ALL");
414    }
415
416    mod testes_validacao {
417        use super::*;
418
419        #[test]
420        fn nome_comprimento_en() {
421            let msg = match Language::English {
422                Language::English => format!("name must be 1-{} chars", 80),
423                Language::Portugues => format!("nome deve ter entre 1 e {} caracteres", 80),
424            };
425            assert!(msg.contains("name must be 1-80 chars"), "obtido: {msg}");
426        }
427
428        #[test]
429        fn nome_comprimento_pt() {
430            let msg = match Language::Portugues {
431                Language::English => format!("name must be 1-{} chars", 80),
432                Language::Portugues => format!("nome deve ter entre 1 e {} caracteres", 80),
433            };
434            assert!(
435                msg.contains("nome deve ter entre 1 e 80 caracteres"),
436                "obtido: {msg}"
437            );
438        }
439
440        #[test]
441        fn nome_kebab_en() {
442            let nome = "Invalid_Name";
443            let msg = match Language::English {
444                Language::English => format!(
445                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
446                ),
447                Language::Portugues => {
448                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
449                }
450            };
451            assert!(msg.contains("kebab-case slug"), "obtido: {msg}");
452            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
453        }
454
455        #[test]
456        fn nome_kebab_pt() {
457            let nome = "Invalid_Name";
458            let msg = match Language::Portugues {
459                Language::English => format!(
460                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
461                ),
462                Language::Portugues => {
463                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
464                }
465            };
466            assert!(msg.contains("kebab-case"), "obtido: {msg}");
467            assert!(msg.contains("minúsculas"), "obtido: {msg}");
468            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
469        }
470
471        #[test]
472        fn descricao_excede_en() {
473            let msg = match Language::English {
474                Language::English => format!("description must be <= {} chars", 500),
475                Language::Portugues => format!("descrição deve ter no máximo {} caracteres", 500),
476            };
477            assert!(msg.contains("description must be <= 500"), "obtido: {msg}");
478        }
479
480        #[test]
481        fn descricao_excede_pt() {
482            let msg = match Language::Portugues {
483                Language::English => format!("description must be <= {} chars", 500),
484                Language::Portugues => format!("descrição deve ter no máximo {} caracteres", 500),
485            };
486            assert!(
487                msg.contains("descrição deve ter no máximo 500"),
488                "obtido: {msg}"
489            );
490        }
491
492        #[test]
493        fn body_excede_en() {
494            let msg = match Language::English {
495                Language::English => format!("body exceeds {} chars", 20_000),
496                Language::Portugues => format!("corpo excede {} caracteres", 20_000),
497            };
498            assert!(msg.contains("body exceeds 20000"), "obtido: {msg}");
499        }
500
501        #[test]
502        fn body_excede_pt() {
503            let msg = match Language::Portugues {
504                Language::English => format!("body exceeds {} chars", 20_000),
505                Language::Portugues => format!("corpo excede {} caracteres", 20_000),
506            };
507            assert!(msg.contains("corpo excede 20000"), "obtido: {msg}");
508        }
509
510        #[test]
511        fn novo_nome_comprimento_en() {
512            let msg = match Language::English {
513                Language::English => format!("new-name must be 1-{} chars", 80),
514                Language::Portugues => format!("novo nome deve ter entre 1 e {} caracteres", 80),
515            };
516            assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
517        }
518
519        #[test]
520        fn novo_nome_comprimento_pt() {
521            let msg = match Language::Portugues {
522                Language::English => format!("new-name must be 1-{} chars", 80),
523                Language::Portugues => format!("novo nome deve ter entre 1 e {} caracteres", 80),
524            };
525            assert!(
526                msg.contains("novo nome deve ter entre 1 e 80"),
527                "obtido: {msg}"
528            );
529        }
530
531        #[test]
532        fn novo_nome_kebab_en() {
533            let nome = "Bad Name";
534            let msg = match Language::English {
535                Language::English => format!(
536                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
537                ),
538                Language::Portugues => format!(
539                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
540                ),
541            };
542            assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
543        }
544
545        #[test]
546        fn novo_nome_kebab_pt() {
547            let nome = "Bad Name";
548            let msg = match Language::Portugues {
549                Language::English => format!(
550                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
551                ),
552                Language::Portugues => format!(
553                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
554                ),
555            };
556            assert!(
557                msg.contains("novo nome deve estar em kebab-case"),
558                "obtido: {msg}"
559            );
560        }
561
562        #[test]
563        fn nome_reservado_en() {
564            let msg = match Language::English {
565                Language::English => {
566                    "names and namespaces starting with __ are reserved for internal use"
567                        .to_string()
568                }
569                Language::Portugues => {
570                    "nomes e namespaces iniciados com __ são reservados para uso interno"
571                        .to_string()
572                }
573            };
574            assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
575        }
576
577        #[test]
578        fn nome_reservado_pt() {
579            let msg = match Language::Portugues {
580                Language::English => {
581                    "names and namespaces starting with __ are reserved for internal use"
582                        .to_string()
583                }
584                Language::Portugues => {
585                    "nomes e namespaces iniciados com __ são reservados para uso interno"
586                        .to_string()
587                }
588            };
589            assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
590        }
591    }
592}