dataprof_core/locale.rs
1//! The closed set of locales the pattern catalogue carries detectors for.
2//!
3//! A locale is *strict*: setting one suppresses patterns belonging to any other
4//! locale. That strictness is right for a tag the catalogue knows, and wrong for
5//! one it does not — an unrecognised tag used to be accepted and behave as
6//! "suppress every locale-specific pattern", so `locale="it-IT"` returned no
7//! patterns where `locale="IT"` returned a confident match, with nothing in the
8//! report saying the tag was not understood. Parsing a tag into [`Locale`]
9//! before it can be stored keeps that failure out of the type: the common
10//! spellings normalise, and anything left over is an error naming the set.
11
12/// A locale the pattern catalogue carries locale-specific detectors for.
13///
14/// Parse a user-supplied tag with [`str::parse`] or
15/// [`Locale::parse_optional`]; the variants are also usable directly
16/// (`Locale::It`).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub enum Locale {
19 /// Canada
20 Ca,
21 /// Germany
22 De,
23 /// France
24 Fr,
25 /// United Kingdom
26 Gb,
27 /// Italy
28 It,
29 /// United States
30 Us,
31}
32
33impl Locale {
34 /// Every supported locale, in the order error messages list them.
35 pub fn all() -> Vec<Self> {
36 vec![Self::Ca, Self::De, Self::Fr, Self::Gb, Self::It, Self::Us]
37 }
38
39 /// The ISO 3166-1 alpha-2 code, as the pattern catalogue spells it.
40 pub fn as_str(&self) -> &'static str {
41 match self {
42 Self::Ca => "CA",
43 Self::De => "DE",
44 Self::Fr => "FR",
45 Self::Gb => "GB",
46 Self::It => "IT",
47 Self::Us => "US",
48 }
49 }
50
51 /// The ISO 3166-1 alpha-3 code, accepted as an alternative spelling.
52 fn alpha3(&self) -> &'static str {
53 match self {
54 Self::Ca => "CAN",
55 Self::De => "DEU",
56 Self::Fr => "FRA",
57 Self::Gb => "GBR",
58 Self::It => "ITA",
59 Self::Us => "USA",
60 }
61 }
62
63 /// Parse an optional tag, where absent and blank both mean "no locale".
64 ///
65 /// This is the boundary helper for callers who receive a tag from a user or
66 /// another language: `None` and `""` are the same request (rank patterns
67 /// without a locale preference), and every other value must resolve.
68 ///
69 /// ```
70 /// use dataprof_core::Locale;
71 ///
72 /// assert_eq!(Locale::parse_optional(None), Ok(None));
73 /// assert_eq!(Locale::parse_optional(Some(" ")), Ok(None));
74 /// assert_eq!(Locale::parse_optional(Some("it-IT")), Ok(Some(Locale::It)));
75 /// assert!(Locale::parse_optional(Some("XX")).is_err());
76 /// ```
77 pub fn parse_optional(tag: Option<&str>) -> Result<Option<Self>, String> {
78 match tag.map(str::trim) {
79 None | Some("") => Ok(None),
80 Some(tag) => tag.parse().map(Some),
81 }
82 }
83
84 /// Resolve a normalised tag, or `None` when it names no supported locale.
85 ///
86 /// Two forms resolve: a bare region code (`"IT"`, `"it"`, `"ITA"`) and a
87 /// language-region pair, in either the BCP 47 or the POSIX spelling
88 /// (`"it-IT"`, `"it_IT"`, `"en-GB"`). A stray separator is tolerated; a
89 /// longer tag is not, because its last subtag is not its region — the
90 /// script, variant, extension and private-use subtags all sit after it, so
91 /// reading `de-CH-x-IT` as Italy would answer a Swiss request with Italy's
92 /// patterns.
93 ///
94 /// A tag naming a region the catalogue has no patterns for (`"de-CH"`) does
95 /// not fall back to its language subtag either: it names a locale dataprof
96 /// does not support, and answering with Germany's patterns would be a guess.
97 fn resolve(tag: &str) -> Option<Self> {
98 let subtags: Vec<&str> = tag
99 .split(['-', '_'])
100 .filter(|subtag| !subtag.is_empty())
101 .collect();
102
103 let region = match subtags.as_slice() {
104 // One subtag is the region itself; two are language and region.
105 [region] | [_, region] => region.to_ascii_uppercase(),
106 _ => return None,
107 };
108
109 Self::all()
110 .into_iter()
111 .find(|locale| locale.as_str() == region || locale.alpha3() == region)
112 }
113}
114
115impl std::str::FromStr for Locale {
116 type Err = String;
117
118 fn from_str(s: &str) -> Result<Self, Self::Err> {
119 Self::resolve(s).ok_or_else(|| {
120 let supported = Self::all()
121 .iter()
122 .map(|locale| locale.as_str())
123 .collect::<Vec<_>>()
124 .join(", ");
125 format!(
126 "Unknown locale: '{s}'. Supported locales: {supported} (ISO 3166-1 alpha-2). \
127 'it', 'ITA' and 'it-IT' are accepted spellings of 'IT'."
128 )
129 })
130 }
131}
132
133impl std::fmt::Display for Locale {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 write!(f, "{}", self.as_str())
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn spellings_of_the_same_locale_resolve_alike() {
145 for tag in ["IT", "it", "It", " it ", "ITA", "ita", "it-IT", "it_IT"] {
146 assert_eq!(
147 Locale::parse_optional(Some(tag)),
148 Ok(Some(Locale::It)),
149 "tag {tag:?} did not resolve to IT"
150 );
151 }
152 }
153
154 #[test]
155 fn the_region_subtag_decides_not_the_language() {
156 // en-GB and en-US share a language and select different catalogues.
157 assert_eq!(Locale::parse_optional(Some("en-GB")), Ok(Some(Locale::Gb)));
158 assert_eq!(Locale::parse_optional(Some("en-US")), Ok(Some(Locale::Us)));
159 assert_eq!(Locale::parse_optional(Some("fr-CA")), Ok(Some(Locale::Ca)));
160 }
161
162 #[test]
163 fn absent_and_blank_mean_no_locale() {
164 assert_eq!(Locale::parse_optional(None), Ok(None));
165 assert_eq!(Locale::parse_optional(Some("")), Ok(None));
166 assert_eq!(Locale::parse_optional(Some(" ")), Ok(None));
167 }
168
169 #[test]
170 fn an_unsupported_region_is_an_error_not_a_fallback() {
171 // Switzerland has no catalogue; resolving de-CH to Germany would be a
172 // guess, and accepting it silently is the bug this type exists for.
173 for tag in ["XX", "de-CH", "es", "en", "ZZZZ"] {
174 assert!(
175 tag.parse::<Locale>().is_err(),
176 "tag {tag:?} was accepted as a locale"
177 );
178 }
179 }
180
181 #[test]
182 fn a_subtag_past_the_region_is_not_read_as_the_region() {
183 // Script, variant, extension and private-use subtags all sit after the
184 // region, so the last subtag of a longer tag is not it: `de-CH-x-IT`
185 // names Switzerland, and reading it as Italy would be the same silent
186 // wrong answer from the other direction.
187 for tag in [
188 "de-CH-x-IT",
189 "it-IT-u-ca-gregory",
190 "zh-Hans-CN",
191 "sr-Latn-RS-x-US",
192 ] {
193 assert!(
194 tag.parse::<Locale>().is_err(),
195 "tag {tag:?} was accepted as a locale"
196 );
197 }
198 }
199
200 #[test]
201 fn a_stray_separator_is_tolerated() {
202 // A trailing or doubled separator is a typo for the tag, not a longer
203 // tag: the subtags it actually carries still name one region.
204 for tag in ["IT-", "_it_", "it--IT", "-IT"] {
205 assert_eq!(
206 tag.parse::<Locale>(),
207 Ok(Locale::It),
208 "tag {tag:?} should resolve to IT"
209 );
210 }
211 }
212
213 #[test]
214 fn the_error_names_the_supported_set() {
215 let error = "it-IT-u-ca-gregory".parse::<Locale>().unwrap_err();
216 assert!(error.contains("it-IT-u-ca-gregory"), "{error}");
217 for locale in Locale::all() {
218 assert!(error.contains(locale.as_str()), "{error}");
219 }
220 }
221
222 #[test]
223 fn display_round_trips_through_parse() {
224 for locale in Locale::all() {
225 assert_eq!(locale.to_string().parse::<Locale>(), Ok(locale));
226 }
227 }
228}