Skip to main content

seam_server/
resolve.rs

1/* src/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) { Some(loc.to_string()) } else { None }
32  }
33}
34
35pub fn from_url_prefix() -> Box<dyn ResolveStrategy> {
36  Box::new(FromUrlPrefix)
37}
38
39// -- FromCookie --
40
41pub 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
72// -- FromAcceptLanguage --
73
74pub 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      // Prefix match: zh-CN -> zh
116      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
132// -- FromUrlQuery --
133
134pub 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
162// -- Chain runner --
163
164pub 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
173/// Default strategy chain: url_prefix -> cookie("seam-locale") -> accept-language
174pub 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  // -- FromUrlPrefix tests --
198
199  #[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  // -- FromCookie tests --
221
222  #[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  // -- FromAcceptLanguage tests --
263
264  #[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  // -- FromUrlQuery tests --
307
308  #[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  // -- Chain composition tests --
349
350  #[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    // url_prefix wins over cookie
356    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}