1use http::request::Parts;
2use std::sync::Arc;
3
4use super::config::TemplateConfig;
5
6pub trait LocaleResolver: Send + Sync {
16 fn resolve(&self, parts: &Parts) -> Option<String>;
19}
20
21pub struct QueryParamResolver {
28 param_name: String,
29 available_locales: Vec<String>,
30}
31
32impl QueryParamResolver {
33 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
61pub struct CookieResolver {
68 cookie_name: String,
69 available_locales: Vec<String>,
70}
71
72impl CookieResolver {
73 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
105pub 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
130pub struct AcceptLanguageResolver {
137 available: Vec<String>,
138}
139
140impl AcceptLanguageResolver {
141 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 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 let lang = lang.split('-').next().unwrap_or(&lang).to_lowercase();
165 (lang, quality)
166 })
167 .collect();
168
169 langs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
171
172 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
183pub(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 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}