Skip to main content

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}