Skip to main content

sova_i18n/
resolve.rs

1//! Locale resolution chain.
2
3use sova_core::Request;
4
5/// Sources checked in order (first match wins).
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LocaleSource {
8    Path,
9    Query,
10    Cookie,
11    AcceptLanguage,
12    Default,
13}
14
15#[derive(Debug, Clone)]
16pub struct ResolvedLocale {
17    pub code: String,
18    pub source: LocaleSource,
19}
20
21#[derive(Debug, Clone)]
22pub struct ResolveOptions {
23    pub locales: Vec<String>,
24    pub default: String,
25    pub query_key: String,
26    pub cookie_name: Option<String>,
27    /// When true, first path segment may be a locale (`/en/...`).
28    pub path_prefix: bool,
29}
30
31impl ResolveOptions {
32    pub fn new(locales: impl IntoIterator<Item = impl Into<String>>, default: impl Into<String>) -> Self {
33        Self {
34            locales: locales.into_iter().map(|s| s.into()).collect(),
35            default: default.into(),
36            query_key: "locale".into(),
37            cookie_name: None,
38            path_prefix: true,
39        }
40    }
41}
42
43pub fn resolve_server_locale(req: &Request, opts: &ResolveOptions) -> ResolvedLocale {
44    if opts.path_prefix {
45        if let Some(code) = path_locale(&req.path, &opts.locales) {
46            return ResolvedLocale {
47                code,
48                source: LocaleSource::Path,
49            };
50        }
51    }
52
53    if let Some(code) = req
54        .query
55        .get(&opts.query_key)
56        .map(|s| s.as_str())
57        .and_then(|c| normalize_known(c, &opts.locales))
58    {
59        return ResolvedLocale {
60            code,
61            source: LocaleSource::Query,
62        };
63    }
64
65    #[cfg(feature = "cookie")]
66    if let Some(name) = &opts.cookie_name {
67        if let Some(code) = req
68            .get::<sova_cookies::Cookies>()
69            .and_then(|c| c.get(name))
70            .and_then(|c| normalize_known(c, &opts.locales))
71        {
72            return ResolvedLocale {
73                code,
74                source: LocaleSource::Cookie,
75            };
76        }
77    }
78
79    if let Some(raw) = req.header("accept-language") {
80        if let Some(code) = negotiate_accept_language(raw, &opts.locales) {
81            return ResolvedLocale {
82                code,
83                source: LocaleSource::AcceptLanguage,
84            };
85        }
86    }
87
88    ResolvedLocale {
89        code: opts.default.clone(),
90        source: LocaleSource::Default,
91    }
92}
93
94fn path_locale(path: &str, locales: &[String]) -> Option<String> {
95    let seg = path.trim_start_matches('/').split('/').next().unwrap_or("");
96    normalize_known(seg, locales)
97}
98
99fn normalize_known(code: &str, locales: &[String]) -> Option<String> {
100    let lower = code.to_ascii_lowercase();
101    locales
102        .iter()
103        .find(|l| l.eq_ignore_ascii_case(&lower))
104        .cloned()
105}
106
107/// Parse `Accept-Language` and pick the best known locale.
108pub fn negotiate_accept_language(header: &str, locales: &[String]) -> Option<String> {
109    let mut tags: Vec<(String, f32)> = Vec::new();
110    for part in header.split(',') {
111        let mut it = part.trim().split(';');
112        let tag = it.next()?.trim().to_ascii_lowercase();
113        if tag.is_empty() || tag == "*" {
114            continue;
115        }
116        let mut q = 1.0_f32;
117        for param in it {
118            let param = param.trim();
119            if let Some(v) = param.strip_prefix("q=") {
120                q = v.parse().unwrap_or(0.0);
121            }
122        }
123        tags.push((tag, q));
124    }
125    tags.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
126
127    for (tag, _) in &tags {
128        if let Some(c) = normalize_known(tag, locales) {
129            return Some(c);
130        }
131        if let Some((lang, _)) = tag.split_once('-') {
132            if let Some(c) = normalize_known(lang, locales) {
133                return Some(c);
134            }
135        }
136    }
137    None
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use http::Method;
144    use sova_core::Request;
145
146    fn opts() -> ResolveOptions {
147        ResolveOptions::new(["en", "de"], "en")
148    }
149
150    #[test]
151    fn path_beats_query() {
152        let req = Request::builder()
153            .method(Method::GET)
154            .path("/de/x")
155            .build();
156        // query would be de vs en — path wins
157        let mut req = req;
158        req.query.insert("locale".into(), "en".into());
159        let r = resolve_server_locale(&req, &opts());
160        assert_eq!(r.code, "de");
161        assert_eq!(r.source, LocaleSource::Path);
162    }
163
164    #[test]
165    fn query_beats_accept() {
166        let req = Request::builder()
167            .method(Method::GET)
168            .path("/blog")
169            .header("accept-language", "de")
170            .build();
171        let mut req = req;
172        req.query.insert("locale".into(), "en".into());
173        let mut o = opts();
174        o.path_prefix = false;
175        let r = resolve_server_locale(&req, &o);
176        assert_eq!(r.code, "en");
177        assert_eq!(r.source, LocaleSource::Query);
178    }
179
180    #[test]
181    fn accept_language_negotiation() {
182        assert_eq!(
183            negotiate_accept_language("fr-CH, fr;q=0.9, de;q=0.8, en;q=0.7", &["en".into(), "de".into()]),
184            Some("de".into())
185        );
186    }
187}