1use 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 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 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
62pub 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
69pub fn current() -> Language {
71 *IDIOMA_GLOBAL.get_or_init(Language::from_env_or_locale)
72}
73
74pub fn tr(en: &str, pt: &str) -> &'static str {
76 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
86pub fn prefixo_erro() -> &'static str {
88 match current() {
89 Language::English => "Error",
90 Language::Portugues => "Erro",
91 }
92}
93
94pub 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
227pub 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}