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