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