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        pub fn edit_recomputing_embedding() -> &'static str {
570            "Recalculando embedding da memória editada..."
571        }
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use serial_test::serial;
579
580    #[test]
581    #[serial]
582    fn fallback_english_when_env_absent() {
583        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
584        std::env::set_var("LC_ALL", "C");
585        std::env::set_var("LANG", "C");
586        assert_eq!(Language::from_env_or_locale(), Language::English);
587        std::env::remove_var("LC_ALL");
588        std::env::remove_var("LANG");
589    }
590
591    #[test]
592    #[serial]
593    fn env_pt_selects_portuguese() {
594        std::env::remove_var("LC_ALL");
595        std::env::remove_var("LANG");
596        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt");
597        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
598        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
599    }
600
601    #[test]
602    #[serial]
603    fn env_pt_br_selects_portuguese() {
604        std::env::remove_var("LC_ALL");
605        std::env::remove_var("LANG");
606        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt-BR");
607        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
608        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
609    }
610
611    #[test]
612    #[serial]
613    fn locale_ptbr_utf8_selects_portuguese() {
614        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
615        std::env::set_var("LC_ALL", "pt_BR.UTF-8");
616        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
617        std::env::remove_var("LC_ALL");
618    }
619
620    #[test]
621    #[serial]
622    fn posix_precedence_lc_all_overrides_lang() {
623        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
624        std::env::remove_var("LC_MESSAGES");
625        std::env::set_var("LC_ALL", "en_US.UTF-8");
626        std::env::set_var("LANG", "pt_BR.UTF-8");
627        assert_eq!(
628            Language::from_env_or_locale(),
629            Language::English,
630            "LC_ALL=en_US must override LANG=pt_BR per POSIX"
631        );
632        std::env::remove_var("LC_ALL");
633        std::env::remove_var("LANG");
634    }
635
636    #[test]
637    #[serial]
638    fn posix_precedence_lc_all_unrecognized_stops_iteration() {
639        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
640        std::env::remove_var("LC_MESSAGES");
641        std::env::set_var("LC_ALL", "ja_JP.UTF-8");
642        std::env::set_var("LANG", "pt_BR.UTF-8");
643        assert_eq!(
644            Language::from_env_or_locale(),
645            Language::English,
646            "LC_ALL=ja_JP set must stop iteration; falls back to English default"
647        );
648        std::env::remove_var("LC_ALL");
649        std::env::remove_var("LANG");
650    }
651
652    #[test]
653    #[serial]
654    fn lang_pt_selects_portuguese_when_lc_all_unset() {
655        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
656        std::env::remove_var("LC_ALL");
657        std::env::remove_var("LC_MESSAGES");
658        std::env::set_var("LANG", "pt_BR.UTF-8");
659        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
660        std::env::remove_var("LANG");
661    }
662
663    mod validation_tests {
664        use super::*;
665
666        #[test]
667        fn name_length_en() {
668            let msg = match Language::English {
669                Language::English => format!("name must be 1-{} chars", 80),
670                Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
671            };
672            assert!(msg.contains("name must be 1-80 chars"), "obtido: {msg}");
673        }
674
675        #[test]
676        fn name_length_pt() {
677            let msg = match Language::Portuguese {
678                Language::English => format!("name must be 1-{} chars", 80),
679                Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
680            };
681            assert!(
682                msg.contains("nome deve ter entre 1 e 80 caracteres"),
683                "obtido: {msg}"
684            );
685        }
686
687        #[test]
688        fn name_kebab_en() {
689            let nome = "Invalid_Name";
690            let msg = match Language::English {
691                Language::English => format!(
692                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
693                ),
694                Language::Portuguese => {
695                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
696                }
697            };
698            assert!(msg.contains("kebab-case slug"), "obtido: {msg}");
699            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
700        }
701
702        #[test]
703        fn name_kebab_pt() {
704            let nome = "Invalid_Name";
705            let msg = match Language::Portuguese {
706                Language::English => format!(
707                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
708                ),
709                Language::Portuguese => {
710                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
711                }
712            };
713            assert!(msg.contains("kebab-case"), "obtido: {msg}");
714            assert!(msg.contains("minúsculas"), "obtido: {msg}");
715            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
716        }
717
718        #[test]
719        fn description_exceeds_en() {
720            let msg = match Language::English {
721                Language::English => format!("description must be <= {} chars", 500),
722                Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
723            };
724            assert!(msg.contains("description must be <= 500"), "obtido: {msg}");
725        }
726
727        #[test]
728        fn description_exceeds_pt() {
729            let msg = match Language::Portuguese {
730                Language::English => format!("description must be <= {} chars", 500),
731                Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
732            };
733            assert!(
734                msg.contains("descrição deve ter no máximo 500"),
735                "obtido: {msg}"
736            );
737        }
738
739        #[test]
740        fn body_exceeds_en() {
741            let limite = crate::constants::MAX_MEMORY_BODY_LEN;
742            let msg = match Language::English {
743                Language::English => format!("body exceeds {limite} bytes"),
744                Language::Portuguese => format!("corpo excede {limite} bytes"),
745            };
746            assert!(msg.contains("body exceeds 512000"), "obtido: {msg}");
747        }
748
749        #[test]
750        fn body_exceeds_pt() {
751            let limite = crate::constants::MAX_MEMORY_BODY_LEN;
752            let msg = match Language::Portuguese {
753                Language::English => format!("body exceeds {limite} bytes"),
754                Language::Portuguese => format!("corpo excede {limite} bytes"),
755            };
756            assert!(msg.contains("corpo excede 512000"), "obtido: {msg}");
757        }
758
759        #[test]
760        fn new_name_length_en() {
761            let msg = match Language::English {
762                Language::English => format!("new-name must be 1-{} chars", 80),
763                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
764            };
765            assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
766        }
767
768        #[test]
769        fn new_name_length_pt() {
770            let msg = match Language::Portuguese {
771                Language::English => format!("new-name must be 1-{} chars", 80),
772                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
773            };
774            assert!(
775                msg.contains("novo nome deve ter entre 1 e 80"),
776                "obtido: {msg}"
777            );
778        }
779
780        #[test]
781        fn new_name_kebab_en() {
782            let nome = "Bad Name";
783            let msg = match Language::English {
784                Language::English => format!(
785                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
786                ),
787                Language::Portuguese => format!(
788                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
789                ),
790            };
791            assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
792        }
793
794        #[test]
795        fn new_name_kebab_pt() {
796            let nome = "Bad Name";
797            let msg = match Language::Portuguese {
798                Language::English => format!(
799                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
800                ),
801                Language::Portuguese => format!(
802                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
803                ),
804            };
805            assert!(
806                msg.contains("novo nome deve estar em kebab-case"),
807                "obtido: {msg}"
808            );
809        }
810
811        #[test]
812        fn reserved_name_en() {
813            let msg = match Language::English {
814                Language::English => {
815                    "names and namespaces starting with __ are reserved for internal use"
816                        .to_string()
817                }
818                Language::Portuguese => {
819                    "nomes e namespaces iniciados com __ são reservados para uso interno"
820                        .to_string()
821                }
822            };
823            assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
824        }
825
826        #[test]
827        fn reserved_name_pt() {
828            let msg = match Language::Portuguese {
829                Language::English => {
830                    "names and namespaces starting with __ are reserved for internal use"
831                        .to_string()
832                }
833                Language::Portuguese => {
834                    "nomes e namespaces iniciados com __ são reservados para uso interno"
835                        .to_string()
836                }
837            };
838            assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
839        }
840    }
841
842    mod app_error_pt_translation_tests {
843        use crate::errors::AppError;
844
845        #[test]
846        fn localized_message_pt_not_found_fully_translated() {
847            let err =
848                AppError::NotFound("memory 'test-mem' not found in namespace 'global'".into());
849            let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
850            assert!(
851                pt.contains("memória"),
852                "PT must translate 'memory' to 'memória': {pt}"
853            );
854            assert!(
855                pt.contains("não encontrada no namespace"),
856                "PT must translate full phrase: {pt}"
857            );
858            assert!(
859                !pt.contains("not found in namespace"),
860                "PT must not contain English phrase: {pt}"
861            );
862        }
863
864        #[test]
865        fn localized_message_pt_duplicate_fully_translated() {
866            let err = AppError::Duplicate(
867                "memory 'x' already exists in namespace 'global'. Use --force-merge to update."
868                    .into(),
869            );
870            let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
871            assert!(pt.contains("memória"), "PT must translate 'memory': {pt}");
872            assert!(
873                pt.contains("já existe no namespace"),
874                "PT must translate 'already exists': {pt}"
875            );
876        }
877    }
878}