Skip to main content

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}