Skip to main content

sofie_values/web/
locale.rs

1use sofie_core::{Error, Result};
2
3/// Each part is checked for its shape, not against a registry.
4#[derive(Clone, Debug, PartialEq, Eq, Hash)]
5pub struct Locale {
6    tag: String,
7}
8
9impl Locale {
10    pub fn parse(text: &str) -> Result<Self> {
11        let mut parts = text.split(['-', '_']);
12        let language = parts
13            .next()
14            .filter(|language| is_letters(language, 2..=3))
15            .ok_or_else(|| invalid_locale("must start with a language such as en"))?;
16        let mut tag = language.to_ascii_lowercase();
17        let mut rest = parts.peekable();
18        if let Some(script) = rest.next_if(|script| is_letters(script, 4..=4)) {
19            tag.push('-');
20            tag.push_str(&script[..1].to_ascii_uppercase());
21            tag.push_str(&script[1..].to_ascii_lowercase());
22        }
23        if let Some(region) = rest.next() {
24            if !is_letters(region, 2..=2) && !is_digits(region, 3) {
25                return Err(invalid_locale("must have a region such as US or 419"));
26            }
27            tag.push('-');
28            tag.push_str(&region.to_ascii_uppercase());
29        }
30        if rest.next().is_some() {
31            return Err(invalid_locale(
32                "must be a language, script and region at most",
33            ));
34        }
35        Ok(Self { tag })
36    }
37
38    #[must_use]
39    pub fn as_str(&self) -> &str {
40        &self.tag
41    }
42
43    #[must_use]
44    pub fn language(&self) -> &str {
45        self.tag.split('-').next().unwrap_or_default()
46    }
47}
48
49fn is_letters(part: &str, lengths: std::ops::RangeInclusive<usize>) -> bool {
50    lengths.contains(&part.len()) && part.bytes().all(|byte| byte.is_ascii_alphabetic())
51}
52
53fn is_digits(part: &str, length: usize) -> bool {
54    part.len() == length && part.bytes().all(|byte| byte.is_ascii_digit())
55}
56
57fn invalid_locale(message: &str) -> Error {
58    Error::invalid("locale", message)
59}
60
61sofie_core::text_value!(Locale, tag);
62
63crate::present::present_text!(
64    Locale,
65    crate::Input::typed(&[("type", "text"), ("maxlength", "35")]),
66    Exact
67);
68
69#[cfg(test)]
70mod tests {
71    use sofie_core::SqlValue;
72
73    use super::*;
74    use crate::testing::{assert_refused, round_trip};
75
76    #[test]
77    fn writes_each_part_in_its_usual_case() {
78        for (text, tag) in [
79            ("tr", "tr"),
80            ("en-us", "en-US"),
81            ("EN_GB", "en-GB"),
82            ("zh-hant-tw", "zh-Hant-TW"),
83            ("sr-Latn", "sr-Latn"),
84            ("es-419", "es-419"),
85            ("fil-PH", "fil-PH"),
86        ] {
87            assert_eq!(Locale::parse(text).unwrap().as_str(), tag, "{text}");
88        }
89        assert_eq!(Locale::parse("zh-Hant-TW").unwrap().language(), "zh");
90    }
91
92    #[test]
93    fn refuses_what_is_not_a_tag() {
94        assert_refused(
95            Locale::parse,
96            &[
97                "",
98                "e",
99                "engl",
100                "en-",
101                "en-USA",
102                "en-U1",
103                "en-US-x",
104                "en-Latn-US-1",
105                "1en",
106                "en US",
107            ],
108        );
109    }
110
111    #[test]
112    fn round_trips_through_a_text_column_and_json() {
113        let locale = Locale::parse("en-US").unwrap();
114        assert_eq!(round_trip(&locale), vec![SqlValue::Text("en-US".into())]);
115        let read: Locale = serde_json::from_value(serde_json::json!("en_us")).unwrap();
116        assert_eq!(read, locale);
117        assert_eq!(
118            serde_json::to_value(&read).unwrap(),
119            serde_json::json!("en-US")
120        );
121    }
122}