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