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(nome: &str, namespace: &str) -> String {
172        format!("memory '{nome}' 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(nome: &str, namespace: &str) -> String {
187        format!("entity \"{nome}\" 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(nome: &str, namespace: &str) -> String {
199        format!(
200            "memory '{nome}' 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(versao: i64, nome: &str) -> String {
221        format!("version {versao} not found for memory '{nome}'")
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(nome: &str, namespace: &str) -> String {
233        format!("soft-deleted memory '{nome}' 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/// Localized validation messages for memory fields.
253pub mod validation;
254
255#[cfg(test)]
256mod tests;