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 v = v.to_lowercase();
37 if v.starts_with("pt") {
38 return Language::Portugues;
39 }
40 if v.starts_with("en") {
41 return Language::English;
42 }
43 }
44 for var in &["LC_ALL", "LANG"] {
45 if let Ok(v) = std::env::var(var) {
46 if v.to_lowercase().starts_with("pt") {
47 return Language::Portugues;
48 }
49 }
50 }
51 Language::English
52 }
53}
54
55static IDIOMA_GLOBAL: OnceLock<Language> = OnceLock::new();
56
57pub fn init(explicit: Option<Language>) {
60 let resolved = explicit.unwrap_or_else(Language::from_env_or_locale);
61 let _ = IDIOMA_GLOBAL.set(resolved);
62}
63
64pub fn current() -> Language {
66 *IDIOMA_GLOBAL.get_or_init(Language::from_env_or_locale)
67}
68
69pub fn tr(en: &str, pt: &str) -> &'static str {
71 match current() {
76 Language::English => Box::leak(en.to_string().into_boxed_str()),
77 Language::Portugues => Box::leak(pt.to_string().into_boxed_str()),
78 }
79}
80
81pub fn prefixo_erro() -> &'static str {
83 match current() {
84 Language::English => "Error",
85 Language::Portugues => "Erro",
86 }
87}
88
89pub mod erros {
91 use super::current;
92 use crate::i18n::Language;
93
94 pub fn memoria_nao_encontrada(nome: &str, namespace: &str) -> String {
95 match current() {
96 Language::English => {
97 format!("memory '{nome}' not found in namespace '{namespace}'")
98 }
99 Language::Portugues => {
100 format!("memória '{nome}' não encontrada no namespace '{namespace}'")
101 }
102 }
103 }
104
105 pub fn banco_nao_encontrado(path: &str) -> String {
106 match current() {
107 Language::English => {
108 format!("database not found at {path}. Run 'sqlite-graphrag init' first.")
109 }
110 Language::Portugues => format!(
111 "banco de dados não encontrado em {path}. Execute 'sqlite-graphrag init' primeiro."
112 ),
113 }
114 }
115
116 pub fn entidade_nao_encontrada(nome: &str, namespace: &str) -> String {
117 match current() {
118 Language::English => {
119 format!("entity \"{nome}\" does not exist in namespace \"{namespace}\"")
120 }
121 Language::Portugues => {
122 format!("entidade \"{nome}\" não existe no namespace \"{namespace}\"")
123 }
124 }
125 }
126
127 pub fn relacionamento_nao_encontrado(
128 de: &str,
129 rel: &str,
130 para: &str,
131 namespace: &str,
132 ) -> String {
133 match current() {
134 Language::English => format!(
135 "relationship \"{de}\" --[{rel}]--> \"{para}\" does not exist in namespace \"{namespace}\""
136 ),
137 Language::Portugues => format!(
138 "relacionamento \"{de}\" --[{rel}]--> \"{para}\" não existe no namespace \"{namespace}\""
139 ),
140 }
141 }
142
143 pub fn memoria_duplicada(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::Portugues => format!(
149 "memória '{nome}' já existe no namespace '{namespace}'. Use --force-merge para atualizar."
150 ),
151 }
152 }
153
154 pub fn conflito_optimistic_lock(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::Portugues => format!(
160 "conflito de optimistic lock: esperava updated_at={expected}, mas atual é {current_ts}"
161 ),
162 }
163 }
164
165 pub fn versao_nao_encontrada(versao: i64, nome: &str) -> String {
166 match current() {
167 Language::English => format!("version {versao} not found for memory '{nome}'"),
168 Language::Portugues => {
169 format!("versão {versao} não encontrada para a memória '{nome}'")
170 }
171 }
172 }
173
174 pub fn sem_resultados_recall(min_distance: f32, query: &str, namespace: &str) -> String {
175 match current() {
176 Language::English => format!(
177 "no results within --min-distance {min_distance} for query '{query}' in namespace '{namespace}'"
178 ),
179 Language::Portugues => format!(
180 "nenhum resultado dentro de --min-distance {min_distance} para a consulta '{query}' no namespace '{namespace}'"
181 ),
182 }
183 }
184
185 pub fn memoria_soft_deleted_nao_encontrada(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::Portugues => {
191 format!("memória soft-deleted '{nome}' não encontrada no namespace '{namespace}'")
192 }
193 }
194 }
195
196 pub fn conflito_processo_concorrente() -> String {
197 match current() {
198 Language::English => {
199 "optimistic lock conflict: memory was modified by another process".to_string()
200 }
201 Language::Portugues => {
202 "conflito de optimistic lock: memória foi modificada por outro processo".to_string()
203 }
204 }
205 }
206
207 pub fn limite_entidades(max: usize) -> String {
208 match current() {
209 Language::English => format!("entities exceed limit of {max}"),
210 Language::Portugues => format!("entidades excedem o limite de {max}"),
211 }
212 }
213
214 pub fn limite_relacionamentos(max: usize) -> String {
215 match current() {
216 Language::English => format!("relationships exceed limit of {max}"),
217 Language::Portugues => format!("relacionamentos excedem o limite de {max}"),
218 }
219 }
220}
221
222pub mod validacao {
224 use super::current;
225 use crate::i18n::Language;
226
227 pub fn nome_comprimento(max: usize) -> String {
228 match current() {
229 Language::English => format!("name must be 1-{max} chars"),
230 Language::Portugues => format!("nome deve ter entre 1 e {max} caracteres"),
231 }
232 }
233
234 pub fn nome_reservado() -> String {
235 match current() {
236 Language::English => {
237 "names and namespaces starting with __ are reserved for internal use".to_string()
238 }
239 Language::Portugues => {
240 "nomes e namespaces iniciados com __ são reservados para uso interno".to_string()
241 }
242 }
243 }
244
245 pub fn nome_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::Portugues => {
251 format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
252 }
253 }
254 }
255
256 pub fn descricao_excede(max: usize) -> String {
257 match current() {
258 Language::English => format!("description must be <= {max} chars"),
259 Language::Portugues => format!("descrição deve ter no máximo {max} caracteres"),
260 }
261 }
262
263 pub fn body_excede(max: usize) -> String {
264 match current() {
265 Language::English => format!("body exceeds {max} chars"),
266 Language::Portugues => format!("corpo excede {max} caracteres"),
267 }
268 }
269
270 pub fn novo_nome_comprimento(max: usize) -> String {
271 match current() {
272 Language::English => format!("new-name must be 1-{max} chars"),
273 Language::Portugues => format!("novo nome deve ter entre 1 e {max} caracteres"),
274 }
275 }
276
277 pub fn novo_nome_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::Portugues => format!(
283 "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
284 ),
285 }
286 }
287
288 pub fn namespace_comprimento() -> String {
289 match current() {
290 Language::English => "namespace must be 1-80 chars".to_string(),
291 Language::Portugues => "namespace deve ter entre 1 e 80 caracteres".to_string(),
292 }
293 }
294
295 pub fn namespace_formato() -> String {
296 match current() {
297 Language::English => "namespace must be alphanumeric + hyphens/underscores".to_string(),
298 Language::Portugues => {
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::Portugues => format!("traversal de caminho rejeitado: {p}"),
308 }
309 }
310
311 pub fn tz_invalido(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::Portugues => format!(
317 "SQLITE_GRAPHRAG_DISPLAY_TZ inválido: '{v}'; use um nome IANA como 'America/Sao_Paulo'"
318 ),
319 }
320 }
321
322 pub fn config_namespace_invalido(path: &str, err: &str) -> String {
323 match current() {
324 Language::English => {
325 format!("invalid project namespace config '{path}': {err}")
326 }
327 Language::Portugues => {
328 format!("configuração de namespace de projeto inválida '{path}': {err}")
329 }
330 }
331 }
332
333 pub fn projects_mapping_invalido(path: &str, err: &str) -> String {
334 match current() {
335 Language::English => format!("invalid projects mapping '{path}': {err}"),
336 Language::Portugues => format!("mapeamento de projetos inválido '{path}': {err}"),
337 }
338 }
339
340 pub fn link_auto_referencial() -> String {
341 match current() {
342 Language::English => "--from and --to must be different entities — self-referential relationships are not supported".to_string(),
343 Language::Portugues => "--from e --to devem ser entidades diferentes — relacionamentos auto-referenciais não são suportados".to_string(),
344 }
345 }
346
347 pub fn link_peso_invalido(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::Portugues => {
353 format!("--weight: deve estar entre 0.0 e 1.0 (atual: {weight})")
354 }
355 }
356 }
357
358 pub fn sync_destino_igual_fonte() -> String {
359 match current() {
360 Language::English => {
361 "destination path must differ from the source database path".to_string()
362 }
363 Language::Portugues => {
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 testes {
373 use super::*;
374 use serial_test::serial;
375
376 #[test]
377 #[serial]
378 fn fallback_english_quando_env_ausente() {
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_seleciona_portugues() {
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::Portugues);
394 std::env::remove_var("SQLITE_GRAPHRAG_LANG");
395 }
396
397 #[test]
398 #[serial]
399 fn env_pt_br_seleciona_portugues() {
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::Portugues);
404 std::env::remove_var("SQLITE_GRAPHRAG_LANG");
405 }
406
407 #[test]
408 #[serial]
409 fn locale_ptbr_utf8_seleciona_portugues() {
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::Portugues);
413 std::env::remove_var("LC_ALL");
414 }
415
416 mod testes_validacao {
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::Portugues => 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::Portugues {
431 Language::English => format!("name must be 1-{} chars", 80),
432 Language::Portugues => 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 nome_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::Portugues => {
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 nome_kebab_pt() {
457 let nome = "Invalid_Name";
458 let msg = match Language::Portugues {
459 Language::English => format!(
460 "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
461 ),
462 Language::Portugues => {
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::Portugues => 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::Portugues {
483 Language::English => format!("description must be <= {} chars", 500),
484 Language::Portugues => 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 msg = match Language::English {
495 Language::English => format!("body exceeds {} chars", 20_000),
496 Language::Portugues => format!("corpo excede {} caracteres", 20_000),
497 };
498 assert!(msg.contains("body exceeds 20000"), "obtido: {msg}");
499 }
500
501 #[test]
502 fn body_excede_pt() {
503 let msg = match Language::Portugues {
504 Language::English => format!("body exceeds {} chars", 20_000),
505 Language::Portugues => format!("corpo excede {} caracteres", 20_000),
506 };
507 assert!(msg.contains("corpo excede 20000"), "obtido: {msg}");
508 }
509
510 #[test]
511 fn novo_nome_comprimento_en() {
512 let msg = match Language::English {
513 Language::English => format!("new-name must be 1-{} chars", 80),
514 Language::Portugues => format!("novo nome deve ter entre 1 e {} caracteres", 80),
515 };
516 assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
517 }
518
519 #[test]
520 fn novo_nome_comprimento_pt() {
521 let msg = match Language::Portugues {
522 Language::English => format!("new-name must be 1-{} chars", 80),
523 Language::Portugues => format!("novo nome deve ter entre 1 e {} caracteres", 80),
524 };
525 assert!(
526 msg.contains("novo nome deve ter entre 1 e 80"),
527 "obtido: {msg}"
528 );
529 }
530
531 #[test]
532 fn novo_nome_kebab_en() {
533 let nome = "Bad Name";
534 let msg = match Language::English {
535 Language::English => format!(
536 "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
537 ),
538 Language::Portugues => format!(
539 "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
540 ),
541 };
542 assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
543 }
544
545 #[test]
546 fn novo_nome_kebab_pt() {
547 let nome = "Bad Name";
548 let msg = match Language::Portugues {
549 Language::English => format!(
550 "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
551 ),
552 Language::Portugues => format!(
553 "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
554 ),
555 };
556 assert!(
557 msg.contains("novo nome deve estar em kebab-case"),
558 "obtido: {msg}"
559 );
560 }
561
562 #[test]
563 fn nome_reservado_en() {
564 let msg = match Language::English {
565 Language::English => {
566 "names and namespaces starting with __ are reserved for internal use"
567 .to_string()
568 }
569 Language::Portugues => {
570 "nomes e namespaces iniciados com __ são reservados para uso interno"
571 .to_string()
572 }
573 };
574 assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
575 }
576
577 #[test]
578 fn nome_reservado_pt() {
579 let msg = match Language::Portugues {
580 Language::English => {
581 "names and namespaces starting with __ are reserved for internal use"
582 .to_string()
583 }
584 Language::Portugues => {
585 "nomes e namespaces iniciados com __ são reservados para uso interno"
586 .to_string()
587 }
588 };
589 assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
590 }
591 }
592}