reinhardt_i18n/lib.rs
1//! Internationalization (i18n) support for Reinhardt
2//!
3//! This crate provides Django-style internationalization features including:
4//! - Message translation with gettext-style API
5//! - Plural forms support
6//! - Context-aware translations
7//! - Lazy translation evaluation
8//! - Message catalog management
9//!
10//! # Example
11//!
12//! ```
13//! use reinhardt_i18n::{TranslationContext, set_active_translation, gettext, MessageCatalog};
14//! use std::sync::Arc;
15//!
16//! // Create a translation context with Japanese catalog
17//! let mut ctx = TranslationContext::new("ja", "en-US");
18//! let mut catalog = MessageCatalog::new("ja");
19//! catalog.add_translation("Hello", "こんにちは");
20//! ctx.add_catalog("ja", catalog).unwrap();
21//!
22//! // Set as active translation context (scoped)
23//! let _guard = set_active_translation(Arc::new(ctx));
24//!
25//! // Translate messages
26//! let greeting = gettext("Hello");
27//! assert_eq!(greeting, "こんにちは");
28//! // Guard is dropped here, restoring previous context
29//! ```
30//!
31//! # DI Integration
32//!
33//! When the `di` feature is enabled, `TranslationContext` implements `Injectable`:
34//!
35//! ```ignore
36//! use reinhardt_di::{InjectionContext, SingletonScope, Injectable};
37//! use reinhardt_i18n::TranslationContext;
38//!
39//! async fn handler(ctx: &InjectionContext) {
40//! let translation = TranslationContext::inject(ctx).await.unwrap();
41//! // Use translation...
42//! }
43//! ```
44
45use std::cell::RefCell;
46use std::collections::HashMap;
47use std::sync::Arc;
48
49use reinhardt_utils::safe_path_join;
50
51/// Error types for i18n operations
52#[derive(Debug, thiserror::Error)]
53pub enum I18nError {
54 #[error("Invalid locale format: {0}")]
55 InvalidLocale(String),
56 #[error("Catalog not found for locale: {0}")]
57 CatalogNotFound(String),
58 #[error("Failed to load catalog: {0}")]
59 LoadError(String),
60 #[error("Path traversal detected: {0}")]
61 PathTraversal(String),
62}
63
64mod catalog;
65mod lazy;
66mod locale;
67pub mod po_parser;
68mod translation;
69pub mod utils;
70
71pub use catalog::MessageCatalog;
72pub use lazy::LazyString;
73use locale::validate_locale;
74pub use locale::{activate, activate_with_catalog, deactivate, get_locale};
75pub use translation::{gettext, gettext_lazy, ngettext, ngettext_lazy, npgettext, pgettext};
76
77// Re-export get_locale as get_language for compatibility
78pub use locale::get_locale as get_language;
79
80// New scoped translation API
81// TranslationContext, TranslationGuard, set_active_translation, get_active_translation
82// are defined below and exported at module level
83
84/// Catalog loader for loading message catalogs from files or other sources
85pub struct CatalogLoader {
86 base_path: std::path::PathBuf,
87}
88
89impl CatalogLoader {
90 /// Create a new catalog loader with the given base path
91 ///
92 /// # Example
93 /// ```
94 /// use reinhardt_i18n::CatalogLoader;
95 ///
96 /// let loader = CatalogLoader::new("locale");
97 /// ```
98 pub fn new<P: Into<std::path::PathBuf>>(base_path: P) -> Self {
99 Self {
100 base_path: base_path.into(),
101 }
102 }
103
104 /// Load a catalog for the given locale from a .po file
105 ///
106 /// This method looks for .po files in the following locations:
107 /// - `{base_path}/{locale}/LC_MESSAGES/django.po`
108 /// - `{base_path}/{locale}/LC_MESSAGES/messages.po`
109 ///
110 /// # Errors
111 ///
112 /// Returns `I18nError::CatalogNotFound` if no .po file is found for the locale.
113 /// Returns `I18nError::LoadError` if the file cannot be opened or parsed.
114 ///
115 /// # Example
116 /// ```no_run
117 /// use reinhardt_i18n::CatalogLoader;
118 ///
119 /// let loader = CatalogLoader::new("locale");
120 /// let catalog = loader.load("fr").unwrap();
121 /// ```
122 pub fn load(&self, locale: &str) -> Result<MessageCatalog, I18nError> {
123 // Validate locale name to prevent path traversal attacks.
124 // Locale names should only contain alphanumeric characters, hyphens, and underscores.
125 if locale.is_empty()
126 || !locale
127 .chars()
128 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
129 {
130 return Err(I18nError::InvalidLocale(locale.to_string()));
131 }
132
133 // Defense in depth: use safe_path_join to verify the locale path stays
134 // within the base directory, even after the character validation above.
135 let safe_locale_dir = safe_path_join(&self.base_path, locale).map_err(|e| {
136 I18nError::PathTraversal(format!(
137 "Locale '{}' failed path safety check: {}",
138 locale, e
139 ))
140 })?;
141
142 // Try multiple common .po file locations
143 let possible_paths = vec![
144 safe_locale_dir.join("LC_MESSAGES").join("django.po"),
145 safe_locale_dir.join("LC_MESSAGES").join("messages.po"),
146 ];
147
148 for path in possible_paths {
149 if path.exists() {
150 let file = std::fs::File::open(&path).map_err(|e| {
151 I18nError::LoadError(format!("Failed to open .po file at {:?}: {}", path, e))
152 })?;
153
154 return po_parser::parse_po_file(file, locale)
155 .map_err(|e| I18nError::LoadError(format!("Failed to parse .po file: {}", e)));
156 }
157 }
158
159 // If no .po file found, return an error
160 Err(I18nError::CatalogNotFound(locale.to_string()))
161 }
162
163 /// Load a catalog for the given locale, returning an empty catalog if not found
164 ///
165 /// This is a convenience method that falls back to an empty catalog when
166 /// no .po file is found. Use `load()` instead if you want to handle
167 /// missing catalogs explicitly.
168 ///
169 /// # Example
170 /// ```
171 /// use reinhardt_i18n::CatalogLoader;
172 ///
173 /// let loader = CatalogLoader::new("locale");
174 /// // Returns empty catalog if no .po file exists
175 /// let catalog = loader.load_or_empty("fr");
176 /// ```
177 pub fn load_or_empty(&self, locale: &str) -> MessageCatalog {
178 self.load(locale)
179 .unwrap_or_else(|_| MessageCatalog::new(locale))
180 }
181
182 /// Load a catalog from a specific .po file path
183 ///
184 /// # Example
185 /// ```no_run
186 /// use reinhardt_i18n::CatalogLoader;
187 ///
188 /// let loader = CatalogLoader::new("locale");
189 /// let catalog = loader.load_from_file("locale/fr/custom.po", "fr").unwrap();
190 /// ```
191 pub fn load_from_file<P: AsRef<std::path::Path>>(
192 &self,
193 path: P,
194 locale: &str,
195 ) -> Result<MessageCatalog, String> {
196 // Validate the file path stays within the base directory
197 let path_str = path
198 .as_ref()
199 .to_str()
200 .ok_or_else(|| "Invalid path encoding".to_string())?;
201 let safe_path = safe_path_join(&self.base_path, path_str).map_err(|e| e.to_string())?;
202
203 let file = std::fs::File::open(&safe_path)
204 .map_err(|e| format!("Failed to open .po file: {}", e))?;
205
206 po_parser::parse_po_file(file, locale)
207 .map_err(|e| format!("Failed to parse .po file: {}", e))
208 }
209}
210
211// Thread-local storage for the active translation context
212thread_local! {
213 static ACTIVE_TRANSLATION: RefCell<Option<Arc<TranslationContext>>> = const { RefCell::new(None) };
214}
215
216/// Translation context containing catalogs and locale settings.
217///
218/// This struct holds all the translation state including:
219/// - Message catalogs indexed by locale
220/// - Current active locale
221/// - Fallback locale for missing translations
222///
223/// # Usage
224///
225/// ```
226/// use reinhardt_i18n::{TranslationContext, set_active_translation, gettext, MessageCatalog};
227/// use std::sync::Arc;
228///
229/// let mut ctx = TranslationContext::new("ja", "en-US");
230/// let mut catalog = MessageCatalog::new("ja");
231/// catalog.add_translation("Hello", "こんにちは");
232/// ctx.add_catalog("ja", catalog).unwrap();
233///
234/// let _guard = set_active_translation(Arc::new(ctx));
235/// assert_eq!(gettext("Hello"), "こんにちは");
236/// ```
237#[derive(Clone, Default)]
238pub struct TranslationContext {
239 current_locale: String,
240 fallback_locale: String,
241 catalogs: HashMap<String, MessageCatalog>,
242}
243
244impl TranslationContext {
245 /// Creates a new translation context with the specified locales.
246 ///
247 /// # Arguments
248 ///
249 /// * `current_locale` - The current locale to use for translations
250 /// * `fallback_locale` - The fallback locale when translation is not found
251 pub fn new(current_locale: impl Into<String>, fallback_locale: impl Into<String>) -> Self {
252 Self {
253 current_locale: current_locale.into(),
254 fallback_locale: fallback_locale.into(),
255 catalogs: HashMap::new(),
256 }
257 }
258
259 /// Creates a new translation context with English (en-US) as default.
260 pub fn english() -> Self {
261 Self::new("en-US", "en-US")
262 }
263
264 /// Returns the current locale.
265 pub fn get_locale(&self) -> &str {
266 if self.current_locale.is_empty() {
267 "en-US"
268 } else {
269 &self.current_locale
270 }
271 }
272
273 /// Returns the fallback locale.
274 pub fn get_fallback_locale(&self) -> &str {
275 if self.fallback_locale.is_empty() {
276 "en-US"
277 } else {
278 &self.fallback_locale
279 }
280 }
281
282 /// Returns the catalog for the given locale.
283 pub fn get_catalog(&self, locale: &str) -> Option<&MessageCatalog> {
284 self.catalogs.get(locale)
285 }
286
287 /// Sets the current locale.
288 ///
289 /// # Errors
290 ///
291 /// Returns `I18nError::InvalidLocale` if the locale string format is invalid.
292 pub fn set_locale(&mut self, locale: impl Into<String>) -> Result<(), I18nError> {
293 let locale = locale.into();
294 // Allow empty string for deactivation (reset to default)
295 if !locale.is_empty() {
296 validate_locale(&locale)?;
297 }
298 self.current_locale = locale;
299 Ok(())
300 }
301
302 /// Sets the fallback locale.
303 ///
304 /// # Errors
305 ///
306 /// Returns `I18nError::InvalidLocale` if the locale string format is invalid.
307 pub fn set_fallback_locale(&mut self, locale: impl Into<String>) -> Result<(), I18nError> {
308 let locale = locale.into();
309 if !locale.is_empty() {
310 validate_locale(&locale)?;
311 }
312 self.fallback_locale = locale;
313 Ok(())
314 }
315
316 /// Adds a message catalog for the given locale.
317 ///
318 /// # Errors
319 ///
320 /// Returns `I18nError::InvalidLocale` if the locale string format is invalid.
321 pub fn add_catalog(
322 &mut self,
323 locale: impl Into<String>,
324 catalog: MessageCatalog,
325 ) -> Result<(), I18nError> {
326 let locale = locale.into();
327 validate_locale(&locale)?;
328 self.catalogs.insert(locale, catalog);
329 Ok(())
330 }
331
332 /// Translates a message using the current locale.
333 ///
334 /// Falls back to the fallback locale if translation is not found.
335 pub fn translate(&self, message: &str) -> String {
336 let locale = self.get_locale();
337
338 if let Some(translation) = self.get_catalog(locale).and_then(|c| c.get(message)) {
339 return translation.clone();
340 }
341
342 // Try fallback locale
343 let fallback = self.get_fallback_locale();
344 if locale != fallback
345 && let Some(translation) = self.get_catalog(fallback).and_then(|c| c.get(message))
346 {
347 return translation.clone();
348 }
349
350 // Return original message if no translation found
351 message.to_string()
352 }
353
354 /// Translates a message with plural support.
355 pub fn translate_plural(&self, singular: &str, plural: &str, count: usize) -> String {
356 let locale = self.get_locale();
357
358 if let Some(translation) = self
359 .get_catalog(locale)
360 .and_then(|c| c.get_plural(singular, count))
361 {
362 return translation.clone();
363 }
364
365 // Try fallback locale
366 let fallback = self.get_fallback_locale();
367 if locale != fallback
368 && let Some(translation) = self
369 .get_catalog(fallback)
370 .and_then(|c| c.get_plural(singular, count))
371 {
372 return translation.clone();
373 }
374
375 // Use default English plural rules
376 if count == 1 { singular } else { plural }.to_string()
377 }
378
379 /// Translates a message with context.
380 pub fn translate_context(&self, context: &str, message: &str) -> String {
381 let locale = self.get_locale();
382
383 if let Some(translation) = self
384 .get_catalog(locale)
385 .and_then(|c| c.get_context(context, message))
386 {
387 return translation.clone();
388 }
389
390 // Try fallback locale
391 let fallback = self.get_fallback_locale();
392 if locale != fallback
393 && let Some(translation) = self
394 .get_catalog(fallback)
395 .and_then(|c| c.get_context(context, message))
396 {
397 return translation.clone();
398 }
399
400 // Return original message if no translation found
401 message.to_string()
402 }
403
404 /// Translates a message with context and plural support.
405 pub fn translate_context_plural(
406 &self,
407 context: &str,
408 singular: &str,
409 plural: &str,
410 count: usize,
411 ) -> String {
412 let locale = self.get_locale();
413
414 if let Some(translation) = self
415 .get_catalog(locale)
416 .and_then(|c| c.get_context_plural(context, singular, count))
417 {
418 return translation.clone();
419 }
420
421 // Try fallback locale
422 let fallback = self.get_fallback_locale();
423 if locale != fallback
424 && let Some(translation) = self
425 .get_catalog(fallback)
426 .and_then(|c| c.get_context_plural(context, singular, count))
427 {
428 return translation.clone();
429 }
430
431 // Use default English plural rules
432 if count == 1 { singular } else { plural }.to_string()
433 }
434}
435
436/// RAII guard for active TranslationContext scope.
437///
438/// When dropped, restores the previous translation context.
439pub struct TranslationGuard {
440 prev: Option<Arc<TranslationContext>>,
441}
442
443impl Drop for TranslationGuard {
444 fn drop(&mut self) {
445 ACTIVE_TRANSLATION.with(|t| {
446 // Use try_borrow_mut to prevent panic on reentrant drop.
447 // If the RefCell is already borrowed (e.g., during a destructor chain),
448 // the translation will be cleaned up when the outer borrow is released.
449 if let Ok(mut guard) = t.try_borrow_mut() {
450 *guard = self.prev.take();
451 }
452 });
453 }
454}
455
456/// Sets the active translation context and returns a guard.
457///
458/// The guard restores the previous context when dropped.
459///
460/// # Example
461///
462/// ```
463/// use reinhardt_i18n::{TranslationContext, set_active_translation, gettext, MessageCatalog};
464/// use std::sync::Arc;
465///
466/// let mut ctx = TranslationContext::new("de", "en-US");
467/// let mut catalog = MessageCatalog::new("de");
468/// catalog.add_translation("Hello", "Hallo");
469/// ctx.add_catalog("de", catalog).unwrap();
470///
471/// {
472/// let _guard = set_active_translation(Arc::new(ctx));
473/// assert_eq!(gettext("Hello"), "Hallo");
474/// }
475/// // Context restored to previous (or None)
476/// assert_eq!(gettext("Hello"), "Hello");
477/// ```
478pub fn set_active_translation(ctx: Arc<TranslationContext>) -> TranslationGuard {
479 let prev = ACTIVE_TRANSLATION.with(|t| t.borrow_mut().replace(ctx));
480 TranslationGuard { prev }
481}
482
483/// Sets the active translation context permanently without returning a guard.
484///
485/// Unlike `set_active_translation()`, this function does not provide RAII semantics.
486/// The translation context remains active until explicitly changed or until the thread ends.
487/// Use this when you need permanent activation without scope-based cleanup.
488///
489/// # Memory Safety
490///
491/// This function is memory-safe and does not leak memory like `std::mem::forget` on the guard.
492/// The previous translation context (if any) is properly dropped.
493///
494/// # Example
495///
496/// ```
497/// use reinhardt_i18n::{TranslationContext, set_active_translation_permanent, gettext, MessageCatalog};
498/// use std::sync::Arc;
499///
500/// let mut ctx = TranslationContext::new("de", "en-US");
501/// let mut catalog = MessageCatalog::new("de");
502/// catalog.add_translation("Hello", "Hallo");
503/// ctx.add_catalog("de", catalog).unwrap();
504///
505/// set_active_translation_permanent(Arc::new(ctx));
506/// assert_eq!(gettext("Hello"), "Hallo");
507///
508/// // Context remains active (no guard to drop)
509/// assert_eq!(gettext("Hello"), "Hallo");
510/// ```
511pub fn set_active_translation_permanent(ctx: Arc<TranslationContext>) {
512 ACTIVE_TRANSLATION.with(|t| {
513 *t.borrow_mut() = Some(ctx);
514 });
515}
516
517/// Returns the currently active translation context, if any.
518pub fn get_active_translation() -> Option<Arc<TranslationContext>> {
519 ACTIVE_TRANSLATION.with(|t| t.borrow().clone())
520}
521
522// DI integration (feature-gated)
523#[cfg(feature = "di")]
524mod di_integration {
525 use super::*;
526 use reinhardt_di::{DiResult, Injectable, InjectionContext};
527
528 #[async_trait::async_trait]
529 impl Injectable for TranslationContext {
530 async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
531 // First check thread-local storage
532 if let Some(active) = get_active_translation() {
533 return Ok((*active).clone());
534 }
535
536 // Fall back to singleton scope
537 if let Some(singleton) = ctx.get_singleton::<TranslationContext>() {
538 return Ok((*singleton).clone());
539 }
540
541 // Default to empty English context
542 Ok(TranslationContext::english())
543 }
544 }
545}