Skip to main content

sqlite_graphrag/
i18n.rs

1//! Bilingual human-readable message layer.
2//!
3//! The CLI uses `--lang en|pt` (global flag) or `SQLITE_GRAPHRAG_LANG` (env var) to choose
4//! the language of stderr progress messages. JSON stdout is deterministic and identical
5//! across languages — only strings intended for humans pass through this module.
6//!
7//! Detection (highest to lowest priority):
8//! 1. Explicit `--lang` flag
9//! 2. Env var `SQLITE_GRAPHRAG_LANG`
10//! 3. OS locale (`LANG`, `LC_ALL`) with `pt` prefix
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    Portuguese,
21}
22
23impl Language {
24    /// Parses a command-line string into a `Language` without relying on clap.
25    /// Accepts the same aliases defined in `#[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::Portuguese),
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::Portuguese;
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::Portuguese;
53                }
54            }
55        }
56        Language::English
57    }
58}
59
60static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
61
62/// Initializes the global language. Subsequent calls are silently ignored
63/// (OnceLock semantics) — guaranteeing thread-safety and determinism.
64pub fn init(explicit: Option<Language>) {
65    let resolved = explicit.unwrap_or_else(Language::from_env_or_locale);
66    let _ = GLOBAL_LANGUAGE.set(resolved);
67}
68
69/// Returns the active language, or fallback English if `init` was never called.
70pub fn current() -> Language {
71    *GLOBAL_LANGUAGE.get_or_init(Language::from_env_or_locale)
72}
73
74/// Translates a bilingual message by selecting the active variant.
75pub fn tr(en: &str, pt: &str) -> &'static str {
76    // SAFETY: We return one of the two static strings passed as &str.
77    // Since we cannot prove to the borrow checker that the references outlive,
78    // we use Box::leak to promote to &'static str. Minimal cost (tens of
79    // distinct strings during the CLI process lifetime).
80    match current() {
81        Language::English => Box::leak(en.to_string().into_boxed_str()),
82        Language::Portuguese => Box::leak(pt.to_string().into_boxed_str()),
83    }
84}
85
86/// Localized prefix for error messages displayed to the end user.
87pub fn error_prefix() -> &'static str {
88    match current() {
89        Language::English => "Error",
90        Language::Portuguese => "Erro",
91    }
92}
93
94/// Localized error messages for `AppError` variants.
95pub mod errors_msg {
96    use super::current;
97    use crate::i18n::Language;
98
99    pub fn memory_not_found(nome: &str, namespace: &str) -> String {
100        match current() {
101            Language::English => {
102                format!("memory '{nome}' not found in namespace '{namespace}'")
103            }
104            Language::Portuguese => {
105                format!("memória '{nome}' não encontrada no namespace '{namespace}'")
106            }
107        }
108    }
109
110    pub fn database_not_found(path: &str) -> String {
111        match current() {
112            Language::English => {
113                format!("database not found at {path}. Run 'sqlite-graphrag init' first.")
114            }
115            Language::Portuguese => format!(
116                "banco de dados não encontrado em {path}. Execute 'sqlite-graphrag init' primeiro."
117            ),
118        }
119    }
120
121    pub fn entity_not_found(nome: &str, namespace: &str) -> String {
122        match current() {
123            Language::English => {
124                format!("entity \"{nome}\" does not exist in namespace \"{namespace}\"")
125            }
126            Language::Portuguese => {
127                format!("entidade \"{nome}\" não existe no namespace \"{namespace}\"")
128            }
129        }
130    }
131
132    pub fn relationship_not_found(de: &str, rel: &str, para: &str, namespace: &str) -> String {
133        match current() {
134            Language::English => format!(
135                "relationship \"{de}\" --[{rel}]--> \"{para}\" does not exist in namespace \"{namespace}\""
136            ),
137            Language::Portuguese => format!(
138                "relacionamento \"{de}\" --[{rel}]--> \"{para}\" não existe no namespace \"{namespace}\""
139            ),
140        }
141    }
142
143    pub fn duplicate_memory(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::Portuguese => format!(
149                "memória '{nome}' já existe no namespace '{namespace}'. Use --force-merge para atualizar."
150            ),
151        }
152    }
153
154    pub fn optimistic_lock_conflict(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::Portuguese => format!(
160                "conflito de optimistic lock: esperava updated_at={expected}, mas atual é {current_ts}"
161            ),
162        }
163    }
164
165    pub fn version_not_found(versao: i64, nome: &str) -> String {
166        match current() {
167            Language::English => format!("version {versao} not found for memory '{nome}'"),
168            Language::Portuguese => {
169                format!("versão {versao} não encontrada para a memória '{nome}'")
170            }
171        }
172    }
173
174    pub fn no_recall_results(max_distance: f32, query: &str, namespace: &str) -> String {
175        match current() {
176            Language::English => format!(
177                "no results within --max-distance {max_distance} for query '{query}' in namespace '{namespace}'"
178            ),
179            Language::Portuguese => format!(
180                "nenhum resultado dentro de --max-distance {max_distance} para a consulta '{query}' no namespace '{namespace}'"
181            ),
182        }
183    }
184
185    pub fn soft_deleted_memory_not_found(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::Portuguese => {
191                format!("memória soft-deleted '{nome}' não encontrada no namespace '{namespace}'")
192            }
193        }
194    }
195
196    pub fn concurrent_process_conflict() -> String {
197        match current() {
198            Language::English => {
199                "optimistic lock conflict: memory was modified by another process".to_string()
200            }
201            Language::Portuguese => {
202                "conflito de optimistic lock: memória foi modificada por outro processo".to_string()
203            }
204        }
205    }
206
207    pub fn entity_limit_exceeded(max: usize) -> String {
208        match current() {
209            Language::English => format!("entities exceed limit of {max}"),
210            Language::Portuguese => format!("entidades excedem o limite de {max}"),
211        }
212    }
213
214    pub fn relationship_limit_exceeded(max: usize) -> String {
215        match current() {
216            Language::English => format!("relationships exceed limit of {max}"),
217            Language::Portuguese => format!("relacionamentos excedem o limite de {max}"),
218        }
219    }
220}
221
222/// Localized validation messages for memory fields.
223pub mod validation {
224    use super::current;
225    use crate::i18n::Language;
226
227    pub fn name_length(max: usize) -> String {
228        match current() {
229            Language::English => format!("name must be 1-{max} chars"),
230            Language::Portuguese => format!("nome deve ter entre 1 e {max} caracteres"),
231        }
232    }
233
234    pub fn reserved_name() -> String {
235        match current() {
236            Language::English => {
237                "names and namespaces starting with __ are reserved for internal use".to_string()
238            }
239            Language::Portuguese => {
240                "nomes e namespaces iniciados com __ são reservados para uso interno".to_string()
241            }
242        }
243    }
244
245    pub fn name_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::Portuguese => {
251                format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
252            }
253        }
254    }
255
256    pub fn description_exceeds(max: usize) -> String {
257        match current() {
258            Language::English => format!("description must be <= {max} chars"),
259            Language::Portuguese => format!("descrição deve ter no máximo {max} caracteres"),
260        }
261    }
262
263    pub fn body_exceeds(max: usize) -> String {
264        match current() {
265            Language::English => format!("body exceeds {max} bytes"),
266            Language::Portuguese => format!("corpo excede {max} bytes"),
267        }
268    }
269
270    pub fn new_name_length(max: usize) -> String {
271        match current() {
272            Language::English => format!("new-name must be 1-{max} chars"),
273            Language::Portuguese => format!("novo nome deve ter entre 1 e {max} caracteres"),
274        }
275    }
276
277    pub fn new_name_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::Portuguese => format!(
283                "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
284            ),
285        }
286    }
287
288    pub fn namespace_length() -> String {
289        match current() {
290            Language::English => "namespace must be 1-80 chars".to_string(),
291            Language::Portuguese => "namespace deve ter entre 1 e 80 caracteres".to_string(),
292        }
293    }
294
295    pub fn namespace_format() -> String {
296        match current() {
297            Language::English => "namespace must be alphanumeric + hyphens/underscores".to_string(),
298            Language::Portuguese => {
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::Portuguese => format!("traversal de caminho rejeitado: {p}"),
308        }
309    }
310
311    pub fn invalid_tz(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::Portuguese => format!(
317                "SQLITE_GRAPHRAG_DISPLAY_TZ inválido: '{v}'; use um nome IANA como 'America/Sao_Paulo'"
318            ),
319        }
320    }
321
322    pub fn invalid_namespace_config(path: &str, err: &str) -> String {
323        match current() {
324            Language::English => {
325                format!("invalid project namespace config '{path}': {err}")
326            }
327            Language::Portuguese => {
328                format!("configuração de namespace de projeto inválida '{path}': {err}")
329            }
330        }
331    }
332
333    pub fn invalid_projects_mapping(path: &str, err: &str) -> String {
334        match current() {
335            Language::English => format!("invalid projects mapping '{path}': {err}"),
336            Language::Portuguese => format!("mapeamento de projetos inválido '{path}': {err}"),
337        }
338    }
339
340    pub fn self_referential_link() -> String {
341        match current() {
342            Language::English => "--from and --to must be different entities — self-referential relationships are not supported".to_string(),
343            Language::Portuguese => "--from e --to devem ser entidades diferentes — relacionamentos auto-referenciais não são suportados".to_string(),
344        }
345    }
346
347    pub fn invalid_link_weight(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::Portuguese => {
353                format!("--weight: deve estar entre 0.0 e 1.0 (atual: {weight})")
354            }
355        }
356    }
357
358    pub fn sync_destination_equals_source() -> String {
359        match current() {
360            Language::English => {
361                "destination path must differ from the source database path".to_string()
362            }
363            Language::Portuguese => {
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 tests {
373    use super::*;
374    use serial_test::serial;
375
376    #[test]
377    #[serial]
378    fn fallback_english_when_env_absent() {
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_selects_portuguese() {
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::Portuguese);
394        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
395    }
396
397    #[test]
398    #[serial]
399    fn env_pt_br_selects_portuguese() {
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::Portuguese);
404        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
405    }
406
407    #[test]
408    #[serial]
409    fn locale_ptbr_utf8_selects_portuguese() {
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::Portuguese);
413        std::env::remove_var("LC_ALL");
414    }
415
416    mod validation_tests {
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::Portuguese => 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::Portuguese {
431                Language::English => format!("name must be 1-{} chars", 80),
432                Language::Portuguese => 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 name_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::Portuguese => {
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 name_kebab_pt() {
457            let nome = "Invalid_Name";
458            let msg = match Language::Portuguese {
459                Language::English => format!(
460                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
461                ),
462                Language::Portuguese => {
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::Portuguese => 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::Portuguese {
483                Language::English => format!("description must be <= {} chars", 500),
484                Language::Portuguese => 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 limite = crate::constants::MAX_MEMORY_BODY_LEN;
495            let msg = match Language::English {
496                Language::English => format!("body exceeds {limite} bytes"),
497                Language::Portuguese => format!("corpo excede {limite} bytes"),
498            };
499            assert!(msg.contains("body exceeds 512000"), "obtido: {msg}");
500        }
501
502        #[test]
503        fn body_excede_pt() {
504            let limite = crate::constants::MAX_MEMORY_BODY_LEN;
505            let msg = match Language::Portuguese {
506                Language::English => format!("body exceeds {limite} bytes"),
507                Language::Portuguese => format!("corpo excede {limite} bytes"),
508            };
509            assert!(msg.contains("corpo excede 512000"), "obtido: {msg}");
510        }
511
512        #[test]
513        fn new_name_length_en() {
514            let msg = match Language::English {
515                Language::English => format!("new-name must be 1-{} chars", 80),
516                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
517            };
518            assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
519        }
520
521        #[test]
522        fn new_name_length_pt() {
523            let msg = match Language::Portuguese {
524                Language::English => format!("new-name must be 1-{} chars", 80),
525                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
526            };
527            assert!(
528                msg.contains("novo nome deve ter entre 1 e 80"),
529                "obtido: {msg}"
530            );
531        }
532
533        #[test]
534        fn new_name_kebab_en() {
535            let nome = "Bad Name";
536            let msg = match Language::English {
537                Language::English => format!(
538                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
539                ),
540                Language::Portuguese => format!(
541                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
542                ),
543            };
544            assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
545        }
546
547        #[test]
548        fn new_name_kebab_pt() {
549            let nome = "Bad Name";
550            let msg = match Language::Portuguese {
551                Language::English => format!(
552                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
553                ),
554                Language::Portuguese => format!(
555                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
556                ),
557            };
558            assert!(
559                msg.contains("novo nome deve estar em kebab-case"),
560                "obtido: {msg}"
561            );
562        }
563
564        #[test]
565        fn nome_reservado_en() {
566            let msg = match Language::English {
567                Language::English => {
568                    "names and namespaces starting with __ are reserved for internal use"
569                        .to_string()
570                }
571                Language::Portuguese => {
572                    "nomes e namespaces iniciados com __ são reservados para uso interno"
573                        .to_string()
574                }
575            };
576            assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
577        }
578
579        #[test]
580        fn nome_reservado_pt() {
581            let msg = match Language::Portuguese {
582                Language::English => {
583                    "names and namespaces starting with __ are reserved for internal use"
584                        .to_string()
585                }
586                Language::Portuguese => {
587                    "nomes e namespaces iniciados com __ são reservados para uso interno"
588                        .to_string()
589                }
590            };
591            assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
592        }
593    }
594}