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            format!("duplicata detectada: {msg}")
401        }
402
403        pub fn conflict(msg: &str) -> String {
404            format!("conflito: {msg}")
405        }
406
407        pub fn not_found(msg: &str) -> String {
408            format!("não encontrado: {msg}")
409        }
410
411        pub fn namespace_error(msg: &str) -> String {
412            format!("namespace não resolvido: {msg}")
413        }
414
415        pub fn limit_exceeded(msg: &str) -> String {
416            format!("limite excedido: {msg}")
417        }
418
419        pub fn database(err: &str) -> String {
420            format!("erro de banco de dados: {err}")
421        }
422
423        pub fn embedding(msg: &str) -> String {
424            format!("erro de embedding: {msg}")
425        }
426
427        pub fn vec_extension(msg: &str) -> String {
428            format!("extensão sqlite-vec falhou: {msg}")
429        }
430
431        pub fn db_busy(msg: &str) -> String {
432            format!("banco ocupado: {msg}")
433        }
434
435        pub fn batch_partial_failure(total: usize, failed: usize) -> String {
436            format!("falha parcial em batch: {failed} de {total} itens falharam")
437        }
438
439        pub fn io(err: &str) -> String {
440            format!("erro de I/O: {err}")
441        }
442
443        pub fn internal(err: &str) -> String {
444            format!("erro interno: {err}")
445        }
446
447        pub fn json(err: &str) -> String {
448            format!("erro de JSON: {err}")
449        }
450
451        pub fn lock_busy(msg: &str) -> String {
452            format!("lock ocupado: {msg}")
453        }
454
455        pub fn all_slots_full(max: usize, waited_secs: u64) -> String {
456            format!(
457                "todos os {max} slots de concorrência ocupados após aguardar {waited_secs}s \
458                 (exit 75); use --max-concurrency ou aguarde outras invocações terminarem"
459            )
460        }
461
462        pub fn low_memory(available_mb: u64, required_mb: u64) -> String {
463            format!(
464                "memória disponível ({available_mb}MB) abaixo do mínimo requerido ({required_mb}MB) \
465                 para carregar o modelo; aborte outras cargas ou use --skip-memory-guard (exit 77)"
466            )
467        }
468    }
469
470    /// Portuguese translations for runtime startup messages emitted from `main.rs`.
471    ///
472    /// These mirror the English text supplied alongside each call to
473    /// `output::emit_progress_i18n` / `output::emit_error_i18n`, keeping the PT
474    /// strings confined to this module per the language policy.
475    pub mod runtime_pt {
476        pub fn embedding_heavy_must_measure_ram() -> String {
477            "comando intensivo em embedding precisa medir RAM disponível".to_string()
478        }
479
480        pub fn heavy_command_detected(available_mb: u64, safe_concurrency: usize) -> String {
481            format!(
482                "Comando pesado detectado; memória disponível: {available_mb} MB; \
483                 concorrência segura: {safe_concurrency}"
484            )
485        }
486
487        pub fn reducing_concurrency(
488            requested_concurrency: usize,
489            effective_concurrency: usize,
490        ) -> String {
491            format!(
492                "Reduzindo a concorrência solicitada de {requested_concurrency} para \
493                 {effective_concurrency} para evitar oversubscription de memória"
494            )
495        }
496
497        pub fn initializing_embedding_model() -> &'static str {
498            "Inicializando modelo de embedding (pode baixar na primeira execução)..."
499        }
500
501        pub fn embedding_chunks_serially(count: usize) -> String {
502            format!("Embedando {count} chunks serialmente para manter memória limitada...")
503        }
504
505        pub fn remember_step_input_validated(available_mb: u64) -> String {
506            format!("Etapa remember: entrada validada; memória disponível {available_mb} MB")
507        }
508
509        pub fn remember_step_chunking_completed(
510            total_passage_tokens: usize,
511            model_max_length: usize,
512            chunks_count: usize,
513            rss_mb: u64,
514        ) -> String {
515            format!(
516                "Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem \
517                 (máximo do modelo {model_max_length}); chunking gerou {chunks_count} chunks; \
518                 RSS do processo {rss_mb} MB"
519            )
520        }
521
522        pub fn remember_step_embeddings_completed(rss_mb: u64) -> String {
523            format!("Etapa remember: embeddings dos chunks concluídos; RSS do processo {rss_mb} MB")
524        }
525
526        pub fn restore_recomputing_embedding() -> &'static str {
527            "Recalculando embedding da memória restaurada..."
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use serial_test::serial;
536
537    #[test]
538    #[serial]
539    fn fallback_english_when_env_absent() {
540        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
541        std::env::set_var("LC_ALL", "C");
542        std::env::set_var("LANG", "C");
543        assert_eq!(Language::from_env_or_locale(), Language::English);
544        std::env::remove_var("LC_ALL");
545        std::env::remove_var("LANG");
546    }
547
548    #[test]
549    #[serial]
550    fn env_pt_selects_portuguese() {
551        std::env::remove_var("LC_ALL");
552        std::env::remove_var("LANG");
553        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt");
554        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
555        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
556    }
557
558    #[test]
559    #[serial]
560    fn env_pt_br_selects_portuguese() {
561        std::env::remove_var("LC_ALL");
562        std::env::remove_var("LANG");
563        std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt-BR");
564        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
565        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
566    }
567
568    #[test]
569    #[serial]
570    fn locale_ptbr_utf8_selects_portuguese() {
571        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
572        std::env::set_var("LC_ALL", "pt_BR.UTF-8");
573        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
574        std::env::remove_var("LC_ALL");
575    }
576
577    #[test]
578    #[serial]
579    fn posix_precedence_lc_all_overrides_lang() {
580        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
581        std::env::remove_var("LC_MESSAGES");
582        std::env::set_var("LC_ALL", "en_US.UTF-8");
583        std::env::set_var("LANG", "pt_BR.UTF-8");
584        assert_eq!(
585            Language::from_env_or_locale(),
586            Language::English,
587            "LC_ALL=en_US must override LANG=pt_BR per POSIX"
588        );
589        std::env::remove_var("LC_ALL");
590        std::env::remove_var("LANG");
591    }
592
593    #[test]
594    #[serial]
595    fn posix_precedence_lc_all_unrecognized_stops_iteration() {
596        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
597        std::env::remove_var("LC_MESSAGES");
598        std::env::set_var("LC_ALL", "ja_JP.UTF-8");
599        std::env::set_var("LANG", "pt_BR.UTF-8");
600        assert_eq!(
601            Language::from_env_or_locale(),
602            Language::English,
603            "LC_ALL=ja_JP set must stop iteration; falls back to English default"
604        );
605        std::env::remove_var("LC_ALL");
606        std::env::remove_var("LANG");
607    }
608
609    #[test]
610    #[serial]
611    fn lang_pt_selects_portuguese_when_lc_all_unset() {
612        std::env::remove_var("SQLITE_GRAPHRAG_LANG");
613        std::env::remove_var("LC_ALL");
614        std::env::remove_var("LC_MESSAGES");
615        std::env::set_var("LANG", "pt_BR.UTF-8");
616        assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
617        std::env::remove_var("LANG");
618    }
619
620    mod validation_tests {
621        use super::*;
622
623        #[test]
624        fn name_length_en() {
625            let msg = match Language::English {
626                Language::English => format!("name must be 1-{} chars", 80),
627                Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
628            };
629            assert!(msg.contains("name must be 1-80 chars"), "obtido: {msg}");
630        }
631
632        #[test]
633        fn name_length_pt() {
634            let msg = match Language::Portuguese {
635                Language::English => format!("name must be 1-{} chars", 80),
636                Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
637            };
638            assert!(
639                msg.contains("nome deve ter entre 1 e 80 caracteres"),
640                "obtido: {msg}"
641            );
642        }
643
644        #[test]
645        fn name_kebab_en() {
646            let nome = "Invalid_Name";
647            let msg = match Language::English {
648                Language::English => format!(
649                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
650                ),
651                Language::Portuguese => {
652                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
653                }
654            };
655            assert!(msg.contains("kebab-case slug"), "obtido: {msg}");
656            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
657        }
658
659        #[test]
660        fn name_kebab_pt() {
661            let nome = "Invalid_Name";
662            let msg = match Language::Portuguese {
663                Language::English => format!(
664                    "name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
665                ),
666                Language::Portuguese => {
667                    format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
668                }
669            };
670            assert!(msg.contains("kebab-case"), "obtido: {msg}");
671            assert!(msg.contains("minúsculas"), "obtido: {msg}");
672            assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
673        }
674
675        #[test]
676        fn description_exceeds_en() {
677            let msg = match Language::English {
678                Language::English => format!("description must be <= {} chars", 500),
679                Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
680            };
681            assert!(msg.contains("description must be <= 500"), "obtido: {msg}");
682        }
683
684        #[test]
685        fn description_exceeds_pt() {
686            let msg = match Language::Portuguese {
687                Language::English => format!("description must be <= {} chars", 500),
688                Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
689            };
690            assert!(
691                msg.contains("descrição deve ter no máximo 500"),
692                "obtido: {msg}"
693            );
694        }
695
696        #[test]
697        fn body_exceeds_en() {
698            let limite = crate::constants::MAX_MEMORY_BODY_LEN;
699            let msg = match Language::English {
700                Language::English => format!("body exceeds {limite} bytes"),
701                Language::Portuguese => format!("corpo excede {limite} bytes"),
702            };
703            assert!(msg.contains("body exceeds 512000"), "obtido: {msg}");
704        }
705
706        #[test]
707        fn body_exceeds_pt() {
708            let limite = crate::constants::MAX_MEMORY_BODY_LEN;
709            let msg = match Language::Portuguese {
710                Language::English => format!("body exceeds {limite} bytes"),
711                Language::Portuguese => format!("corpo excede {limite} bytes"),
712            };
713            assert!(msg.contains("corpo excede 512000"), "obtido: {msg}");
714        }
715
716        #[test]
717        fn new_name_length_en() {
718            let msg = match Language::English {
719                Language::English => format!("new-name must be 1-{} chars", 80),
720                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
721            };
722            assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
723        }
724
725        #[test]
726        fn new_name_length_pt() {
727            let msg = match Language::Portuguese {
728                Language::English => format!("new-name must be 1-{} chars", 80),
729                Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
730            };
731            assert!(
732                msg.contains("novo nome deve ter entre 1 e 80"),
733                "obtido: {msg}"
734            );
735        }
736
737        #[test]
738        fn new_name_kebab_en() {
739            let nome = "Bad Name";
740            let msg = match Language::English {
741                Language::English => format!(
742                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
743                ),
744                Language::Portuguese => format!(
745                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
746                ),
747            };
748            assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
749        }
750
751        #[test]
752        fn new_name_kebab_pt() {
753            let nome = "Bad Name";
754            let msg = match Language::Portuguese {
755                Language::English => format!(
756                    "new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
757                ),
758                Language::Portuguese => format!(
759                    "novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
760                ),
761            };
762            assert!(
763                msg.contains("novo nome deve estar em kebab-case"),
764                "obtido: {msg}"
765            );
766        }
767
768        #[test]
769        fn reserved_name_en() {
770            let msg = match Language::English {
771                Language::English => {
772                    "names and namespaces starting with __ are reserved for internal use"
773                        .to_string()
774                }
775                Language::Portuguese => {
776                    "nomes e namespaces iniciados com __ são reservados para uso interno"
777                        .to_string()
778                }
779            };
780            assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
781        }
782
783        #[test]
784        fn reserved_name_pt() {
785            let msg = match Language::Portuguese {
786                Language::English => {
787                    "names and namespaces starting with __ are reserved for internal use"
788                        .to_string()
789                }
790                Language::Portuguese => {
791                    "nomes e namespaces iniciados com __ são reservados para uso interno"
792                        .to_string()
793                }
794            };
795            assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
796        }
797    }
798}