Skip to main content

sqlite_graphrag/
i18n.rs

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