Skip to main content

sqlite_graphrag/
i18n.rs

1//! Bilingual human-readable message layer.
2//!
3//! The CLI uses `--lang en|pt` (global flag) or `SQLITE_GRAPHRAG_LANG` (env var) to choose
4//! the language of stderr progress messages. JSON stdout is deterministic and identical
5//! across languages — only strings intended for humans pass through this module.
6//!
7//! Detection (highest to lowest priority):
8//! 1. Explicit `--lang` flag
9//! 2. Env var `SQLITE_GRAPHRAG_LANG`
10//! 3. OS locale (`LANG`, `LC_ALL`) with `pt` prefix
11//! 4. Fallback `English`
12
13use 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    /// Parses a command-line string into a `Language` without relying on clap.
25    /// Accepts the same aliases defined in `#[value(...)]`: "en", "pt", etc.
26    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        // v1.0.36 (L5): empty `SQLITE_GRAPHRAG_LANG` is treated as unset (no warning),
36        // matching POSIX convention where empty-string env vars are equivalent to
37        // missing ones for locale detection.
38        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                // Unrecognized non-empty value: warn and fall through to locale detection.
48                tracing::warn!(
49                    value = %v,
50                    "SQLITE_GRAPHRAG_LANG value not recognized, falling back to locale detection"
51                );
52            }
53        }
54        // POSIX locale precedence: LC_ALL > LC_MESSAGES > LANG.
55        // If LC_ALL is set, LANG must be IGNORED regardless of value.
56        // Previous implementation iterated both and returned PT on any "pt" prefix,
57        // violating POSIX semantics (e.g. `LC_ALL=en_US LANG=pt_BR` returned PT).
58        // Now: stop at first set var; recognize both "pt" and "en" prefixes; fall
59        // through to English default only when no locale var is set.
60        for var in &["LC_ALL", "LC_MESSAGES", "LANG"] {
61            if let Ok(v) = std::env::var(var) {
62                if v.is_empty() {
63                    // POSIX: empty-string env var is equivalent to unset; fall
64                    // through to the next variable in the precedence chain.
65                    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                // Found non-empty var with unrecognized prefix: respect POSIX
75                // precedence by stopping iteration here. Do NOT fall through
76                // to the next var.
77                break;
78            }
79        }
80        Language::English
81    }
82}
83
84static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
85
86/// Initializes the global language. Subsequent calls are silently ignored
87/// (OnceLock semantics) — guaranteeing thread-safety and determinism.
88///
89/// v1.0.36 (L6): early-return when already initialized so the env-fallback
90/// resolver (`from_env_or_locale`) does not run a second time. Without this
91/// guard, calling `init(None)` after `current()` already populated the
92/// OnceLock causes `from_env_or_locale` to fire its `tracing::warn!` twice
93/// for unrecognized `SQLITE_GRAPHRAG_LANG` values.
94pub 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
102/// Returns the active language, or fallback English if `init` was never called.
103pub fn current() -> Language {
104    *GLOBAL_LANGUAGE.get_or_init(Language::from_env_or_locale)
105}
106
107/// Translates a bilingual message by selecting the active variant.
108///
109/// v1.0.36 (M4): inputs are constrained to `&'static str` so the function
110/// can return one of them directly without `Box::leak`. The previous
111/// implementation leaked one allocation per call which accumulated in
112/// long-running pipelines; this version is allocation-free. All in-tree
113/// callers already pass string literals, which are `&'static str`.
114pub fn tr(en: &'static str, pt: &'static str) -> &'static str {
115    match current() {
116        Language::English => en,
117        Language::Portuguese => pt,
118    }
119}
120
121/// Progress message emitted after pruning relationships.
122///
123/// English-only: this string is emitted to stderr as a progress notice and
124/// does not vary by language because the prune-relations command targets
125/// agent-first pipelines where deterministic output matters.
126pub fn relations_pruned(count: usize, relation: &str, namespace: &str) -> String {
127    format!("pruned {count} '{relation}' relationships in namespace '{namespace}'")
128}
129
130/// Progress message for dry-run preview of prune-relations.
131///
132/// English-only: emitted to stderr as a progress notice.
133pub fn prune_dry_run(count: usize, relation: &str) -> String {
134    format!("dry run: {count} '{relation}' relationships would be removed")
135}
136
137/// Warning message when --yes is not passed for destructive prune-relations.
138///
139/// English-only: emitted to stderr as a progress notice.
140pub fn prune_requires_yes() -> String {
141    "destructive operation requires --yes flag; use --dry-run to preview".to_string()
142}
143
144/// Localized prefix for error messages displayed to the end user.
145pub fn error_prefix() -> &'static str {
146    match current() {
147        Language::English => "Error",
148        Language::Portuguese => "Erro",
149    }
150}
151
152/// Error messages for `AppError` variants — always English.
153///
154/// These strings end up inside `AppError` inner fields and may appear in
155/// deterministic JSON stdout (e.g. ingest NDJSON). Portuguese translations
156/// for stderr live in `pub mod app_error_pt` and are applied by
157/// `localized_message_for(Language::Portuguese)`.
158pub 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 duplicate_memory_soft_deleted(name: &str, namespace: &str) -> String {
188        format!(
189            "memory '{name}' exists but is soft-deleted in namespace '{namespace}'; \
190             use --force-merge to restore and update, or `restore` to revive it"
191        )
192    }
193
194    pub fn optimistic_lock_conflict(expected: i64, current_ts: i64) -> String {
195        format!(
196            "optimistic lock conflict: expected updated_at={expected}, but current is {current_ts}"
197        )
198    }
199
200    pub fn version_not_found(versao: i64, nome: &str) -> String {
201        format!("version {versao} not found for memory '{nome}'")
202    }
203
204    pub fn no_recall_results(max_distance: f32, query: &str, namespace: &str) -> String {
205        format!(
206            "no results within --max-distance {max_distance} for query '{query}' in namespace '{namespace}'"
207        )
208    }
209
210    pub fn soft_deleted_memory_not_found(nome: &str, namespace: &str) -> String {
211        format!("soft-deleted memory '{nome}' not found in namespace '{namespace}'")
212    }
213
214    pub fn concurrent_process_conflict() -> String {
215        "optimistic lock conflict: memory was modified by another process".to_string()
216    }
217
218    pub fn entity_limit_exceeded(max: usize) -> String {
219        format!("entities exceed limit of {max}")
220    }
221
222    pub fn relationship_limit_exceeded(max: usize) -> String {
223        format!("relationships exceed limit of {max}")
224    }
225}
226
227/// Localized validation messages for memory fields.
228pub mod validation {
229    use super::current;
230    use crate::i18n::Language;
231
232    pub fn name_length(max: usize) -> String {
233        match current() {
234            Language::English => format!("name must be 1-{max} chars"),
235            Language::Portuguese => format!("nome deve ter entre 1 e {max} caracteres"),
236        }
237    }
238
239    pub fn reserved_name() -> String {
240        match current() {
241            Language::English => {
242                "names and namespaces starting with __ are reserved for internal use".to_string()
243            }
244            Language::Portuguese => {
245                "nomes e namespaces iniciados com __ são reservados para uso interno".to_string()
246            }
247        }
248    }
249
250    pub fn name_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::Portuguese => {
256                format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
257            }
258        }
259    }
260
261    pub fn description_exceeds(max: usize) -> String {
262        match current() {
263            Language::English => format!("description must be <= {max} chars"),
264            Language::Portuguese => format!("descrição deve ter no máximo {max} caracteres"),
265        }
266    }
267
268    pub fn body_exceeds(max: usize) -> String {
269        match current() {
270            Language::English => format!("body exceeds {max} bytes"),
271            Language::Portuguese => format!("corpo excede {max} bytes"),
272        }
273    }
274
275    pub fn new_name_length(max: usize) -> String {
276        match current() {
277            Language::English => format!("new-name must be 1-{max} chars"),
278            Language::Portuguese => format!("novo nome deve ter entre 1 e {max} caracteres"),
279        }
280    }
281
282    pub fn new_name_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::Portuguese => format!(
288                "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
289            ),
290        }
291    }
292
293    pub fn namespace_length() -> String {
294        match current() {
295            Language::English => "namespace must be 1-80 chars".to_string(),
296            Language::Portuguese => "namespace deve ter entre 1 e 80 caracteres".to_string(),
297        }
298    }
299
300    pub fn namespace_format() -> String {
301        match current() {
302            Language::English => "namespace must be alphanumeric + hyphens/underscores".to_string(),
303            Language::Portuguese => {
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::Portuguese => format!("traversal de caminho rejeitado: {p}"),
313        }
314    }
315
316    pub fn invalid_tz(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::Portuguese => format!(
322                "SQLITE_GRAPHRAG_DISPLAY_TZ inválido: '{v}'; use um nome IANA como 'America/Sao_Paulo'"
323            ),
324        }
325    }
326
327    pub fn empty_query() -> String {
328        match current() {
329            Language::English => "query cannot be empty".to_string(),
330            Language::Portuguese => "a consulta não pode estar vazia".to_string(),
331        }
332    }
333
334    pub fn empty_body() -> String {
335        match current() {
336            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(),
337            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(),
338        }
339    }
340
341    pub fn invalid_namespace_config(path: &str, err: &str) -> String {
342        match current() {
343            Language::English => {
344                format!("invalid project namespace config '{path}': {err}")
345            }
346            Language::Portuguese => {
347                format!("configuração de namespace de projeto inválida '{path}': {err}")
348            }
349        }
350    }
351
352    pub fn invalid_projects_mapping(path: &str, err: &str) -> String {
353        match current() {
354            Language::English => format!("invalid projects mapping '{path}': {err}"),
355            Language::Portuguese => format!("mapeamento de projetos inválido '{path}': {err}"),
356        }
357    }
358
359    pub fn self_referential_link() -> String {
360        match current() {
361            Language::English => "--from and --to must be different entities — self-referential relationships are not supported".to_string(),
362            Language::Portuguese => "--from e --to devem ser entidades diferentes — relacionamentos auto-referenciais não são suportados".to_string(),
363        }
364    }
365
366    pub fn invalid_link_weight(weight: f64) -> String {
367        match current() {
368            Language::English => {
369                format!("--weight: must be between 0.0 and 1.0 (actual: {weight})")
370            }
371            Language::Portuguese => {
372                format!("--weight: deve estar entre 0.0 e 1.0 (atual: {weight})")
373            }
374        }
375    }
376
377    pub fn sync_destination_equals_source() -> String {
378        match current() {
379            Language::English => {
380                "destination path must differ from the source database path".to_string()
381            }
382            Language::Portuguese => {
383                "caminho de destino deve ser diferente do caminho do banco de dados fonte"
384                    .to_string()
385            }
386        }
387    }
388
389    /// Portuguese translations for `AppError` Display messages.
390    ///
391    /// Each helper mirrors a single `AppError` variant's `#[error(...)]` text in
392    /// Portuguese, keeping the language barrier confined to this module. The
393    /// English source of truth lives in `src/errors.rs` via `thiserror`.
394    pub mod app_error_pt {
395        pub fn validation(msg: &str) -> String {
396            format!("erro de validação: {msg}")
397        }
398
399        pub fn duplicate(msg: &str) -> String {
400            let translated = msg
401                .replace("already exists in namespace", "já existe no namespace")
402                .replace(
403                    "exists but is soft-deleted in namespace",
404                    "existe mas está excluída temporariamente no namespace",
405                )
406                .replace(
407                    "Use --force-merge to update.",
408                    "Use --force-merge para atualizar.",
409                )
410                .replace(
411                    "use --force-merge to restore and update, or `restore` to revive it",
412                    "use --force-merge para restaurar e atualizar, ou `restore` para revivê-la",
413                )
414                .replace("memory", "memória");
415            format!("duplicata detectada: {translated}")
416        }
417
418        pub fn conflict(msg: &str) -> String {
419            let translated = msg
420                .replace("optimistic lock conflict", "conflito de lock otimista")
421                .replace("but current is", "mas atual é")
422                .replace(
423                    "was modified by another process",
424                    "foi modificada por outro processo",
425                );
426            format!("conflito: {translated}")
427        }
428
429        pub fn not_found(msg: &str) -> String {
430            let translated = msg
431                .replace("not found in namespace", "não encontrada no namespace")
432                .replace("not found for memory", "não encontrada para memória")
433                .replace("does not exist in namespace", "não existe no namespace")
434                .replace("memory or entity", "memória ou entidade")
435                .replace("memory", "memória")
436                .replace("entity", "entidade")
437                .replace("version", "versão")
438                .replace("soft-deleted", "excluída temporariamente");
439            format!("não encontrado: {translated}")
440        }
441
442        pub fn namespace_error(msg: &str) -> String {
443            format!("namespace não resolvido: {msg}")
444        }
445
446        pub fn limit_exceeded(msg: &str) -> String {
447            let translated = msg
448                .replace("exceeds limit of", "excede limite de")
449                .replace("body exceeds", "corpo excede")
450                .replace("entities exceed limit", "entidades excedem limite")
451                .replace(
452                    "relationships exceed limit",
453                    "relacionamentos excedem limite",
454                );
455            format!("limite excedido: {translated}")
456        }
457
458        pub fn database(err: &str) -> String {
459            format!("erro de banco de dados: {err}")
460        }
461
462        pub fn embedding(msg: &str) -> String {
463            format!("erro de embedding: {msg}")
464        }
465
466        pub fn vec_extension(msg: &str) -> String {
467            format!("extensão sqlite-vec falhou: {msg}")
468        }
469
470        pub fn db_busy(msg: &str) -> String {
471            format!("banco ocupado: {msg}")
472        }
473
474        pub fn batch_partial_failure(total: usize, failed: usize) -> String {
475            format!("falha parcial em batch: {failed} de {total} itens falharam")
476        }
477
478        pub fn io(err: &str) -> String {
479            format!("erro de I/O: {err}")
480        }
481
482        pub fn internal(err: &str) -> String {
483            format!("erro interno: {err}")
484        }
485
486        pub fn json(err: &str) -> String {
487            format!("erro de JSON: {err}")
488        }
489
490        pub fn lock_busy(msg: &str) -> String {
491            format!("lock ocupado: {msg}")
492        }
493
494        pub fn all_slots_full(max: usize, waited_secs: u64) -> String {
495            format!(
496                "todos os {max} slots de concorrência ocupados após aguardar {waited_secs}s \
497                 (exit 75); use --max-concurrency ou aguarde outras invocações terminarem"
498            )
499        }
500
501        pub fn low_memory(available_mb: u64, required_mb: u64) -> String {
502            format!(
503                "memória disponível ({available_mb}MB) abaixo do mínimo requerido ({required_mb}MB) \
504                 para carregar o modelo; aborte outras cargas ou use --skip-memory-guard (exit 77)"
505            )
506        }
507    }
508
509    /// Portuguese translations for runtime startup messages emitted from `main.rs`.
510    ///
511    /// These mirror the English text supplied alongside each call to
512    /// `output::emit_progress_i18n` / `output::emit_error_i18n`, keeping the PT
513    /// strings confined to this module per the language policy.
514    pub mod runtime_pt {
515        pub fn embedding_heavy_must_measure_ram() -> String {
516            "comando intensivo em embedding precisa medir RAM disponível".to_string()
517        }
518
519        pub fn heavy_command_detected(available_mb: u64, safe_concurrency: usize) -> String {
520            format!(
521                "Comando pesado detectado; memória disponível: {available_mb} MB; \
522                 concorrência segura: {safe_concurrency}"
523            )
524        }
525
526        pub fn reducing_concurrency(
527            requested_concurrency: usize,
528            effective_concurrency: usize,
529        ) -> String {
530            format!(
531                "Reduzindo a concorrência solicitada de {requested_concurrency} para \
532                 {effective_concurrency} para evitar oversubscription de memória"
533            )
534        }
535
536        pub fn initializing_embedding_model() -> &'static str {
537            "Inicializando modelo de embedding (pode baixar na primeira execução)..."
538        }
539
540        pub fn embedding_chunks_serially(count: usize) -> String {
541            format!("Embedando {count} chunks serialmente para manter memória limitada...")
542        }
543
544        pub fn remember_step_input_validated(available_mb: u64) -> String {
545            format!("Etapa remember: entrada validada; memória disponível {available_mb} MB")
546        }
547
548        pub fn remember_step_chunking_completed(
549            total_passage_tokens: usize,
550            model_max_length: usize,
551            chunks_count: usize,
552            rss_mb: u64,
553        ) -> String {
554            format!(
555                "Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem \
556                 (máximo do modelo {model_max_length}); chunking gerou {chunks_count} chunks; \
557                 RSS do processo {rss_mb} MB"
558            )
559        }
560
561        pub fn remember_step_embeddings_completed(rss_mb: u64) -> String {
562            format!("Etapa remember: embeddings dos chunks concluídos; RSS do processo {rss_mb} MB")
563        }
564
565        pub fn restore_recomputing_embedding() -> &'static str {
566            "Recalculando embedding da memória restaurada..."
567        }
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use serial_test::serial;
575
576    #[test]
577    #[serial]
578    fn fallback_english_when_env_absent() {
579        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
580        std::env::set_var("LC_ALL", "C");
581        std::env::set_var("LANG", "C");
582        assert_eq!(Language::from_env_or_locale(), Language::English);
583        std::env::remove_var("LC_ALL");
584        std::env::remove_var("LANG");
585    }
586
587    #[test]
588    #[serial]
589    fn env_pt_selects_portuguese() {
590        std::env::remove_var("LC_ALL");
591        std::env::remove_var("LANG");
592        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt");
593        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
594        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
595    }
596
597    #[test]
598    #[serial]
599    fn env_pt_br_selects_portuguese() {
600        std::env::remove_var("LC_ALL");
601        std::env::remove_var("LANG");
602        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt-BR");
603        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
604        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
605    }
606
607    #[test]
608    #[serial]
609    fn locale_ptbr_utf8_selects_portuguese() {
610        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
611        std::env::set_var("LC_ALL", "pt_BR.UTF-8");
612        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
613        std::env::remove_var("LC_ALL");
614    }
615
616    #[test]
617    #[serial]
618    fn posix_precedence_lc_all_overrides_lang() {
619        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
620        std::env::remove_var("LC_MESSAGES");
621        std::env::set_var("LC_ALL", "en_US.UTF-8");
622        std::env::set_var("LANG", "pt_BR.UTF-8");
623        assert_eq!(
624            Language::from_env_or_locale(),
625            Language::English,
626            "LC_ALL=en_US must override LANG=pt_BR per POSIX"
627        );
628        std::env::remove_var("LC_ALL");
629        std::env::remove_var("LANG");
630    }
631
632    #[test]
633    #[serial]
634    fn posix_precedence_lc_all_unrecognized_stops_iteration() {
635        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
636        std::env::remove_var("LC_MESSAGES");
637        std::env::set_var("LC_ALL", "ja_JP.UTF-8");
638        std::env::set_var("LANG", "pt_BR.UTF-8");
639        assert_eq!(
640            Language::from_env_or_locale(),
641            Language::English,
642            "LC_ALL=ja_JP set must stop iteration; falls back to English default"
643        );
644        std::env::remove_var("LC_ALL");
645        std::env::remove_var("LANG");
646    }
647
648    #[test]
649    #[serial]
650    fn lang_pt_selects_portuguese_when_lc_all_unset() {
651        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
652        std::env::remove_var("LC_ALL");
653        std::env::remove_var("LC_MESSAGES");
654        std::env::set_var("LANG", "pt_BR.UTF-8");
655        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
656        std::env::remove_var("LANG");
657    }
658
659    mod validation_tests {
660        use super::*;
661
662        #[test]
663        fn name_length_en() {
664            let msg = match Language::English {
665                Language::English => format!("name must be 1-{} chars", 80),
666                Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
667            };
668            assert!(msg.contains("name must be 1-80 chars"), "obtido: {msg}");
669        }
670
671        #[test]
672        fn name_length_pt() {
673            let msg = match Language::Portuguese {
674                Language::English => format!("name must be 1-{} chars", 80),
675                Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
676            };
677            assert!(
678                msg.contains("nome deve ter entre 1 e 80 caracteres"),
679                "obtido: {msg}"
680            );
681        }
682
683        #[test]
684        fn name_kebab_en() {
685            let nome = "Invalid_Name";
686            let msg = match Language::English {
687                Language::English => format!(
688                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
689                ),
690                Language::Portuguese => {
691                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
692                }
693            };
694            assert!(msg.contains("kebab-case slug"), "obtido: {msg}");
695            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
696        }
697
698        #[test]
699        fn name_kebab_pt() {
700            let nome = "Invalid_Name";
701            let msg = match Language::Portuguese {
702                Language::English => format!(
703                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
704                ),
705                Language::Portuguese => {
706                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
707                }
708            };
709            assert!(msg.contains("kebab-case"), "obtido: {msg}");
710            assert!(msg.contains("minúsculas"), "obtido: {msg}");
711            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
712        }
713
714        #[test]
715        fn description_exceeds_en() {
716            let msg = match Language::English {
717                Language::English => format!("description must be <= {} chars", 500),
718                Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
719            };
720            assert!(msg.contains("description must be <= 500"), "obtido: {msg}");
721        }
722
723        #[test]
724        fn description_exceeds_pt() {
725            let msg = match Language::Portuguese {
726                Language::English => format!("description must be <= {} chars", 500),
727                Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
728            };
729            assert!(
730                msg.contains("descrição deve ter no máximo 500"),
731                "obtido: {msg}"
732            );
733        }
734
735        #[test]
736        fn body_exceeds_en() {
737            let limite = crate::constants::MAX_MEMORY_BODY_LEN;
738            let msg = match Language::English {
739                Language::English => format!("body exceeds {limite} bytes"),
740                Language::Portuguese => format!("corpo excede {limite} bytes"),
741            };
742            assert!(msg.contains("body exceeds 512000"), "obtido: {msg}");
743        }
744
745        #[test]
746        fn body_exceeds_pt() {
747            let limite = crate::constants::MAX_MEMORY_BODY_LEN;
748            let msg = match Language::Portuguese {
749                Language::English => format!("body exceeds {limite} bytes"),
750                Language::Portuguese => format!("corpo excede {limite} bytes"),
751            };
752            assert!(msg.contains("corpo excede 512000"), "obtido: {msg}");
753        }
754
755        #[test]
756        fn new_name_length_en() {
757            let msg = match Language::English {
758                Language::English => format!("new-name must be 1-{} chars", 80),
759                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
760            };
761            assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
762        }
763
764        #[test]
765        fn new_name_length_pt() {
766            let msg = match Language::Portuguese {
767                Language::English => format!("new-name must be 1-{} chars", 80),
768                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
769            };
770            assert!(
771                msg.contains("novo nome deve ter entre 1 e 80"),
772                "obtido: {msg}"
773            );
774        }
775
776        #[test]
777        fn new_name_kebab_en() {
778            let nome = "Bad Name";
779            let msg = match Language::English {
780                Language::English => format!(
781                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
782                ),
783                Language::Portuguese => format!(
784                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
785                ),
786            };
787            assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
788        }
789
790        #[test]
791        fn new_name_kebab_pt() {
792            let nome = "Bad Name";
793            let msg = match Language::Portuguese {
794                Language::English => format!(
795                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
796                ),
797                Language::Portuguese => format!(
798                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
799                ),
800            };
801            assert!(
802                msg.contains("novo nome deve estar em kebab-case"),
803                "obtido: {msg}"
804            );
805        }
806
807        #[test]
808        fn reserved_name_en() {
809            let msg = match Language::English {
810                Language::English => {
811                    "names and namespaces starting with __ are reserved for internal use"
812                        .to_string()
813                }
814                Language::Portuguese => {
815                    "nomes e namespaces iniciados com __ são reservados para uso interno"
816                        .to_string()
817                }
818            };
819            assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
820        }
821
822        #[test]
823        fn reserved_name_pt() {
824            let msg = match Language::Portuguese {
825                Language::English => {
826                    "names and namespaces starting with __ are reserved for internal use"
827                        .to_string()
828                }
829                Language::Portuguese => {
830                    "nomes e namespaces iniciados com __ são reservados para uso interno"
831                        .to_string()
832                }
833            };
834            assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
835        }
836    }
837
838    mod app_error_pt_translation_tests {
839        use crate::errors::AppError;
840
841        #[test]
842        fn localized_message_pt_not_found_fully_translated() {
843            let err =
844                AppError::NotFound("memory 'test-mem' not found in namespace 'global'".into());
845            let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
846            assert!(
847                pt.contains("memória"),
848                "PT must translate 'memory' to 'memória': {pt}"
849            );
850            assert!(
851                pt.contains("não encontrada no namespace"),
852                "PT must translate full phrase: {pt}"
853            );
854            assert!(
855                !pt.contains("not found in namespace"),
856                "PT must not contain English phrase: {pt}"
857            );
858        }
859
860        #[test]
861        fn localized_message_pt_duplicate_fully_translated() {
862            let err = AppError::Duplicate(
863                "memory 'x' already exists in namespace 'global'. Use --force-merge to update."
864                    .into(),
865            );
866            let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
867            assert!(pt.contains("memória"), "PT must translate 'memory': {pt}");
868            assert!(
869                pt.contains("já existe no namespace"),
870                "PT must translate 'already exists': {pt}"
871            );
872        }
873    }
874}