1use http::request::Parts;
2use std::sync::Arc;
3
4use super::config::I18nConfig;
5
6pub trait LocaleResolver: Send + Sync {
26 fn resolve(&self, parts: &Parts) -> Option<String>;
29}
30
31pub struct QueryParamResolver {
40 param_name: String,
41 available_locales: Vec<String>,
42}
43
44impl QueryParamResolver {
45 pub fn new(param_name: &str, available_locales: &[String]) -> Self {
50 Self {
51 param_name: param_name.to_string(),
52 available_locales: available_locales.to_vec(),
53 }
54 }
55}
56
57impl LocaleResolver for QueryParamResolver {
58 fn resolve(&self, parts: &Parts) -> Option<String> {
59 let query = parts.uri.query()?;
60 for pair in query.split('&') {
61 if let Some((key, value)) = pair.split_once('=')
62 && key == self.param_name
63 && (self.available_locales.is_empty()
64 || self.available_locales.iter().any(|l| l == value))
65 {
66 return Some(value.to_string());
67 }
68 }
69 None
70 }
71}
72
73pub struct CookieResolver {
82 cookie_name: String,
83 available_locales: Vec<String>,
84}
85
86impl CookieResolver {
87 pub fn new(cookie_name: &str, available_locales: &[String]) -> Self {
92 Self {
93 cookie_name: cookie_name.to_string(),
94 available_locales: available_locales.to_vec(),
95 }
96 }
97}
98
99impl LocaleResolver for CookieResolver {
100 fn resolve(&self, parts: &Parts) -> Option<String> {
101 let cookie_header = parts.headers.get("cookie")?.to_str().ok()?;
102 for cookie in cookie_header.split(';') {
103 let cookie = cookie.trim();
104 if let Some((name, value)) = cookie.split_once('=')
105 && name.trim() == self.cookie_name
106 {
107 let value = value.trim();
108 if self.available_locales.is_empty()
109 || self.available_locales.iter().any(|l| l == value)
110 {
111 return Some(value.to_string());
112 }
113 }
114 }
115 None
116 }
117}
118
119pub struct SessionResolver;
127
128impl LocaleResolver for SessionResolver {
129 fn resolve(&self, parts: &Parts) -> Option<String> {
130 let state = parts
131 .extensions
132 .get::<Arc<crate::auth::session::SessionState>>()?;
133 let guard = state.current.lock().ok()?;
134 let session = guard.as_ref()?;
135 if let serde_json::Value::Object(ref map) = session.data
136 && let Some(serde_json::Value::String(locale)) = map.get("locale")
137 {
138 return Some(locale.clone());
139 }
140 None
141 }
142}
143
144pub struct AcceptLanguageResolver {
154 available: Vec<String>,
155}
156
157impl AcceptLanguageResolver {
158 pub fn new(available: &[&str]) -> Self {
160 Self {
161 available: available.iter().map(|s| s.to_string()).collect(),
162 }
163 }
164}
165
166impl LocaleResolver for AcceptLanguageResolver {
167 fn resolve(&self, parts: &Parts) -> Option<String> {
168 let header = parts.headers.get("accept-language")?.to_str().ok()?;
169
170 let mut langs: Vec<(String, f32)> = header
172 .split(',')
173 .map(|entry| {
174 let entry = entry.trim();
175 let (lang, quality) = if let Some((l, q)) = entry.split_once(";q=") {
176 (l.trim().to_string(), q.trim().parse::<f32>().unwrap_or(0.0))
177 } else {
178 (entry.to_string(), 1.0)
179 };
180 let lang = lang.split('-').next().unwrap_or(&lang).to_lowercase();
182 (lang, quality)
183 })
184 .collect();
185
186 langs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
188
189 for (lang, _) in &langs {
191 if self.available.iter().any(|a| a == lang) {
192 return Some(lang.clone());
193 }
194 }
195
196 None
197 }
198}
199
200pub(super) fn default_chain(
203 config: &I18nConfig,
204 available_locales: &[String],
205) -> Vec<Arc<dyn LocaleResolver>> {
206 let mut chain: Vec<Arc<dyn LocaleResolver>> = vec![
207 Arc::new(QueryParamResolver::new(
208 &config.locale_query_param,
209 available_locales,
210 )),
211 Arc::new(CookieResolver::new(
212 &config.locale_cookie,
213 available_locales,
214 )),
215 ];
216 chain.push(Arc::new(SessionResolver));
217 chain.push(Arc::new(AcceptLanguageResolver::new(
218 &available_locales
219 .iter()
220 .map(|s| s.as_str())
221 .collect::<Vec<_>>(),
222 )));
223 chain
224}
225
226pub(super) fn resolve_locale(chain: &[Arc<dyn LocaleResolver>], parts: &Parts) -> Option<String> {
227 chain.iter().find_map(|r| r.resolve(parts))
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use http::Request;
234
235 fn parts_from_request(req: Request<()>) -> http::request::Parts {
236 req.into_parts().0
237 }
238
239 #[test]
240 fn query_param_resolver_extracts_lang() {
241 let resolver = QueryParamResolver::new("lang", &[]);
242 let req = Request::builder().uri("/?lang=uk").body(()).unwrap();
243 let parts = parts_from_request(req);
244 assert_eq!(resolver.resolve(&parts), Some("uk".into()));
245 }
246
247 #[test]
248 fn query_param_resolver_returns_none_when_absent() {
249 let resolver = QueryParamResolver::new("lang", &[]);
250 let req = Request::builder().uri("/").body(()).unwrap();
251 let parts = parts_from_request(req);
252 assert_eq!(resolver.resolve(&parts), None);
253 }
254
255 #[test]
256 fn cookie_resolver_extracts_locale() {
257 let resolver = CookieResolver::new("lang", &[]);
258 let req = Request::builder()
259 .header("cookie", "lang=uk; other=value")
260 .body(())
261 .unwrap();
262 let parts = parts_from_request(req);
263 assert_eq!(resolver.resolve(&parts), Some("uk".into()));
264 }
265
266 #[test]
267 fn cookie_resolver_returns_none_when_absent() {
268 let resolver = CookieResolver::new("lang", &[]);
269 let req = Request::builder().body(()).unwrap();
270 let parts = parts_from_request(req);
271 assert_eq!(resolver.resolve(&parts), None);
272 }
273
274 #[test]
275 fn accept_language_resolver_picks_best_match() {
276 let resolver = AcceptLanguageResolver::new(&["en", "uk", "fr"]);
277 let req = Request::builder()
278 .header("accept-language", "uk;q=0.9, en;q=0.8, fr;q=0.7")
279 .body(())
280 .unwrap();
281 let parts = parts_from_request(req);
282 assert_eq!(resolver.resolve(&parts), Some("uk".into()));
283 }
284
285 #[test]
286 fn accept_language_resolver_ignores_unsupported() {
287 let resolver = AcceptLanguageResolver::new(&["en"]);
288 let req = Request::builder()
289 .header("accept-language", "de;q=0.9, en;q=0.8")
290 .body(())
291 .unwrap();
292 let parts = parts_from_request(req);
293 assert_eq!(resolver.resolve(&parts), Some("en".into()));
294 }
295
296 #[test]
297 fn accept_language_resolver_returns_none_for_no_match() {
298 let resolver = AcceptLanguageResolver::new(&["en"]);
299 let req = Request::builder()
300 .header("accept-language", "de, fr")
301 .body(())
302 .unwrap();
303 let parts = parts_from_request(req);
304 assert_eq!(resolver.resolve(&parts), None);
305 }
306
307 #[test]
308 fn accept_language_normalizes_region_tags() {
309 let resolver = AcceptLanguageResolver::new(&["en"]);
310 let req = Request::builder()
311 .header("accept-language", "en-US;q=0.9")
312 .body(())
313 .unwrap();
314 let parts = parts_from_request(req);
315 assert_eq!(resolver.resolve(&parts), Some("en".into()));
316 }
317
318 #[test]
319 fn session_resolver_returns_none_without_session() {
320 let resolver = SessionResolver;
321 let req = Request::builder().body(()).unwrap();
322 let parts = parts_from_request(req);
323 assert_eq!(resolver.resolve(&parts), None);
324 }
325
326 #[test]
327 fn query_param_rejects_unknown_locale() {
328 let available = vec!["en".into(), "uk".into()];
329 let resolver = QueryParamResolver::new("lang", &available);
330 let req = Request::builder().uri("/?lang=xx").body(()).unwrap();
331 let parts = parts_from_request(req);
332 assert_eq!(resolver.resolve(&parts), None);
333 }
334
335 #[test]
336 fn query_param_accepts_known_locale() {
337 let available = vec!["en".into(), "uk".into()];
338 let resolver = QueryParamResolver::new("lang", &available);
339 let req = Request::builder().uri("/?lang=uk").body(()).unwrap();
340 let parts = parts_from_request(req);
341 assert_eq!(resolver.resolve(&parts), Some("uk".into()));
342 }
343
344 #[test]
345 fn cookie_rejects_unknown_locale() {
346 let available = vec!["en".into(), "uk".into()];
347 let resolver = CookieResolver::new("lang", &available);
348 let req = Request::builder()
349 .header("cookie", "lang=xx")
350 .body(())
351 .unwrap();
352 let parts = parts_from_request(req);
353 assert_eq!(resolver.resolve(&parts), None);
354 }
355
356 #[test]
357 fn cookie_accepts_known_locale() {
358 let available = vec!["en".into(), "uk".into()];
359 let resolver = CookieResolver::new("lang", &available);
360 let req = Request::builder()
361 .header("cookie", "lang=uk")
362 .body(())
363 .unwrap();
364 let parts = parts_from_request(req);
365 assert_eq!(resolver.resolve(&parts), Some("uk".into()));
366 }
367
368 #[test]
369 fn resolve_locale_chain_ordering() {
370 let available: Vec<String> = vec!["en".into(), "uk".into(), "fr".into()];
371 let chain: Vec<Arc<dyn LocaleResolver>> = vec![
372 Arc::new(QueryParamResolver::new("lang", &available)),
373 Arc::new(CookieResolver::new("lang", &available)),
374 ];
375 let req = Request::builder()
377 .uri("/?lang=uk")
378 .header("cookie", "lang=fr")
379 .body(())
380 .unwrap();
381 let parts = parts_from_request(req);
382 let result = resolve_locale(&chain, &parts);
383 assert_eq!(result, Some("uk".into()));
384 }
385
386 #[test]
387 fn default_chain_builds_all_resolvers() {
388 let config = I18nConfig::default();
389 let available = vec!["en".into(), "uk".into()];
390 let chain = default_chain(&config, &available);
391 assert_eq!(chain.len(), 4);
392 }
393}