Skip to main content

sofie_values/web/
locale.rs

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