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