Skip to main content

lang_lib/
request.rs

1fn parse_quality(param: &str) -> Option<u16> {
2    let (_, value) = param.split_once('=')?;
3    let value = value.trim();
4
5    if value == "1" {
6        return Some(1000);
7    }
8
9    if value == "0" {
10        return Some(0);
11    }
12
13    if let Some(fraction) = value.strip_prefix("1.") {
14        if fraction.chars().all(|ch| ch == '0') {
15            return Some(1000);
16        }
17
18        return None;
19    }
20
21    let fraction = value.strip_prefix("0.")?;
22    if fraction.is_empty() || !fraction.chars().all(|ch| ch.is_ascii_digit()) {
23        return None;
24    }
25
26    let mut padded = fraction.to_string();
27    padded.truncate(3);
28    while padded.len() < 3 {
29        padded.push('0');
30    }
31
32    padded.parse().ok()
33}
34
35fn parse_weighted_language(token: &str) -> Option<(String, u16)> {
36    let mut parts = token.split(';');
37    let language = parts.next()?.trim();
38    if language.is_empty() {
39        return None;
40    }
41
42    let mut quality = 1000;
43    for part in parts {
44        let part = part.trim();
45        if part.starts_with("q=") {
46            quality = parse_quality(part)?;
47            break;
48        }
49    }
50
51    Some((language.to_ascii_lowercase(), quality))
52}
53
54fn primary_subtag(locale: &str) -> &str {
55    locale.split(['-', '_']).next().unwrap_or(locale)
56}
57
58fn match_score(requested: &str, supported: &str) -> u8 {
59    let supported = supported.to_ascii_lowercase();
60
61    if requested == supported {
62        return 2;
63    }
64
65    if primary_subtag(requested) == primary_subtag(&supported) {
66        return 1;
67    }
68
69    0
70}
71
72fn resolve_accept_language_impl<'a>(
73    header: &str,
74    supported_locales: &[&'a str],
75    default_locale: &'a str,
76) -> &'a str {
77    let mut best_match: Option<(&'a str, u16, u8, usize)> = None;
78
79    for (order, token) in header.split(',').enumerate() {
80        let Some((language, quality)) = parse_weighted_language(token) else {
81            continue;
82        };
83
84        for &supported in supported_locales {
85            let score = match_score(&language, supported);
86            if score == 0 {
87                continue;
88            }
89
90            let is_better = match best_match {
91                None => true,
92                Some((_, best_quality, best_score, best_order)) => {
93                    quality > best_quality
94                        || (quality == best_quality
95                            && (score > best_score || (score == best_score && order < best_order)))
96                }
97            };
98
99            if is_better {
100                best_match = Some((supported, quality, score, order));
101            }
102        }
103    }
104
105    best_match
106        .map(|(locale, _, _, _)| locale)
107        .unwrap_or(default_locale)
108}
109
110/// Resolves an `Accept-Language` header against a supported locale list.
111///
112/// The function prefers higher `q` values, then exact locale matches, then
113/// primary-language matches such as `es-ES` -> `es`. If no supported locale
114/// matches the header, `default_locale` is returned.
115///
116/// Matching is ASCII case-insensitive and supports locale identifiers that use
117/// either `-` or `_` separators.
118///
119/// # Examples
120///
121/// ```rust
122/// use lang_lib::resolve_accept_language;
123///
124/// let locale = resolve_accept_language(
125///     "es-ES,es;q=0.9,en;q=0.8",
126///     &["en", "es"],
127///     "en",
128/// );
129///
130/// assert_eq!(locale, "es");
131/// ```
132pub fn resolve_accept_language<'a>(
133    header: &str,
134    supported_locales: &[&'a str],
135    default_locale: &'a str,
136) -> &'a str {
137    resolve_accept_language_impl(header, supported_locales, default_locale)
138}
139
140/// Resolves an `Accept-Language` header against a runtime locale list and
141/// returns an owned `String`.
142///
143/// This variant is convenient when supported locales come from configuration,
144/// a database, or any other runtime source represented as `Vec<String>`.
145///
146/// # Examples
147///
148/// ```rust
149/// use lang_lib::resolve_accept_language_owned;
150///
151/// let supported = vec!["en".to_string(), "es".to_string()];
152/// let locale = resolve_accept_language_owned(
153///     "es-MX,es;q=0.9,en;q=0.7",
154///     &supported,
155///     "en",
156/// );
157///
158/// assert_eq!(locale, "es");
159/// ```
160pub fn resolve_accept_language_owned<S>(
161    header: &str,
162    supported_locales: &[S],
163    default_locale: &str,
164) -> String
165where
166    S: AsRef<str>,
167{
168    let supported_refs: Vec<&str> = supported_locales.iter().map(AsRef::as_ref).collect();
169    resolve_accept_language_impl(header, &supported_refs, default_locale).to_string()
170}