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 low_memory(available_mb: u64, required_mb: u64) -> String {
486            format!(
487                "memória disponível ({available_mb}MB) abaixo do mínimo requerido ({required_mb}MB) \
488                 para carregar o modelo; aborte outras cargas ou use --skip-memory-guard (exit 77)"
489            )
490        }
491
492        pub fn binary_not_found(name: &str) -> String {
493            format!("binário não encontrado: {name} — instale e adicione ao PATH")
494        }
495
496        pub fn rate_limited(detail: &str) -> String {
497            format!("taxa de requisição excedida: {detail}")
498        }
499
500        pub fn timeout(operation: &str, secs: u64) -> String {
501            format!("timeout após {secs}s: {operation}")
502        }
503    }
504
505    /// Portuguese translations for runtime startup messages emitted from `main.rs`.
506    ///
507    /// These mirror the English text supplied alongside each call to
508    /// `output::emit_progress_i18n` / `output::emit_error_i18n`, keeping the PT
509    /// strings confined to this module per the language policy.
510    pub mod runtime_pt {
511        pub fn embedding_heavy_must_measure_ram() -> String {
512            "comando intensivo em embedding precisa medir RAM disponível".to_string()
513        }
514
515        pub fn heavy_command_detected(available_mb: u64, safe_concurrency: usize) -> String {
516            format!(
517                "Comando pesado detectado; memória disponível: {available_mb} MB; \
518                 concorrência segura: {safe_concurrency}"
519            )
520        }
521
522        pub fn reducing_concurrency(
523            requested_concurrency: usize,
524            effective_concurrency: usize,
525        ) -> String {
526            format!(
527                "Reduzindo a concorrência solicitada de {requested_concurrency} para \
528                 {effective_concurrency} para evitar oversubscription de memória"
529            )
530        }
531
532        pub fn initializing_embedding_model() -> &'static str {
533            "Inicializando modelo de embedding (pode baixar na primeira execução)..."
534        }
535
536        pub fn embedding_chunks_serially(count: usize) -> String {
537            format!("Embedando {count} chunks serialmente para manter memória limitada...")
538        }
539
540        pub fn remember_step_input_validated(available_mb: u64) -> String {
541            format!("Etapa remember: entrada validada; memória disponível {available_mb} MB")
542        }
543
544        pub fn remember_step_chunking_completed(
545            total_passage_tokens: usize,
546            model_max_length: usize,
547            chunks_count: usize,
548            rss_mb: u64,
549        ) -> String {
550            format!(
551                "Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem \
552                 (máximo do modelo {model_max_length}); chunking gerou {chunks_count} chunks; \
553                 RSS do processo {rss_mb} MB"
554            )
555        }
556
557        pub fn remember_step_embeddings_completed(rss_mb: u64) -> String {
558            format!("Etapa remember: embeddings dos chunks concluídos; RSS do processo {rss_mb} MB")
559        }
560
561        pub fn restore_recomputing_embedding() -> &'static str {
562            "Recalculando embedding da memória restaurada..."
563        }
564
565        pub fn edit_recomputing_embedding() -> &'static str {
566            "Recalculando embedding da memória editada..."
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}