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