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 Portuguese,
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::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") {
39 if !v.is_empty() {
40 let lower = v.to_lowercase();
41 if lower.starts_with("pt") {
42 return Language::Portuguese;
43 }
44 if lower.starts_with("en") {
45 return Language::English;
46 }
47 tracing::warn!(
49 value = %v,
50 "SQLITE_GRAPHRAG_LANG value not recognized, falling back to locale detection"
51 );
52 }
53 }
54 for var in &["LC_ALL", "LANG"] {
55 if let Ok(v) = std::env::var(var) {
56 if v.to_lowercase().starts_with("pt") {
57 return Language::Portuguese;
58 }
59 }
60 }
61 Language::English
62 }
63}
64
65static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
66
67pub fn init(explicit: Option<Language>) {
76 if GLOBAL_LANGUAGE.get().is_some() {
77 return;
78 }
79 let resolved = explicit.unwrap_or_else(Language::from_env_or_locale);
80 let _ = GLOBAL_LANGUAGE.set(resolved);
81}
82
83pub fn current() -> Language {
85 *GLOBAL_LANGUAGE.get_or_init(Language::from_env_or_locale)
86}
87
88pub fn tr(en: &'static str, pt: &'static str) -> &'static str {
96 match current() {
97 Language::English => en,
98 Language::Portuguese => pt,
99 }
100}
101
102pub fn error_prefix() -> &'static str {
104 match current() {
105 Language::English => "Error",
106 Language::Portuguese => "Erro",
107 }
108}
109
110pub mod errors_msg {
112 use super::current;
113 use crate::i18n::Language;
114
115 pub fn memory_not_found(nome: &str, namespace: &str) -> String {
116 match current() {
117 Language::English => {
118 format!("memory '{nome}' not found in namespace '{namespace}'")
119 }
120 Language::Portuguese => {
121 format!("memória '{nome}' não encontrada no namespace '{namespace}'")
122 }
123 }
124 }
125
126 pub fn database_not_found(path: &str) -> String {
127 match current() {
128 Language::English => {
129 format!("database not found at {path}. Run 'sqlite-graphrag init' first.")
130 }
131 Language::Portuguese => format!(
132 "banco de dados não encontrado em {path}. Execute 'sqlite-graphrag init' primeiro."
133 ),
134 }
135 }
136
137 pub fn entity_not_found(nome: &str, namespace: &str) -> String {
138 match current() {
139 Language::English => {
140 format!("entity \"{nome}\" does not exist in namespace \"{namespace}\"")
141 }
142 Language::Portuguese => {
143 format!("entidade \"{nome}\" não existe no namespace \"{namespace}\"")
144 }
145 }
146 }
147
148 pub fn relationship_not_found(de: &str, rel: &str, para: &str, namespace: &str) -> String {
149 match current() {
150 Language::English => format!(
151 "relationship \"{de}\" --[{rel}]--> \"{para}\" does not exist in namespace \"{namespace}\""
152 ),
153 Language::Portuguese => format!(
154 "relacionamento \"{de}\" --[{rel}]--> \"{para}\" não existe no namespace \"{namespace}\""
155 ),
156 }
157 }
158
159 pub fn duplicate_memory(nome: &str, namespace: &str) -> String {
160 match current() {
161 Language::English => format!(
162 "memory '{nome}' already exists in namespace '{namespace}'. Use --force-merge to update."
163 ),
164 Language::Portuguese => format!(
165 "memória '{nome}' já existe no namespace '{namespace}'. Use --force-merge para atualizar."
166 ),
167 }
168 }
169
170 pub fn optimistic_lock_conflict(expected: i64, current_ts: i64) -> String {
171 match current() {
172 Language::English => format!(
173 "optimistic lock conflict: expected updated_at={expected}, but current is {current_ts}"
174 ),
175 Language::Portuguese => format!(
176 "conflito de optimistic lock: esperava updated_at={expected}, mas atual é {current_ts}"
177 ),
178 }
179 }
180
181 pub fn version_not_found(versao: i64, nome: &str) -> String {
182 match current() {
183 Language::English => format!("version {versao} not found for memory '{nome}'"),
184 Language::Portuguese => {
185 format!("versão {versao} não encontrada para a memória '{nome}'")
186 }
187 }
188 }
189
190 pub fn no_recall_results(max_distance: f32, query: &str, namespace: &str) -> String {
191 match current() {
192 Language::English => format!(
193 "no results within --max-distance {max_distance} for query '{query}' in namespace '{namespace}'"
194 ),
195 Language::Portuguese => format!(
196 "nenhum resultado dentro de --max-distance {max_distance} para a consulta '{query}' no namespace '{namespace}'"
197 ),
198 }
199 }
200
201 pub fn soft_deleted_memory_not_found(nome: &str, namespace: &str) -> String {
202 match current() {
203 Language::English => {
204 format!("soft-deleted memory '{nome}' not found in namespace '{namespace}'")
205 }
206 Language::Portuguese => {
207 format!("memória soft-deleted '{nome}' não encontrada no namespace '{namespace}'")
208 }
209 }
210 }
211
212 pub fn concurrent_process_conflict() -> String {
213 match current() {
214 Language::English => {
215 "optimistic lock conflict: memory was modified by another process".to_string()
216 }
217 Language::Portuguese => {
218 "conflito de optimistic lock: memória foi modificada por outro processo".to_string()
219 }
220 }
221 }
222
223 pub fn entity_limit_exceeded(max: usize) -> String {
224 match current() {
225 Language::English => format!("entities exceed limit of {max}"),
226 Language::Portuguese => format!("entidades excedem o limite de {max}"),
227 }
228 }
229
230 pub fn relationship_limit_exceeded(max: usize) -> String {
231 match current() {
232 Language::English => format!("relationships exceed limit of {max}"),
233 Language::Portuguese => format!("relacionamentos excedem o limite de {max}"),
234 }
235 }
236}
237
238pub mod validation {
240 use super::current;
241 use crate::i18n::Language;
242
243 pub fn name_length(max: usize) -> String {
244 match current() {
245 Language::English => format!("name must be 1-{max} chars"),
246 Language::Portuguese => format!("nome deve ter entre 1 e {max} caracteres"),
247 }
248 }
249
250 pub fn reserved_name() -> String {
251 match current() {
252 Language::English => {
253 "names and namespaces starting with __ are reserved for internal use".to_string()
254 }
255 Language::Portuguese => {
256 "nomes e namespaces iniciados com __ são reservados para uso interno".to_string()
257 }
258 }
259 }
260
261 pub fn name_kebab(nome: &str) -> String {
262 match current() {
263 Language::English => format!(
264 "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
265 ),
266 Language::Portuguese => {
267 format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
268 }
269 }
270 }
271
272 pub fn description_exceeds(max: usize) -> String {
273 match current() {
274 Language::English => format!("description must be <= {max} chars"),
275 Language::Portuguese => format!("descrição deve ter no máximo {max} caracteres"),
276 }
277 }
278
279 pub fn body_exceeds(max: usize) -> String {
280 match current() {
281 Language::English => format!("body exceeds {max} bytes"),
282 Language::Portuguese => format!("corpo excede {max} bytes"),
283 }
284 }
285
286 pub fn new_name_length(max: usize) -> String {
287 match current() {
288 Language::English => format!("new-name must be 1-{max} chars"),
289 Language::Portuguese => format!("novo nome deve ter entre 1 e {max} caracteres"),
290 }
291 }
292
293 pub fn new_name_kebab(nome: &str) -> String {
294 match current() {
295 Language::English => format!(
296 "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
297 ),
298 Language::Portuguese => format!(
299 "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
300 ),
301 }
302 }
303
304 pub fn namespace_length() -> String {
305 match current() {
306 Language::English => "namespace must be 1-80 chars".to_string(),
307 Language::Portuguese => "namespace deve ter entre 1 e 80 caracteres".to_string(),
308 }
309 }
310
311 pub fn namespace_format() -> String {
312 match current() {
313 Language::English => "namespace must be alphanumeric + hyphens/underscores".to_string(),
314 Language::Portuguese => {
315 "namespace deve ser alfanumérico com hífens/sublinhados".to_string()
316 }
317 }
318 }
319
320 pub fn path_traversal(p: &str) -> String {
321 match current() {
322 Language::English => format!("path traversal rejected: {p}"),
323 Language::Portuguese => format!("traversal de caminho rejeitado: {p}"),
324 }
325 }
326
327 pub fn invalid_tz(v: &str) -> String {
328 match current() {
329 Language::English => format!(
330 "SQLITE_GRAPHRAG_DISPLAY_TZ invalid: '{v}'; use an IANA name like 'America/Sao_Paulo'"
331 ),
332 Language::Portuguese => format!(
333 "SQLITE_GRAPHRAG_DISPLAY_TZ inválido: '{v}'; use um nome IANA como 'America/Sao_Paulo'"
334 ),
335 }
336 }
337
338 pub fn empty_query() -> String {
339 match current() {
340 Language::English => "query cannot be empty".to_string(),
341 Language::Portuguese => "a consulta não pode estar vazia".to_string(),
342 }
343 }
344
345 pub fn empty_body() -> String {
346 match current() {
347 Language::English => "body cannot be empty: provide --body, --body-file, or --body-stdin with content, or supply a graph via --entities-file/--graph-stdin".to_string(),
348 Language::Portuguese => "o corpo não pode estar vazio: forneça --body, --body-file ou --body-stdin com conteúdo, ou um grafo via --entities-file/--graph-stdin".to_string(),
349 }
350 }
351
352 pub fn invalid_namespace_config(path: &str, err: &str) -> String {
353 match current() {
354 Language::English => {
355 format!("invalid project namespace config '{path}': {err}")
356 }
357 Language::Portuguese => {
358 format!("configuração de namespace de projeto inválida '{path}': {err}")
359 }
360 }
361 }
362
363 pub fn invalid_projects_mapping(path: &str, err: &str) -> String {
364 match current() {
365 Language::English => format!("invalid projects mapping '{path}': {err}"),
366 Language::Portuguese => format!("mapeamento de projetos inválido '{path}': {err}"),
367 }
368 }
369
370 pub fn self_referential_link() -> String {
371 match current() {
372 Language::English => "--from and --to must be different entities — self-referential relationships are not supported".to_string(),
373 Language::Portuguese => "--from e --to devem ser entidades diferentes — relacionamentos auto-referenciais não são suportados".to_string(),
374 }
375 }
376
377 pub fn invalid_link_weight(weight: f64) -> String {
378 match current() {
379 Language::English => {
380 format!("--weight: must be between 0.0 and 1.0 (actual: {weight})")
381 }
382 Language::Portuguese => {
383 format!("--weight: deve estar entre 0.0 e 1.0 (atual: {weight})")
384 }
385 }
386 }
387
388 pub fn sync_destination_equals_source() -> String {
389 match current() {
390 Language::English => {
391 "destination path must differ from the source database path".to_string()
392 }
393 Language::Portuguese => {
394 "caminho de destino deve ser diferente do caminho do banco de dados fonte"
395 .to_string()
396 }
397 }
398 }
399
400 pub mod app_error_pt {
406 pub fn validation(msg: &str) -> String {
407 format!("erro de validação: {msg}")
408 }
409
410 pub fn duplicate(msg: &str) -> String {
411 format!("duplicata detectada: {msg}")
412 }
413
414 pub fn conflict(msg: &str) -> String {
415 format!("conflito: {msg}")
416 }
417
418 pub fn not_found(msg: &str) -> String {
419 format!("não encontrado: {msg}")
420 }
421
422 pub fn namespace_error(msg: &str) -> String {
423 format!("namespace não resolvido: {msg}")
424 }
425
426 pub fn limit_exceeded(msg: &str) -> String {
427 format!("limite excedido: {msg}")
428 }
429
430 pub fn database(err: &str) -> String {
431 format!("erro de banco de dados: {err}")
432 }
433
434 pub fn embedding(msg: &str) -> String {
435 format!("erro de embedding: {msg}")
436 }
437
438 pub fn vec_extension(msg: &str) -> String {
439 format!("extensão sqlite-vec falhou: {msg}")
440 }
441
442 pub fn db_busy(msg: &str) -> String {
443 format!("banco ocupado: {msg}")
444 }
445
446 pub fn batch_partial_failure(total: usize, failed: usize) -> String {
447 format!("falha parcial em batch: {failed} de {total} itens falharam")
448 }
449
450 pub fn io(err: &str) -> String {
451 format!("erro de I/O: {err}")
452 }
453
454 pub fn internal(err: &str) -> String {
455 format!("erro interno: {err}")
456 }
457
458 pub fn json(err: &str) -> String {
459 format!("erro de JSON: {err}")
460 }
461
462 pub fn lock_busy(msg: &str) -> String {
463 format!("lock ocupado: {msg}")
464 }
465
466 pub fn all_slots_full(max: usize, waited_secs: u64) -> String {
467 format!(
468 "todos os {max} slots de concorrência ocupados após aguardar {waited_secs}s \
469 (exit 75); use --max-concurrency ou aguarde outras invocações terminarem"
470 )
471 }
472
473 pub fn low_memory(available_mb: u64, required_mb: u64) -> String {
474 format!(
475 "memória disponível ({available_mb}MB) abaixo do mínimo requerido ({required_mb}MB) \
476 para carregar o modelo; aborte outras cargas ou use --skip-memory-guard (exit 77)"
477 )
478 }
479 }
480
481 pub mod runtime_pt {
487 pub fn embedding_heavy_must_measure_ram() -> String {
488 "comando intensivo em embedding precisa medir RAM disponível".to_string()
489 }
490
491 pub fn heavy_command_detected(available_mb: u64, safe_concurrency: usize) -> String {
492 format!(
493 "Comando pesado detectado; memória disponível: {available_mb} MB; \
494 concorrência segura: {safe_concurrency}"
495 )
496 }
497
498 pub fn reducing_concurrency(
499 requested_concurrency: usize,
500 effective_concurrency: usize,
501 ) -> String {
502 format!(
503 "Reduzindo a concorrência solicitada de {requested_concurrency} para \
504 {effective_concurrency} para evitar oversubscription de memória"
505 )
506 }
507
508 pub fn downloading_ner_model() -> &'static str {
509 "Baixando modelo NER (primeira execução, ~676 MB)..."
510 }
511
512 pub fn initializing_embedding_model() -> &'static str {
513 "Inicializando modelo de embedding (pode baixar na primeira execução)..."
514 }
515
516 pub fn embedding_chunks_serially(count: usize) -> String {
517 format!("Embedando {count} chunks serialmente para manter memória limitada...")
518 }
519
520 pub fn remember_step_input_validated(available_mb: u64) -> String {
521 format!("Etapa remember: entrada validada; memória disponível {available_mb} MB")
522 }
523
524 pub fn remember_step_chunking_completed(
525 total_passage_tokens: usize,
526 model_max_length: usize,
527 chunks_count: usize,
528 rss_mb: u64,
529 ) -> String {
530 format!(
531 "Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem \
532 (máximo do modelo {model_max_length}); chunking gerou {chunks_count} chunks; \
533 RSS do processo {rss_mb} MB"
534 )
535 }
536
537 pub fn remember_step_embeddings_completed(rss_mb: u64) -> String {
538 format!("Etapa remember: embeddings dos chunks concluídos; RSS do processo {rss_mb} MB")
539 }
540
541 pub fn restore_recomputing_embedding() -> &'static str {
542 "Recalculando embedding da memória restaurada..."
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use serial_test::serial;
551
552 #[test]
553 #[serial]
554 fn fallback_english_when_env_absent() {
555 std::env::remove_var("SQLITE_GRAPHRAG_LANG");
556 std::env::set_var("LC_ALL", "C");
557 std::env::set_var("LANG", "C");
558 assert_eq!(Language::from_env_or_locale(), Language::English);
559 std::env::remove_var("LC_ALL");
560 std::env::remove_var("LANG");
561 }
562
563 #[test]
564 #[serial]
565 fn env_pt_selects_portuguese() {
566 std::env::remove_var("LC_ALL");
567 std::env::remove_var("LANG");
568 std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt");
569 assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
570 std::env::remove_var("SQLITE_GRAPHRAG_LANG");
571 }
572
573 #[test]
574 #[serial]
575 fn env_pt_br_selects_portuguese() {
576 std::env::remove_var("LC_ALL");
577 std::env::remove_var("LANG");
578 std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt-BR");
579 assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
580 std::env::remove_var("SQLITE_GRAPHRAG_LANG");
581 }
582
583 #[test]
584 #[serial]
585 fn locale_ptbr_utf8_selects_portuguese() {
586 std::env::remove_var("SQLITE_GRAPHRAG_LANG");
587 std::env::set_var("LC_ALL", "pt_BR.UTF-8");
588 assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
589 std::env::remove_var("LC_ALL");
590 }
591
592 mod validation_tests {
593 use super::*;
594
595 #[test]
596 fn name_length_en() {
597 let msg = match Language::English {
598 Language::English => format!("name must be 1-{} chars", 80),
599 Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
600 };
601 assert!(msg.contains("name must be 1-80 chars"), "obtido: {msg}");
602 }
603
604 #[test]
605 fn name_length_pt() {
606 let msg = match Language::Portuguese {
607 Language::English => format!("name must be 1-{} chars", 80),
608 Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
609 };
610 assert!(
611 msg.contains("nome deve ter entre 1 e 80 caracteres"),
612 "obtido: {msg}"
613 );
614 }
615
616 #[test]
617 fn name_kebab_en() {
618 let nome = "Invalid_Name";
619 let msg = match Language::English {
620 Language::English => format!(
621 "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
622 ),
623 Language::Portuguese => {
624 format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
625 }
626 };
627 assert!(msg.contains("kebab-case slug"), "obtido: {msg}");
628 assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
629 }
630
631 #[test]
632 fn name_kebab_pt() {
633 let nome = "Invalid_Name";
634 let msg = match Language::Portuguese {
635 Language::English => format!(
636 "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
637 ),
638 Language::Portuguese => {
639 format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
640 }
641 };
642 assert!(msg.contains("kebab-case"), "obtido: {msg}");
643 assert!(msg.contains("minúsculas"), "obtido: {msg}");
644 assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
645 }
646
647 #[test]
648 fn description_exceeds_en() {
649 let msg = match Language::English {
650 Language::English => format!("description must be <= {} chars", 500),
651 Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
652 };
653 assert!(msg.contains("description must be <= 500"), "obtido: {msg}");
654 }
655
656 #[test]
657 fn description_exceeds_pt() {
658 let msg = match Language::Portuguese {
659 Language::English => format!("description must be <= {} chars", 500),
660 Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
661 };
662 assert!(
663 msg.contains("descrição deve ter no máximo 500"),
664 "obtido: {msg}"
665 );
666 }
667
668 #[test]
669 fn body_exceeds_en() {
670 let limite = crate::constants::MAX_MEMORY_BODY_LEN;
671 let msg = match Language::English {
672 Language::English => format!("body exceeds {limite} bytes"),
673 Language::Portuguese => format!("corpo excede {limite} bytes"),
674 };
675 assert!(msg.contains("body exceeds 512000"), "obtido: {msg}");
676 }
677
678 #[test]
679 fn body_exceeds_pt() {
680 let limite = crate::constants::MAX_MEMORY_BODY_LEN;
681 let msg = match Language::Portuguese {
682 Language::English => format!("body exceeds {limite} bytes"),
683 Language::Portuguese => format!("corpo excede {limite} bytes"),
684 };
685 assert!(msg.contains("corpo excede 512000"), "obtido: {msg}");
686 }
687
688 #[test]
689 fn new_name_length_en() {
690 let msg = match Language::English {
691 Language::English => format!("new-name must be 1-{} chars", 80),
692 Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
693 };
694 assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
695 }
696
697 #[test]
698 fn new_name_length_pt() {
699 let msg = match Language::Portuguese {
700 Language::English => format!("new-name must be 1-{} chars", 80),
701 Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
702 };
703 assert!(
704 msg.contains("novo nome deve ter entre 1 e 80"),
705 "obtido: {msg}"
706 );
707 }
708
709 #[test]
710 fn new_name_kebab_en() {
711 let nome = "Bad Name";
712 let msg = match Language::English {
713 Language::English => format!(
714 "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
715 ),
716 Language::Portuguese => format!(
717 "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
718 ),
719 };
720 assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
721 }
722
723 #[test]
724 fn new_name_kebab_pt() {
725 let nome = "Bad Name";
726 let msg = match Language::Portuguese {
727 Language::English => format!(
728 "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
729 ),
730 Language::Portuguese => format!(
731 "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
732 ),
733 };
734 assert!(
735 msg.contains("novo nome deve estar em kebab-case"),
736 "obtido: {msg}"
737 );
738 }
739
740 #[test]
741 fn reserved_name_en() {
742 let msg = match Language::English {
743 Language::English => {
744 "names and namespaces starting with __ are reserved for internal use"
745 .to_string()
746 }
747 Language::Portuguese => {
748 "nomes e namespaces iniciados com __ são reservados para uso interno"
749 .to_string()
750 }
751 };
752 assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
753 }
754
755 #[test]
756 fn reserved_name_pt() {
757 let msg = match Language::Portuguese {
758 Language::English => {
759 "names and namespaces starting with __ are reserved for internal use"
760 .to_string()
761 }
762 Language::Portuguese => {
763 "nomes e namespaces iniciados com __ são reservados para uso interno"
764 .to_string()
765 }
766 };
767 assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
768 }
769 }
770}