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;