Skip to main content

sqlite_graphrag/i18n/
mod.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/// Language.
14#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
15pub enum Language {
16    /// English variant.
17    #[value(name = "en", aliases = ["english", "EN"])]
18    English,
19    /// Portuguese variant.
20    #[value(name = "pt", aliases = ["portugues", "portuguese", "pt-BR", "pt-br", "PT"])]
21    Portuguese,
22}
23
24impl Language {
25    /// Parses a command-line string into a `Language` without relying on clap.
26    /// Accepts the same aliases defined in `#[value(...)]`: "en", "pt", etc.
27    pub fn from_str_opt(s: &str) -> Option<Self> {
28        match s.to_lowercase().as_str() {
29            "en" | "english" => Some(Language::English),
30            "pt" | "pt-br" | "portugues" | "portuguese" => Some(Language::Portuguese),
31            _ => None,
32        }
33    }
34
35    /// Detect language from environment variables or system locale.
36    pub fn from_env_or_locale() -> Self {
37        // Priority 1: XDG setting `i18n.lang` (no product env — G-T-XDG-04).
38        if let Ok(Some(v)) = crate::config::get_setting("i18n.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                tracing::warn!(target: "i18n",
48                    value = %v,
49                    "i18n.lang setting not recognized, falling back to OS locale"
50                );
51            }
52        }
53        // Priority 2: POSIX OS locale LC_ALL > LC_MESSAGES > LANG (allowed system env).
54        // We read these via std::env (not via sys_locale) because:
55        // (a) `sys_locale::get_locale()` calls into native OS APIs (CFLocaleCopyCurrent
56        //     on macOS, GetUserDefaultLocaleName on Windows) which cache the
57        //     system locale and IGNORE env vars set at runtime by tests;
58        // (b) POSIX specifies LC_ALL > LC_MESSAGES > LANG ordering and an
59        //     unrecognised LC_ALL value must stop iteration (fall back to
60        //     English default).
61        for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
62            if let Ok(v) = std::env::var(var) {
63                if v.is_empty() {
64                    continue;
65                }
66                let lower = v.to_lowercase();
67                if lower.starts_with("pt") {
68                    return Language::Portuguese;
69                }
70                if lower.starts_with("en") {
71                    return Language::English;
72                }
73                // Unrecognised value in a higher-precedence variable stops
74                // iteration per POSIX.1-2017 §8.2.
75                if var == "LC_ALL" {
76                    return Language::English;
77                }
78            }
79        }
80        // Priority 3: cross-platform locale detection via native OS APIs.
81        // Only reached when no POSIX env var is set.
82        if let Some(locale) = sys_locale::get_locale() {
83            let lower = locale.to_lowercase();
84            if lower.starts_with("pt") {
85                return Language::Portuguese;
86            }
87            if lower.starts_with("en") {
88                return Language::English;
89            }
90        }
91        Language::English
92    }
93}
94
95static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
96
97/// Initializes the global language. Subsequent calls are silently ignored
98/// (OnceLock semantics) — guaranteeing thread-safety and determinism.
99///
100/// v1.0.36 (L6): early-return when already initialized so the env-fallback
101/// resolver (`from_env_or_locale`) does not run a second time. Without this
102/// guard, calling `init(None)` after `current()` already populated the
103/// OnceLock causes `from_env_or_locale` to fire its `tracing::warn!` twice
104/// for unrecognized `i18n.lang` XDG values.
105pub fn init(explicit: Option<Language>) {
106    if GLOBAL_LANGUAGE.get().is_some() {
107        return;
108    }
109    let resolved = explicit.unwrap_or_else(Language::from_env_or_locale);
110    let _ = GLOBAL_LANGUAGE.set(resolved);
111}
112
113/// Returns the active language, or fallback English if `init` was never called.
114pub fn current() -> Language {
115    *GLOBAL_LANGUAGE.get_or_init(Language::from_env_or_locale)
116}
117
118/// Translates a bilingual message by selecting the active variant.
119///
120/// v1.0.36 (M4): inputs are constrained to `&'static str` so the function
121/// can return one of them directly without `Box::leak`. The previous
122/// implementation leaked one allocation per call which accumulated in
123/// long-running pipelines; this version is allocation-free. All in-tree
124/// callers already pass string literals, which are `&'static str`.
125pub fn tr(en: &'static str, pt: &'static str) -> &'static str {
126    match current() {
127        Language::English => en,
128        Language::Portuguese => pt,
129    }
130}
131
132/// Progress message emitted after pruning relationships.
133///
134/// English-only: this string is emitted to stderr as a progress notice and
135/// does not vary by language because the prune-relations command targets
136/// agent-first pipelines where deterministic output matters.
137pub fn relations_pruned(count: usize, relation: &str, namespace: &str) -> String {
138    format!("pruned {count} '{relation}' relationships in namespace '{namespace}'")
139}
140
141/// Progress message for dry-run preview of prune-relations.
142///
143/// English-only: emitted to stderr as a progress notice.
144pub fn prune_dry_run(count: usize, relation: &str) -> String {
145    format!("dry run: {count} '{relation}' relationships would be removed")
146}
147
148/// Warning message when --yes is not passed for destructive prune-relations.
149///
150/// English-only: emitted to stderr as a progress notice.
151pub fn prune_requires_yes() -> String {
152    "destructive operation requires --yes flag; use --dry-run to preview".to_string()
153}
154
155/// Localized prefix for error messages displayed to the end user.
156pub fn error_prefix() -> &'static str {
157    match current() {
158        Language::English => "Error",
159        Language::Portuguese => "Erro",
160    }
161}
162
163/// Error messages for `AppError` variants — always English.
164///
165/// These strings end up inside `AppError` inner fields and may appear in
166/// deterministic JSON stdout (e.g. ingest NDJSON). Portuguese translations
167/// for stderr live in `pub mod app_error_pt` and are applied by
168/// `localized_message_for(Language::Portuguese)`.
169pub mod errors_msg {
170    /// Localized message for `memory_not_found`.
171    pub fn memory_not_found(name: &str, namespace: &str) -> String {
172        format!("memory '{name}' not found in namespace '{namespace}'")
173    }
174
175    /// Localized message for `memory_or_entity_not_found`.
176    pub fn memory_or_entity_not_found(name: &str, namespace: &str) -> String {
177        format!("memory or entity '{name}' not found in namespace '{namespace}'")
178    }
179
180    /// Localized message for `database_not_found`.
181    pub fn database_not_found(path: &str) -> String {
182        format!("database not found at {path}. Run 'sqlite-graphrag init' first.")
183    }
184
185    /// Localized message for `entity_not_found`.
186    pub fn entity_not_found(name: &str, namespace: &str) -> String {
187        format!("entity \"{name}\" does not exist in namespace \"{namespace}\"")
188    }
189
190    /// Localized message for `relationship_not_found`.
191    pub fn relationship_not_found(de: &str, rel: &str, para: &str, namespace: &str) -> String {
192        format!(
193            "relationship \"{de}\" --[{rel}]--> \"{para}\" does not exist in namespace \"{namespace}\""
194        )
195    }
196
197    /// Localized message for `duplicate_memory`.
198    pub fn duplicate_memory(name: &str, namespace: &str) -> String {
199        format!(
200            "memory '{name}' already exists in namespace '{namespace}'. Use --force-merge to update."
201        )
202    }
203
204    /// Localized message for `duplicate_memory_soft_deleted`.
205    pub fn duplicate_memory_soft_deleted(name: &str, namespace: &str) -> String {
206        format!(
207            "memory '{name}' exists but is soft-deleted in namespace '{namespace}'; \
208             use --force-merge to restore and update, or `restore` to revive it"
209        )
210    }
211
212    /// Localized message for `optimistic_lock_conflict`.
213    pub fn optimistic_lock_conflict(expected: i64, current_ts: i64) -> String {
214        format!(
215            "optimistic lock conflict: expected updated_at={expected}, but current is {current_ts}"
216        )
217    }
218
219    /// Localized message for `version_not_found`.
220    pub fn version_not_found(version: i64, name: &str) -> String {
221        format!("version {version} not found for memory '{name}'")
222    }
223
224    /// Localized message for `no_recall_results`.
225    pub fn no_recall_results(max_distance: f32, query: &str, namespace: &str) -> String {
226        format!(
227            "no results within --max-distance {max_distance} for query '{query}' in namespace '{namespace}'"
228        )
229    }
230
231    /// Localized message for `soft_deleted_memory_not_found`.
232    pub fn soft_deleted_memory_not_found(name: &str, namespace: &str) -> String {
233        format!("soft-deleted memory '{name}' not found in namespace '{namespace}'")
234    }
235
236    /// Localized message for `concurrent_process_conflict`.
237    pub fn concurrent_process_conflict() -> String {
238        "optimistic lock conflict: memory was modified by another process".to_string()
239    }
240
241    /// Localized message for `entity_limit_exceeded`.
242    pub fn entity_limit_exceeded(max: usize) -> String {
243        format!("entities exceed limit of {max}")
244    }
245
246    /// Localized message for `relationship_limit_exceeded`.
247    pub fn relationship_limit_exceeded(max: usize) -> String {
248        format!("relationships exceed limit of {max}")
249    }
250}
251
252/// GAP-SG-143: operational `AppError` payloads that are bilingual AT
253/// CONSTRUCTION.
254///
255/// # Why not `errors_msg`
256///
257/// `errors_msg` is English-only because its variants (`NotFound`,
258/// `Duplicate`, `Conflict`) are translated at DISPLAY time by the
259/// replace-chains in [`validation::app_error_pt`]. The variants below —
260/// `DbBusy`, `LockBusy`, `NamespaceError`, `Embedding` — reach the operator
261/// through PT helpers that only PREFIX their argument, so an English-only
262/// payload would ship a bilingual hybrid: `banco ocupado: SQLITE_BUSY after 5
263/// retries`.
264///
265/// These builders therefore resolve [`current`] the way
266/// `validation::messages_naming` does, and own both wordings in one place.
267pub mod errors_ops {
268    use super::{current, Language};
269
270    /// SQLite stayed busy after the configured retry budget.
271    pub fn sqlite_busy_after_retries(max_retries: u32) -> String {
272        match current() {
273            Language::English => format!("SQLITE_BUSY after {max_retries} retries"),
274            Language::Portuguese => format!("SQLITE_BUSY após {max_retries} tentativas"),
275        }
276    }
277
278    /// No LLM concurrency slot came free inside the wait window.
279    pub fn llm_slot_acquire_timeout(wait_secs: u64, max_concurrent: u32) -> String {
280        match current() {
281            Language::English => format!(
282                "failed to acquire LLM slot within {wait_secs}s (max={max_concurrent} concurrent)"
283            ),
284            Language::Portuguese => format!(
285                "não foi possível obter um slot de LLM em {wait_secs}s (máx={max_concurrent} concorrentes)"
286            ),
287        }
288    }
289
290    /// A memory row changed between the read and the write of the same
291    /// transaction, so the update matched zero rows.
292    pub fn memory_modified_concurrently(name: &str) -> String {
293        match current() {
294            Language::English => format!("memory '{name}' was modified concurrently; retry"),
295            Language::Portuguese => {
296                format!("memória '{name}' foi modificada concorrentemente; tente novamente")
297            }
298        }
299    }
300
301    /// A rename target is already taken by another ACTIVE memory.
302    pub fn rename_target_occupied(new_name: &str, memory_id: i64) -> String {
303        match current() {
304            Language::English => format!(
305                "target name '{new_name}' is already occupied by active memory id {memory_id}"
306            ),
307            Language::Portuguese => format!(
308                "o nome de destino '{new_name}' já está ocupado pela memória ativa id {memory_id}"
309            ),
310        }
311    }
312
313    /// Creating one more namespace would cross `MAX_NAMESPACES_ACTIVE`.
314    ///
315    /// Shared by `remember` and `ingest`, which used to phrase the same ceiling
316    /// two different ways.
317    pub fn active_namespace_limit_reached(max: u32, namespace: &str) -> String {
318        match current() {
319            Language::English => format!(
320                "active namespace limit of {max} reached while trying to create '{namespace}'"
321            ),
322            Language::Portuguese => {
323                format!("limite de {max} namespaces ativos atingido ao tentar criar '{namespace}'")
324            }
325        }
326    }
327
328    /// `--name-prefix` alone consumes the whole name budget.
329    pub fn name_prefix_exceeds_name_cap(prefix_len: usize, cap: usize) -> String {
330        match current() {
331            Language::English => format!(
332                "--name-prefix is {prefix_len} chars; prefixed names would exceed the \
333                 {cap}-char name cap (MAX_MEMORY_NAME_LEN)"
334            ),
335            Language::Portuguese => format!(
336                "--name-prefix tem {prefix_len} caracteres; nomes prefixados excederiam o \
337                 teto de {cap} caracteres (MAX_MEMORY_NAME_LEN)"
338            ),
339        }
340    }
341
342    /// `try_reserve` refused the ingest plan allocation.
343    ///
344    /// `what` names the collection and comes from the label builders below, so
345    /// the whole sentence stays in one language.
346    pub fn allocation_would_exceed_memory(capacity: usize, what: &str) -> String {
347        match current() {
348            Language::English => {
349                format!("allocation of {capacity} {what} would exceed available memory")
350            }
351            Language::Portuguese => {
352                format!("a alocação de {capacity} {what} excederia a memória disponível")
353            }
354        }
355    }
356
357    /// Allocation label for the ingest slot-metadata vector.
358    pub fn alloc_label_slot_metadata() -> &'static str {
359        super::tr("slot metadata entries", "entradas de metadados de slot")
360    }
361
362    /// Allocation label for the ingest process-item vector.
363    pub fn alloc_label_process_items() -> &'static str {
364        super::tr("process items", "itens de processamento")
365    }
366
367    /// Allocation label for the ingest truncation vector.
368    pub fn alloc_label_truncation_entries() -> &'static str {
369        super::tr("truncation entries", "entradas de truncamento")
370    }
371
372    /// The same body is already stored under a different name (dedup by
373    /// `body_hash`), so ingest skips the file instead of duplicating it.
374    pub fn duplicate_body_hash(hash_id: i64, name: &str) -> String {
375        match current() {
376            Language::English => format!(
377                "identical body already stored as memory id {hash_id} \
378                 (dedup by body_hash); skipping '{name}'"
379            ),
380            Language::Portuguese => format!(
381                "corpo idêntico já armazenado como memória id {hash_id} \
382                 (dedup por body_hash); ignorando '{name}'"
383            ),
384        }
385    }
386
387    /// The embedding backend returned a vector count that does not match the
388    /// batch it was given, so no pairing is safe.
389    pub fn batch_embedding_count_mismatch(vectors: usize, texts: usize) -> String {
390        match current() {
391            Language::English => {
392                format!("batch embedding returned {vectors} vectors for {texts} texts")
393            }
394            Language::Portuguese => {
395                format!("o embedding em lote retornou {vectors} vetores para {texts} textos")
396            }
397        }
398    }
399}
400
401/// Localized validation messages for memory fields.
402pub mod validation;
403
404#[cfg(test)]
405mod tests;