Skip to main content

seam_server/
resolve.rs

1/* packages/server/core/rust/src/resolve.rs */
2
3use 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
19// -- FromUrlPrefix --
20
21pub 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
43// -- FromCookie --
44
45pub 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
76// -- FromAcceptLanguage --
77
78pub 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      // Prefix match: zh-CN -> zh
120      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
136// -- FromUrlQuery --
137
138pub 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
165// -- Chain runner --
166
167pub 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
176/// Default strategy chain: url_prefix -> cookie("seam-locale") -> accept-language
177pub 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  // -- FromUrlPrefix tests --
201
202  #[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  // -- FromCookie tests --
224
225  #[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  // -- FromAcceptLanguage tests --
266
267  #[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  // -- FromUrlQuery tests --
310
311  #[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  // -- Chain composition tests --
352
353  #[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    // url_prefix wins over cookie
359    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}