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