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
19impl<'a> ResolveData<'a> {
20  /// Build a locale lookup set from the configured locales.
21  pub fn locale_set(&self) -> HashSet<&'a str> {
22    self.locales.iter().map(String::as_str).collect()
23  }
24}
25
26// -- FromUrlPrefix --
27
28pub 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
45// -- FromCookie --
46
47pub 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
78// -- FromAcceptLanguage --
79
80pub 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      // Prefix match: zh-CN -> zh
122      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
138// -- FromUrlQuery --
139
140pub 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
168// -- Chain runner --
169
170pub 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
179/// Default strategy chain: url_prefix -> cookie("seam-locale") -> accept-language
180pub 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  // -- FromUrlPrefix tests --
204
205  #[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  // -- FromCookie tests --
227
228  #[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  // -- FromAcceptLanguage tests --
269
270  #[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  // -- FromUrlQuery tests --
313
314  #[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  // -- Chain composition tests --
355
356  #[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    // url_prefix wins over cookie
362    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}