sofie_values/web/
locale.rs1use sofie_core::Result;
2
3use crate::refusal::refusal;
4
5#[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(®ion.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}