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}