Skip to main content

rustauth_i18n/
types.rs

1//! Options for the RustAuth i18n plugin (parity with `@better-auth/i18n`).
2
3use indexmap::IndexMap;
4use rustauth_core::api::ApiRequest;
5use rustauth_core::context::AuthContext;
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11
12/// Sync resolver for locale from the incoming request (callback / session hooks).
13pub type LocaleResolver = Arc<dyn Fn(&AuthContext, &ApiRequest) -> Option<String> + Send + Sync>;
14
15/// Async resolver for locale from the incoming request (callback strategy on async router paths).
16pub type AsyncLocaleResolver = Arc<
17    dyn for<'a> Fn(
18            &'a AuthContext,
19            &'a ApiRequest,
20        ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>>
21        + Send
22        + Sync,
23>;
24
25/// Locale detection strategy order (checked in sequence until one yields a locale).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum LocaleDetectionStrategy {
29    Header,
30    Cookie,
31    Session,
32    Callback,
33}
34
35/// Translation map: error code → localized message.
36pub type TranslationDictionary = IndexMap<String, String>;
37
38/// Value that can be used as an i18n translation error-code key.
39pub trait TranslationKey {
40    fn into_translation_key(self) -> String;
41}
42
43impl TranslationKey for String {
44    fn into_translation_key(self) -> String {
45        self
46    }
47}
48
49impl TranslationKey for &str {
50    fn into_translation_key(self) -> String {
51        self.to_owned()
52    }
53}
54
55impl TranslationKey for &String {
56    fn into_translation_key(self) -> String {
57        self.clone()
58    }
59}
60
61mod error_code_translation {
62    use super::TranslationKey;
63    use rustauth_core::api::ApiErrorCode;
64    use rustauth_core::auth::email_password::AuthFlowErrorCode;
65    use rustauth_core::error_codes::ErrorCode;
66    use rustauth_core::plugin::PluginErrorCode;
67
68    pub trait Sealed {}
69
70    impl Sealed for ApiErrorCode {}
71    impl Sealed for AuthFlowErrorCode {}
72    impl Sealed for PluginErrorCode {}
73
74    impl<T> TranslationKey for T
75    where
76        T: ErrorCode + Sealed,
77    {
78        fn into_translation_key(self) -> String {
79            self.as_str().to_owned()
80        }
81    }
82}
83
84/// Build a translation dictionary from string keys or typed RustAuth error-code enums.
85///
86/// # Examples
87///
88/// ```rust
89/// use rustauth_i18n::translation_dictionary;
90///
91/// let dictionary = translation_dictionary([("INVALID_EMAIL", "Invalid email")]);
92///
93/// assert_eq!(
94///     dictionary.get("INVALID_EMAIL").map(String::as_str),
95///     Some("Invalid email")
96/// );
97/// ```
98pub fn translation_dictionary<K, V, I>(entries: I) -> TranslationDictionary
99where
100    K: TranslationKey,
101    V: Into<String>,
102    I: IntoIterator<Item = (K, V)>,
103{
104    entries
105        .into_iter()
106        .map(|(key, value)| (key.into_translation_key(), value.into()))
107        .collect()
108}
109
110/// Options for [`crate::i18n`].
111#[non_exhaustive]
112#[derive(Clone)]
113pub struct I18nOptions {
114    /// Translation dictionaries keyed by locale code (insertion order matters when picking a fallback locale).
115    pub translations: IndexMap<String, TranslationDictionary>,
116    /// Default locale when detection fails. Must exist in `translations` when set.
117    pub default_locale: Option<String>,
118    /// Strategies tried in order. Defaults to `[Header]`.
119    pub detection: Vec<LocaleDetectionStrategy>,
120    /// Cookie name when using [`LocaleDetectionStrategy::Cookie`]. Default: `"locale"`.
121    pub locale_cookie: String,
122    /// Session user field name for applications that map custom user locale fields. Default: `"locale"`.
123    pub user_locale_field: String,
124    /// Used when [`LocaleDetectionStrategy::Callback`] is enabled on synchronous router paths.
125    pub get_locale: Option<LocaleResolver>,
126    /// Used when [`LocaleDetectionStrategy::Callback`] is enabled on [`AuthRouter::handle_async`](rustauth_core::api::AuthRouter::handle_async).
127    pub get_locale_async: Option<AsyncLocaleResolver>,
128    /// Used when [`LocaleDetectionStrategy::Session`] is enabled — return the user’s stored locale (e.g. after loading session).
129    pub resolve_user_locale: Option<LocaleResolver>,
130}
131
132impl Default for I18nOptions {
133    fn default() -> Self {
134        Self {
135            translations: IndexMap::new(),
136            default_locale: None,
137            detection: vec![LocaleDetectionStrategy::Header],
138            locale_cookie: "locale".to_owned(),
139            user_locale_field: "locale".to_owned(),
140            get_locale: None,
141            get_locale_async: None,
142            resolve_user_locale: None,
143        }
144    }
145}
146
147impl I18nOptions {
148    /// Build options with defaults matching Better Auth; add locales via [`.locale`](Self::locale).
149    ///
150    /// # Examples
151    ///
152    /// ```rust
153    /// use rustauth_i18n::{
154    ///     translation_dictionary, I18nOptions, LocaleDetectionStrategy,
155    /// };
156    ///
157    /// let options = I18nOptions::new()
158    ///     .locale("fr", [("INVALID_EMAIL", "Email invalide")])
159    ///     .default_locale("fr")
160    ///     .detection([LocaleDetectionStrategy::Cookie, LocaleDetectionStrategy::Header])
161    ///     .locale_cookie("lang");
162    ///
163    /// assert_eq!(options.default_locale.as_deref(), Some("fr"));
164    /// ```
165    pub fn new() -> Self {
166        Self::default()
167    }
168
169    /// Add or replace a locale dictionary.
170    pub fn locale<K, V, I>(mut self, code: impl Into<String>, entries: I) -> Self
171    where
172        K: TranslationKey,
173        V: Into<String>,
174        I: IntoIterator<Item = (K, V)>,
175    {
176        self.translations
177            .insert(code.into(), translation_dictionary(entries));
178        self
179    }
180
181    /// Build options from a pre-built locale map (used by migrations and tests).
182    pub fn from_translations(translations: IndexMap<String, TranslationDictionary>) -> Self {
183        Self {
184            translations,
185            ..Self::default()
186        }
187    }
188
189    /// Set the default/fallback locale.
190    pub fn default_locale(mut self, locale: impl Into<String>) -> Self {
191        self.default_locale = Some(locale.into());
192        self
193    }
194
195    /// Set locale detection strategies in priority order.
196    pub fn detection<I>(mut self, detection: I) -> Self
197    where
198        I: IntoIterator<Item = LocaleDetectionStrategy>,
199    {
200        self.detection = detection.into_iter().collect();
201        self
202    }
203
204    /// Set the cookie name used by [`LocaleDetectionStrategy::Cookie`].
205    pub fn locale_cookie(mut self, name: impl Into<String>) -> Self {
206        self.locale_cookie = name.into();
207        self
208    }
209
210    /// Set the user field read by [`LocaleDetectionStrategy::Session`].
211    pub fn user_locale_field(mut self, field: impl Into<String>) -> Self {
212        self.user_locale_field = field.into();
213        self
214    }
215
216    /// Set the synchronous callback resolver used by [`LocaleDetectionStrategy::Callback`].
217    pub fn get_locale(mut self, resolver: LocaleResolver) -> Self {
218        self.get_locale = Some(resolver);
219        self
220    }
221
222    /// Set the async callback resolver used by [`LocaleDetectionStrategy::Callback`].
223    pub fn get_locale_async(mut self, resolver: AsyncLocaleResolver) -> Self {
224        self.get_locale_async = Some(resolver);
225        self
226    }
227
228    /// Set the session locale resolver used by [`LocaleDetectionStrategy::Session`].
229    pub fn resolve_user_locale(mut self, resolver: LocaleResolver) -> Self {
230        self.resolve_user_locale = Some(resolver);
231        self
232    }
233}
234
235impl fmt::Debug for I18nOptions {
236    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
237        formatter
238            .debug_struct("I18nOptions")
239            .field("translations", &self.translations)
240            .field("default_locale", &self.default_locale)
241            .field("detection", &self.detection)
242            .field("locale_cookie", &self.locale_cookie)
243            .field("user_locale_field", &self.user_locale_field)
244            .field(
245                "get_locale",
246                &self.get_locale.as_ref().map(|_| "<locale-resolver>"),
247            )
248            .field(
249                "get_locale_async",
250                &self
251                    .get_locale_async
252                    .as_ref()
253                    .map(|_| "<async-locale-resolver>"),
254            )
255            .field(
256                "resolve_user_locale",
257                &self
258                    .resolve_user_locale
259                    .as_ref()
260                    .map(|_| "<locale-resolver>"),
261            )
262            .finish()
263    }
264}