Skip to main content

mas_axum_utils/
language_detection.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::cmp::Reverse;
9
10use headers::{Error, Header};
11use http::{HeaderName, HeaderValue, header::ACCEPT_LANGUAGE};
12use icu_locale_core::Locale;
13
14#[derive(PartialEq, Eq, Debug)]
15struct AcceptLanguagePart {
16    // None means *
17    locale: Option<Locale>,
18
19    // Quality is between 0 and 1 with 3 decimal places
20    // Which we map from 0 to 1000, e.g. 0.5 becomes 500
21    quality: u16,
22}
23
24impl PartialOrd for AcceptLanguagePart {
25    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
26        Some(self.cmp(other))
27    }
28}
29
30impl Ord for AcceptLanguagePart {
31    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
32        // When comparing two AcceptLanguage structs, we only consider the
33        // quality, in reverse.
34        Reverse(self.quality).cmp(&Reverse(other.quality))
35    }
36}
37
38/// A header that represents the `Accept-Language` header.
39#[derive(PartialEq, Eq, Debug)]
40pub struct AcceptLanguage {
41    parts: Vec<AcceptLanguagePart>,
42}
43
44impl AcceptLanguage {
45    pub fn iter(&self) -> impl Iterator<Item = &Locale> {
46        // This should stop when we hit the first None, aka the first *
47        self.parts.iter().map_while(|item| item.locale.as_ref())
48    }
49}
50
51/// Utility to trim ASCII whitespace from the start and end of a byte slice
52const fn trim_bytes(mut bytes: &[u8]) -> &[u8] {
53    // Trim leading and trailing whitespace
54    while let [first, rest @ ..] = bytes {
55        if first.is_ascii_whitespace() {
56            bytes = rest;
57        } else {
58            break;
59        }
60    }
61
62    while let [rest @ .., last] = bytes {
63        if last.is_ascii_whitespace() {
64            bytes = rest;
65        } else {
66            break;
67        }
68    }
69
70    bytes
71}
72
73impl Header for AcceptLanguage {
74    fn name() -> &'static HeaderName {
75        &ACCEPT_LANGUAGE
76    }
77
78    fn decode<'i, I>(values: &mut I) -> Result<Self, Error>
79    where
80        Self: Sized,
81        I: Iterator<Item = &'i HeaderValue>,
82    {
83        let mut parts = Vec::new();
84        for value in values {
85            for part in value.as_bytes().split(|b| *b == b',') {
86                let mut it = part.split(|b| *b == b';');
87                let locale = it.next().ok_or(Error::invalid())?;
88                let locale = trim_bytes(locale);
89
90                let locale = match locale {
91                    b"*" => None,
92                    locale => {
93                        let locale =
94                            Locale::try_from_utf8(locale).map_err(|_e| Error::invalid())?;
95                        Some(locale)
96                    }
97                };
98
99                let quality = if let Some(quality) = it.next() {
100                    let quality = trim_bytes(quality);
101                    let quality = quality.strip_prefix(b"q=").ok_or(Error::invalid())?;
102                    let quality = std::str::from_utf8(quality).map_err(|_e| Error::invalid())?;
103                    let quality = quality.parse::<f64>().map_err(|_e| Error::invalid())?;
104                    // Bound the quality between 0 and 1
105                    let quality = quality.clamp(0_f64, 1_f64);
106
107                    // Make sure the iterator is empty
108                    if it.next().is_some() {
109                        return Err(Error::invalid());
110                    }
111
112                    #[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
113                    {
114                        f64::round(quality * 1000_f64) as u16
115                    }
116                } else {
117                    1000
118                };
119
120                parts.push(AcceptLanguagePart { locale, quality });
121            }
122        }
123
124        parts.sort();
125
126        Ok(AcceptLanguage { parts })
127    }
128
129    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
130        let mut value = String::new();
131        let mut first = true;
132        for part in &self.parts {
133            if first {
134                first = false;
135            } else {
136                value.push_str(", ");
137            }
138
139            if let Some(locale) = &part.locale {
140                value.push_str(&locale.to_string());
141            } else {
142                value.push('*');
143            }
144
145            if part.quality != 1000 {
146                value.push_str(";q=");
147                value.push_str(&(f64::from(part.quality) / 1000_f64).to_string());
148            }
149        }
150
151        // We know this is safe because we only use ASCII characters
152        values.extend(Some(HeaderValue::from_str(&value).unwrap()));
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use headers::HeaderMapExt;
159    use http::{HeaderMap, HeaderValue, header::ACCEPT_LANGUAGE};
160    use icu_locale_core::locale;
161
162    use super::*;
163
164    #[test]
165    fn test_decode() {
166        let headers = HeaderMap::from_iter([(
167            ACCEPT_LANGUAGE,
168            HeaderValue::from_str("fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5").unwrap(),
169        )]);
170
171        let accept_language: Option<AcceptLanguage> = headers.typed_get();
172        assert!(accept_language.is_some());
173        let accept_language = accept_language.unwrap();
174
175        assert_eq!(
176            accept_language,
177            AcceptLanguage {
178                parts: vec![
179                    AcceptLanguagePart {
180                        locale: Some(locale!("fr-CH")),
181                        quality: 1000,
182                    },
183                    AcceptLanguagePart {
184                        locale: Some(locale!("fr")),
185                        quality: 900,
186                    },
187                    AcceptLanguagePart {
188                        locale: Some(locale!("en")),
189                        quality: 800,
190                    },
191                    AcceptLanguagePart {
192                        locale: Some(locale!("de")),
193                        quality: 700,
194                    },
195                    AcceptLanguagePart {
196                        locale: None,
197                        quality: 500,
198                    },
199                ]
200            }
201        );
202    }
203
204    #[test]
205    /// Test that we can decode a header with multiple values unordered, and
206    /// that the output is ordered by quality
207    fn test_decode_order() {
208        let headers = HeaderMap::from_iter([(
209            ACCEPT_LANGUAGE,
210            HeaderValue::from_str("*;q=0.5, fr-CH, en;q=0.8, fr;q=0.9, de;q=0.9").unwrap(),
211        )]);
212
213        let accept_language: Option<AcceptLanguage> = headers.typed_get();
214        assert!(accept_language.is_some());
215        let accept_language = accept_language.unwrap();
216
217        assert_eq!(
218            accept_language,
219            AcceptLanguage {
220                parts: vec![
221                    AcceptLanguagePart {
222                        locale: Some(locale!("fr-CH")),
223                        quality: 1000,
224                    },
225                    AcceptLanguagePart {
226                        locale: Some(locale!("fr")),
227                        quality: 900,
228                    },
229                    AcceptLanguagePart {
230                        locale: Some(locale!("de")),
231                        quality: 900,
232                    },
233                    AcceptLanguagePart {
234                        locale: Some(locale!("en")),
235                        quality: 800,
236                    },
237                    AcceptLanguagePart {
238                        locale: None,
239                        quality: 500,
240                    },
241                ]
242            }
243        );
244    }
245
246    #[test]
247    fn test_encode() {
248        let accept_language = AcceptLanguage {
249            parts: vec![
250                AcceptLanguagePart {
251                    locale: Some(locale!("fr-CH")),
252                    quality: 1000,
253                },
254                AcceptLanguagePart {
255                    locale: Some(locale!("fr")),
256                    quality: 900,
257                },
258                AcceptLanguagePart {
259                    locale: Some(locale!("de")),
260                    quality: 900,
261                },
262                AcceptLanguagePart {
263                    locale: Some(locale!("en")),
264                    quality: 800,
265                },
266                AcceptLanguagePart {
267                    locale: None,
268                    quality: 500,
269                },
270            ],
271        };
272
273        let mut headers = HeaderMap::new();
274        headers.typed_insert(accept_language);
275        let header = headers.get(ACCEPT_LANGUAGE).unwrap();
276        assert_eq!(
277            header.to_str().unwrap(),
278            "fr-CH, fr;q=0.9, de;q=0.9, en;q=0.8, *;q=0.5"
279        );
280    }
281}