1use std::collections::HashSet;
4
5pub trait ResolveStrategy: Send + Sync {
6 fn kind(&self) -> &'static str;
7 fn resolve(&self, data: &ResolveData) -> Option<String>;
8}
9
10pub struct ResolveData<'a> {
11 pub url: &'a str,
12 pub path_locale: Option<&'a str>,
13 pub cookie_header: Option<&'a str>,
14 pub accept_language: Option<&'a str>,
15 pub locales: &'a [String],
16 pub default_locale: &'a str,
17}
18
19impl<'a> ResolveData<'a> {
20 pub fn locale_set(&self) -> HashSet<&'a str> {
22 self.locales.iter().map(String::as_str).collect()
23 }
24}
25
26pub struct FromUrlPrefix;
29
30impl ResolveStrategy for FromUrlPrefix {
31 fn kind(&self) -> &'static str {
32 "url_prefix"
33 }
34
35 fn resolve(&self, data: &ResolveData) -> Option<String> {
36 let loc = data.path_locale?;
37 if data.locale_set().contains(loc) { Some(loc.to_string()) } else { None }
38 }
39}
40
41pub fn from_url_prefix() -> Box<dyn ResolveStrategy> {
42 Box::new(FromUrlPrefix)
43}
44
45pub struct FromCookie {
48 name: String,
49}
50
51impl ResolveStrategy for FromCookie {
52 fn kind(&self) -> &'static str {
53 "cookie"
54 }
55
56 fn resolve(&self, data: &ResolveData) -> Option<String> {
57 let header = data.cookie_header?;
58 let locale_set = data.locale_set();
59 for pair in header.split(';') {
60 let pair = pair.trim();
61 if let Some((k, v)) = pair.split_once('=')
62 && k.trim() == self.name
63 {
64 let v = v.trim();
65 if locale_set.contains(v) {
66 return Some(v.to_string());
67 }
68 }
69 }
70 None
71 }
72}
73
74pub fn from_cookie(name: &str) -> Box<dyn ResolveStrategy> {
75 Box::new(FromCookie { name: name.to_string() })
76}
77
78pub struct FromAcceptLanguage;
81
82impl ResolveStrategy for FromAcceptLanguage {
83 fn kind(&self) -> &'static str {
84 "accept_language"
85 }
86
87 fn resolve(&self, data: &ResolveData) -> Option<String> {
88 let header = data.accept_language?;
89 if header.is_empty() {
90 return None;
91 }
92
93 let locale_set = data.locale_set();
94
95 let mut entries: Vec<(&str, f64)> = Vec::new();
96 for part in header.split(',') {
97 let part = part.trim();
98 if part.is_empty() {
99 continue;
100 }
101 let mut segments = part.split(';');
102 let lang = segments.next().unwrap_or("").trim();
103 let mut q = 1.0_f64;
104 for s in segments {
105 let s = s.trim();
106 if let Some(val) = s.strip_prefix("q=")
107 && let Ok(v) = val.parse::<f64>()
108 {
109 q = v;
110 }
111 }
112 entries.push((lang, q));
113 }
114
115 entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
116
117 for (lang, _) in &entries {
118 if locale_set.contains(lang) {
119 return Some(lang.to_string());
120 }
121 if let Some(idx) = lang.find('-') {
123 let prefix = &lang[..idx];
124 if locale_set.contains(prefix) {
125 return Some(prefix.to_string());
126 }
127 }
128 }
129
130 None
131 }
132}
133
134pub fn from_accept_language() -> Box<dyn ResolveStrategy> {
135 Box::new(FromAcceptLanguage)
136}
137
138pub struct FromUrlQuery {
141 param: String,
142}
143
144impl ResolveStrategy for FromUrlQuery {
145 fn kind(&self) -> &'static str {
146 "url_query"
147 }
148
149 fn resolve(&self, data: &ResolveData) -> Option<String> {
150 let query_str = data.url.split_once('?').map(|(_, q)| q)?;
151 let locale_set = data.locale_set();
152 for pair in query_str.split('&') {
153 if let Some((k, v)) = pair.split_once('=')
154 && k == self.param
155 && locale_set.contains(v)
156 {
157 return Some(v.to_string());
158 }
159 }
160 None
161 }
162}
163
164pub fn from_url_query(param: &str) -> Box<dyn ResolveStrategy> {
165 Box::new(FromUrlQuery { param: param.to_string() })
166}
167
168pub fn resolve_chain(strategies: &[Box<dyn ResolveStrategy>], data: &ResolveData) -> String {
171 for s in strategies {
172 if let Some(locale) = s.resolve(data) {
173 return locale;
174 }
175 }
176 data.default_locale.to_string()
177}
178
179pub fn default_strategies() -> Vec<Box<dyn ResolveStrategy>> {
181 vec![from_url_prefix(), from_cookie("seam-locale"), from_accept_language()]
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn locales() -> Vec<String> {
189 vec!["en".into(), "zh".into(), "ja".into()]
190 }
191
192 fn make_data<'a>(
193 url: &'a str,
194 path_locale: Option<&'a str>,
195 cookie_header: Option<&'a str>,
196 accept_language: Option<&'a str>,
197 locales: &'a [String],
198 default_locale: &'a str,
199 ) -> ResolveData<'a> {
200 ResolveData { url, path_locale, cookie_header, accept_language, locales, default_locale }
201 }
202
203 #[test]
206 fn url_prefix_valid_locale() {
207 let locs = locales();
208 let data = make_data("", Some("zh"), None, None, &locs, "en");
209 assert_eq!(FromUrlPrefix.resolve(&data), Some("zh".into()));
210 }
211
212 #[test]
213 fn url_prefix_invalid_locale() {
214 let locs = locales();
215 let data = make_data("", Some("fr"), None, None, &locs, "en");
216 assert_eq!(FromUrlPrefix.resolve(&data), None);
217 }
218
219 #[test]
220 fn url_prefix_none() {
221 let locs = locales();
222 let data = make_data("", None, None, None, &locs, "en");
223 assert_eq!(FromUrlPrefix.resolve(&data), None);
224 }
225
226 #[test]
229 fn cookie_valid_locale() {
230 let locs = locales();
231 let strategy = FromCookie { name: "seam-locale".into() };
232 let data = make_data("", None, Some("seam-locale=ja"), None, &locs, "en");
233 assert_eq!(strategy.resolve(&data), Some("ja".into()));
234 }
235
236 #[test]
237 fn cookie_invalid_locale() {
238 let locs = locales();
239 let strategy = FromCookie { name: "seam-locale".into() };
240 let data = make_data("", None, Some("seam-locale=fr"), None, &locs, "en");
241 assert_eq!(strategy.resolve(&data), None);
242 }
243
244 #[test]
245 fn cookie_multiple_pairs() {
246 let locs = locales();
247 let strategy = FromCookie { name: "seam-locale".into() };
248 let data = make_data("", None, Some("other=1; seam-locale=zh; foo=bar"), None, &locs, "en");
249 assert_eq!(strategy.resolve(&data), Some("zh".into()));
250 }
251
252 #[test]
253 fn cookie_wrong_name() {
254 let locs = locales();
255 let strategy = FromCookie { name: "seam-locale".into() };
256 let data = make_data("", None, Some("lang=zh"), None, &locs, "en");
257 assert_eq!(strategy.resolve(&data), None);
258 }
259
260 #[test]
261 fn cookie_no_header() {
262 let locs = locales();
263 let strategy = FromCookie { name: "seam-locale".into() };
264 let data = make_data("", None, None, None, &locs, "en");
265 assert_eq!(strategy.resolve(&data), None);
266 }
267
268 #[test]
271 fn accept_language_exact_match() {
272 let locs = locales();
273 let data = make_data("", None, None, Some("zh,en;q=0.5"), &locs, "en");
274 assert_eq!(FromAcceptLanguage.resolve(&data), Some("zh".into()));
275 }
276
277 #[test]
278 fn accept_language_q_value_priority() {
279 let locs = locales();
280 let data = make_data("", None, None, Some("en;q=0.5,zh;q=0.9"), &locs, "en");
281 assert_eq!(FromAcceptLanguage.resolve(&data), Some("zh".into()));
282 }
283
284 #[test]
285 fn accept_language_prefix_match() {
286 let locs = locales();
287 let data = make_data("", None, None, Some("zh-CN,en;q=0.5"), &locs, "en");
288 assert_eq!(FromAcceptLanguage.resolve(&data), Some("zh".into()));
289 }
290
291 #[test]
292 fn accept_language_no_match() {
293 let locs = locales();
294 let data = make_data("", None, None, Some("fr,de"), &locs, "en");
295 assert_eq!(FromAcceptLanguage.resolve(&data), None);
296 }
297
298 #[test]
299 fn accept_language_empty() {
300 let locs = locales();
301 let data = make_data("", None, None, Some(""), &locs, "en");
302 assert_eq!(FromAcceptLanguage.resolve(&data), None);
303 }
304
305 #[test]
306 fn accept_language_no_header() {
307 let locs = locales();
308 let data = make_data("", None, None, None, &locs, "en");
309 assert_eq!(FromAcceptLanguage.resolve(&data), None);
310 }
311
312 #[test]
315 fn url_query_valid_locale() {
316 let locs = locales();
317 let strategy = FromUrlQuery { param: "lang".into() };
318 let data = make_data("/page?lang=zh", None, None, None, &locs, "en");
319 assert_eq!(strategy.resolve(&data), Some("zh".into()));
320 }
321
322 #[test]
323 fn url_query_invalid_locale() {
324 let locs = locales();
325 let strategy = FromUrlQuery { param: "lang".into() };
326 let data = make_data("/page?lang=fr", None, None, None, &locs, "en");
327 assert_eq!(strategy.resolve(&data), None);
328 }
329
330 #[test]
331 fn url_query_no_query_string() {
332 let locs = locales();
333 let strategy = FromUrlQuery { param: "lang".into() };
334 let data = make_data("/page", None, None, None, &locs, "en");
335 assert_eq!(strategy.resolve(&data), None);
336 }
337
338 #[test]
339 fn url_query_wrong_param() {
340 let locs = locales();
341 let strategy = FromUrlQuery { param: "lang".into() };
342 let data = make_data("/page?locale=zh", None, None, None, &locs, "en");
343 assert_eq!(strategy.resolve(&data), None);
344 }
345
346 #[test]
347 fn url_query_multiple_params() {
348 let locs = locales();
349 let strategy = FromUrlQuery { param: "lang".into() };
350 let data = make_data("/page?foo=bar&lang=ja&baz=1", None, None, None, &locs, "en");
351 assert_eq!(strategy.resolve(&data), Some("ja".into()));
352 }
353
354 #[test]
357 fn chain_priority_ordering() {
358 let locs = locales();
359 let strategies: Vec<Box<dyn ResolveStrategy>> =
360 vec![from_url_prefix(), from_cookie("seam-locale"), from_accept_language()];
361 let data = make_data("", Some("zh"), Some("seam-locale=ja"), Some("en"), &locs, "en");
363 assert_eq!(resolve_chain(&strategies, &data), "zh");
364 }
365
366 #[test]
367 fn chain_falls_to_cookie() {
368 let locs = locales();
369 let strategies: Vec<Box<dyn ResolveStrategy>> =
370 vec![from_url_prefix(), from_cookie("seam-locale"), from_accept_language()];
371 let data = make_data("", None, Some("seam-locale=ja"), Some("zh"), &locs, "en");
372 assert_eq!(resolve_chain(&strategies, &data), "ja");
373 }
374
375 #[test]
376 fn chain_falls_to_accept_language() {
377 let locs = locales();
378 let strategies: Vec<Box<dyn ResolveStrategy>> =
379 vec![from_url_prefix(), from_cookie("seam-locale"), from_accept_language()];
380 let data = make_data("", None, None, Some("zh,en;q=0.5"), &locs, "en");
381 assert_eq!(resolve_chain(&strategies, &data), "zh");
382 }
383
384 #[test]
385 fn chain_falls_to_default() {
386 let locs = locales();
387 let strategies: Vec<Box<dyn ResolveStrategy>> =
388 vec![from_url_prefix(), from_cookie("seam-locale"), from_accept_language()];
389 let data = make_data("", None, None, None, &locs, "en");
390 assert_eq!(resolve_chain(&strategies, &data), "en");
391 }
392
393 #[test]
394 fn empty_chain_falls_to_default() {
395 let locs = locales();
396 let strategies: Vec<Box<dyn ResolveStrategy>> = vec![];
397 let data = make_data("", Some("zh"), Some("seam-locale=ja"), Some("zh"), &locs, "en");
398 assert_eq!(resolve_chain(&strategies, &data), "en");
399 }
400
401 #[test]
402 fn default_strategies_produces_three() {
403 let strategies = default_strategies();
404 assert_eq!(strategies.len(), 3);
405 assert_eq!(strategies[0].kind(), "url_prefix");
406 assert_eq!(strategies[1].kind(), "cookie");
407 assert_eq!(strategies[2].kind(), "accept_language");
408 }
409}