Skip to main content

modo/template/
locale.rs

1use http::request::Parts;
2use std::sync::Arc;
3
4use super::config::TemplateConfig;
5
6/// Trait for extracting the active locale from a request.
7///
8/// Implementations are tried in order within the locale chain built by
9/// [`EngineBuilder::locale_resolvers`](super::EngineBuilder::locale_resolvers).
10/// The first resolver that returns `Some` wins; if all resolvers return `None`,
11/// [`TemplateConfig::default_locale`] is used.
12///
13/// The resolved locale is stored in the request's [`TemplateContext`](super::TemplateContext)
14/// under the key `"locale"` and is available in every template as `{{ locale }}`.
15pub trait LocaleResolver: Send + Sync {
16    /// Returns a locale string (e.g. `"en"`, `"uk"`) if this resolver can determine
17    /// the locale from the request, or `None` to fall through to the next resolver.
18    fn resolve(&self, parts: &Parts) -> Option<String>;
19}
20
21// --- QueryParamResolver ---
22
23/// Resolves the active locale from a URL query parameter.
24///
25/// When `available_locales` is non-empty, only values present in that list are
26/// accepted. Pass an empty slice to accept any value.
27pub struct QueryParamResolver {
28    param_name: String,
29    available_locales: Vec<String>,
30}
31
32impl QueryParamResolver {
33    /// Creates a new resolver that looks at `param_name` in the query string.
34    ///
35    /// `available_locales` constrains which values are accepted; pass `&[]` to accept
36    /// all values.
37    pub fn new(param_name: &str, available_locales: &[String]) -> Self {
38        Self {
39            param_name: param_name.to_string(),
40            available_locales: available_locales.to_vec(),
41        }
42    }
43}
44
45impl LocaleResolver for QueryParamResolver {
46    fn resolve(&self, parts: &Parts) -> Option<String> {
47        let query = parts.uri.query()?;
48        for pair in query.split('&') {
49            if let Some((key, value)) = pair.split_once('=')
50                && key == self.param_name
51                && (self.available_locales.is_empty()
52                    || self.available_locales.iter().any(|l| l == value))
53            {
54                return Some(value.to_string());
55            }
56        }
57        None
58    }
59}
60
61// --- CookieResolver ---
62
63/// Resolves the active locale from a cookie.
64///
65/// When `available_locales` is non-empty, only values present in that list are
66/// accepted.
67pub struct CookieResolver {
68    cookie_name: String,
69    available_locales: Vec<String>,
70}
71
72impl CookieResolver {
73    /// Creates a new resolver that reads `cookie_name`.
74    ///
75    /// `available_locales` constrains which values are accepted; pass `&[]` to accept
76    /// all values.
77    pub fn new(cookie_name: &str, available_locales: &[String]) -> Self {
78        Self {
79            cookie_name: cookie_name.to_string(),
80            available_locales: available_locales.to_vec(),
81        }
82    }
83}
84
85impl LocaleResolver for CookieResolver {
86    fn resolve(&self, parts: &Parts) -> Option<String> {
87        let cookie_header = parts.headers.get("cookie")?.to_str().ok()?;
88        for cookie in cookie_header.split(';') {
89            let cookie = cookie.trim();
90            if let Some((name, value)) = cookie.split_once('=')
91                && name.trim() == self.cookie_name
92            {
93                let value = value.trim();
94                if self.available_locales.is_empty()
95                    || self.available_locales.iter().any(|l| l == value)
96                {
97                    return Some(value.to_string());
98                }
99            }
100        }
101        None
102    }
103}
104
105// --- SessionResolver ---
106
107/// Resolves the active locale from the session data.
108///
109/// Reads the `"locale"` key from the session's JSON data. Requires
110/// [`SessionLayer`](crate::auth::session::SessionLayer) to be installed before this resolver
111/// runs in the middleware stack.
112pub struct SessionResolver;
113
114impl LocaleResolver for SessionResolver {
115    fn resolve(&self, parts: &Parts) -> Option<String> {
116        let state = parts
117            .extensions
118            .get::<Arc<crate::auth::session::SessionState>>()?;
119        let guard = state.current.lock().ok()?;
120        let session = guard.as_ref()?;
121        if let serde_json::Value::Object(ref map) = session.data
122            && let Some(serde_json::Value::String(locale)) = map.get("locale")
123        {
124            return Some(locale.clone());
125        }
126        None
127    }
128}
129
130// --- AcceptLanguageResolver ---
131
132/// Resolves the active locale from the `Accept-Language` HTTP header.
133///
134/// Parses quality values (`q=`), strips region subtags (`en-US` → `en`), and
135/// picks the highest-quality language that matches `available`.
136pub struct AcceptLanguageResolver {
137    available: Vec<String>,
138}
139
140impl AcceptLanguageResolver {
141    /// Creates a new resolver that accepts only locales in `available`.
142    pub fn new(available: &[&str]) -> Self {
143        Self {
144            available: available.iter().map(|s| s.to_string()).collect(),
145        }
146    }
147}
148
149impl LocaleResolver for AcceptLanguageResolver {
150    fn resolve(&self, parts: &Parts) -> Option<String> {
151        let header = parts.headers.get("accept-language")?.to_str().ok()?;
152
153        // Parse "en;q=0.9, uk;q=0.8" → sorted by quality
154        let mut langs: Vec<(String, f32)> = header
155            .split(',')
156            .map(|entry| {
157                let entry = entry.trim();
158                let (lang, quality) = if let Some((l, q)) = entry.split_once(";q=") {
159                    (l.trim().to_string(), q.trim().parse::<f32>().unwrap_or(0.0))
160                } else {
161                    (entry.to_string(), 1.0)
162                };
163                // Normalize: strip region tag ("en-US" → "en")
164                let lang = lang.split('-').next().unwrap_or(&lang).to_lowercase();
165                (lang, quality)
166            })
167            .collect();
168
169        // Sort by quality descending
170        langs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
171
172        // Find first match in available locales
173        for (lang, _) in &langs {
174            if self.available.iter().any(|a| a == lang) {
175                return Some(lang.clone());
176            }
177        }
178
179        None
180    }
181}
182
183// --- Chain helpers ---
184
185pub(crate) fn default_chain(
186    config: &TemplateConfig,
187    available_locales: &[String],
188) -> Vec<Arc<dyn LocaleResolver>> {
189    let mut chain: Vec<Arc<dyn LocaleResolver>> = vec![
190        Arc::new(QueryParamResolver::new(
191            &config.locale_query_param,
192            available_locales,
193        )),
194        Arc::new(CookieResolver::new(
195            &config.locale_cookie,
196            available_locales,
197        )),
198    ];
199    chain.push(Arc::new(SessionResolver));
200    chain.push(Arc::new(AcceptLanguageResolver::new(
201        &available_locales
202            .iter()
203            .map(|s| s.as_str())
204            .collect::<Vec<_>>(),
205    )));
206    chain
207}
208
209pub(crate) fn resolve_locale(chain: &[Arc<dyn LocaleResolver>], parts: &Parts) -> Option<String> {
210    chain.iter().find_map(|r| r.resolve(parts))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use http::Request;
217
218    fn parts_from_request(req: Request<()>) -> http::request::Parts {
219        req.into_parts().0
220    }
221
222    #[test]
223    fn query_param_resolver_extracts_lang() {
224        let resolver = QueryParamResolver::new("lang", &[]);
225        let req = Request::builder().uri("/?lang=uk").body(()).unwrap();
226        let parts = parts_from_request(req);
227        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
228    }
229
230    #[test]
231    fn query_param_resolver_returns_none_when_absent() {
232        let resolver = QueryParamResolver::new("lang", &[]);
233        let req = Request::builder().uri("/").body(()).unwrap();
234        let parts = parts_from_request(req);
235        assert_eq!(resolver.resolve(&parts), None);
236    }
237
238    #[test]
239    fn cookie_resolver_extracts_locale() {
240        let resolver = CookieResolver::new("lang", &[]);
241        let req = Request::builder()
242            .header("cookie", "lang=uk; other=value")
243            .body(())
244            .unwrap();
245        let parts = parts_from_request(req);
246        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
247    }
248
249    #[test]
250    fn cookie_resolver_returns_none_when_absent() {
251        let resolver = CookieResolver::new("lang", &[]);
252        let req = Request::builder().body(()).unwrap();
253        let parts = parts_from_request(req);
254        assert_eq!(resolver.resolve(&parts), None);
255    }
256
257    #[test]
258    fn accept_language_resolver_picks_best_match() {
259        let resolver = AcceptLanguageResolver::new(&["en", "uk", "fr"]);
260        let req = Request::builder()
261            .header("accept-language", "uk;q=0.9, en;q=0.8, fr;q=0.7")
262            .body(())
263            .unwrap();
264        let parts = parts_from_request(req);
265        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
266    }
267
268    #[test]
269    fn accept_language_resolver_ignores_unsupported() {
270        let resolver = AcceptLanguageResolver::new(&["en"]);
271        let req = Request::builder()
272            .header("accept-language", "de;q=0.9, en;q=0.8")
273            .body(())
274            .unwrap();
275        let parts = parts_from_request(req);
276        assert_eq!(resolver.resolve(&parts), Some("en".into()));
277    }
278
279    #[test]
280    fn accept_language_resolver_returns_none_for_no_match() {
281        let resolver = AcceptLanguageResolver::new(&["en"]);
282        let req = Request::builder()
283            .header("accept-language", "de, fr")
284            .body(())
285            .unwrap();
286        let parts = parts_from_request(req);
287        assert_eq!(resolver.resolve(&parts), None);
288    }
289
290    #[test]
291    fn accept_language_normalizes_region_tags() {
292        let resolver = AcceptLanguageResolver::new(&["en"]);
293        let req = Request::builder()
294            .header("accept-language", "en-US;q=0.9")
295            .body(())
296            .unwrap();
297        let parts = parts_from_request(req);
298        assert_eq!(resolver.resolve(&parts), Some("en".into()));
299    }
300
301    #[test]
302    fn session_resolver_returns_none_without_session() {
303        let resolver = SessionResolver;
304        let req = Request::builder().body(()).unwrap();
305        let parts = parts_from_request(req);
306        assert_eq!(resolver.resolve(&parts), None);
307    }
308
309    #[test]
310    fn query_param_rejects_unknown_locale() {
311        let available = vec!["en".into(), "uk".into()];
312        let resolver = QueryParamResolver::new("lang", &available);
313        let req = Request::builder().uri("/?lang=xx").body(()).unwrap();
314        let parts = parts_from_request(req);
315        assert_eq!(resolver.resolve(&parts), None);
316    }
317
318    #[test]
319    fn query_param_accepts_known_locale() {
320        let available = vec!["en".into(), "uk".into()];
321        let resolver = QueryParamResolver::new("lang", &available);
322        let req = Request::builder().uri("/?lang=uk").body(()).unwrap();
323        let parts = parts_from_request(req);
324        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
325    }
326
327    #[test]
328    fn cookie_rejects_unknown_locale() {
329        let available = vec!["en".into(), "uk".into()];
330        let resolver = CookieResolver::new("lang", &available);
331        let req = Request::builder()
332            .header("cookie", "lang=xx")
333            .body(())
334            .unwrap();
335        let parts = parts_from_request(req);
336        assert_eq!(resolver.resolve(&parts), None);
337    }
338
339    #[test]
340    fn cookie_accepts_known_locale() {
341        let available = vec!["en".into(), "uk".into()];
342        let resolver = CookieResolver::new("lang", &available);
343        let req = Request::builder()
344            .header("cookie", "lang=uk")
345            .body(())
346            .unwrap();
347        let parts = parts_from_request(req);
348        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
349    }
350
351    #[test]
352    fn resolve_locale_chain_ordering() {
353        let available: Vec<String> = vec!["en".into(), "uk".into(), "fr".into()];
354        let chain: Vec<Arc<dyn LocaleResolver>> = vec![
355            Arc::new(QueryParamResolver::new("lang", &available)),
356            Arc::new(CookieResolver::new("lang", &available)),
357        ];
358        // Both query param and cookie set — query param should win (first in chain)
359        let req = Request::builder()
360            .uri("/?lang=uk")
361            .header("cookie", "lang=fr")
362            .body(())
363            .unwrap();
364        let parts = parts_from_request(req);
365        let result = resolve_locale(&chain, &parts);
366        assert_eq!(result, Some("uk".into()));
367    }
368
369    #[test]
370    fn default_chain_builds_all_resolvers() {
371        let config = TemplateConfig::default();
372        let available = vec!["en".into(), "uk".into()];
373        let chain = default_chain(&config, &available);
374        assert_eq!(chain.len(), 4);
375    }
376}