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") {
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 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
62pub 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
69pub fn current() -> Language {
71 *GLOBAL_LANGUAGE.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::Portuguese => Box::leak(pt.to_string().into_boxed_str()),
83 }
84}
85
86pub fn error_prefix() -> &'static str {
88 match current() {
89 Language::English => "Error",
90 Language::Portuguese => "Erro",
91 }
92}
93
94pub 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
222pub 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}