#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Locale {
Ca,
De,
Fr,
Gb,
It,
Us,
}
impl Locale {
pub fn all() -> Vec<Self> {
vec![Self::Ca, Self::De, Self::Fr, Self::Gb, Self::It, Self::Us]
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Ca => "CA",
Self::De => "DE",
Self::Fr => "FR",
Self::Gb => "GB",
Self::It => "IT",
Self::Us => "US",
}
}
fn alpha3(&self) -> &'static str {
match self {
Self::Ca => "CAN",
Self::De => "DEU",
Self::Fr => "FRA",
Self::Gb => "GBR",
Self::It => "ITA",
Self::Us => "USA",
}
}
pub fn parse_optional(tag: Option<&str>) -> Result<Option<Self>, String> {
match tag.map(str::trim) {
None | Some("") => Ok(None),
Some(tag) => tag.parse().map(Some),
}
}
fn resolve(tag: &str) -> Option<Self> {
let subtags: Vec<&str> = tag
.split(['-', '_'])
.filter(|subtag| !subtag.is_empty())
.collect();
let region = match subtags.as_slice() {
[region] | [_, region] => region.to_ascii_uppercase(),
_ => return None,
};
Self::all()
.into_iter()
.find(|locale| locale.as_str() == region || locale.alpha3() == region)
}
}
impl std::str::FromStr for Locale {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::resolve(s).ok_or_else(|| {
let supported = Self::all()
.iter()
.map(|locale| locale.as_str())
.collect::<Vec<_>>()
.join(", ");
format!(
"Unknown locale: '{s}'. Supported locales: {supported} (ISO 3166-1 alpha-2). \
'it', 'ITA' and 'it-IT' are accepted spellings of 'IT'."
)
})
}
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spellings_of_the_same_locale_resolve_alike() {
for tag in ["IT", "it", "It", " it ", "ITA", "ita", "it-IT", "it_IT"] {
assert_eq!(
Locale::parse_optional(Some(tag)),
Ok(Some(Locale::It)),
"tag {tag:?} did not resolve to IT"
);
}
}
#[test]
fn the_region_subtag_decides_not_the_language() {
assert_eq!(Locale::parse_optional(Some("en-GB")), Ok(Some(Locale::Gb)));
assert_eq!(Locale::parse_optional(Some("en-US")), Ok(Some(Locale::Us)));
assert_eq!(Locale::parse_optional(Some("fr-CA")), Ok(Some(Locale::Ca)));
}
#[test]
fn absent_and_blank_mean_no_locale() {
assert_eq!(Locale::parse_optional(None), Ok(None));
assert_eq!(Locale::parse_optional(Some("")), Ok(None));
assert_eq!(Locale::parse_optional(Some(" ")), Ok(None));
}
#[test]
fn an_unsupported_region_is_an_error_not_a_fallback() {
for tag in ["XX", "de-CH", "es", "en", "ZZZZ"] {
assert!(
tag.parse::<Locale>().is_err(),
"tag {tag:?} was accepted as a locale"
);
}
}
#[test]
fn a_subtag_past_the_region_is_not_read_as_the_region() {
for tag in [
"de-CH-x-IT",
"it-IT-u-ca-gregory",
"zh-Hans-CN",
"sr-Latn-RS-x-US",
] {
assert!(
tag.parse::<Locale>().is_err(),
"tag {tag:?} was accepted as a locale"
);
}
}
#[test]
fn a_stray_separator_is_tolerated() {
for tag in ["IT-", "_it_", "it--IT", "-IT"] {
assert_eq!(
tag.parse::<Locale>(),
Ok(Locale::It),
"tag {tag:?} should resolve to IT"
);
}
}
#[test]
fn the_error_names_the_supported_set() {
let error = "it-IT-u-ca-gregory".parse::<Locale>().unwrap_err();
assert!(error.contains("it-IT-u-ca-gregory"), "{error}");
for locale in Locale::all() {
assert!(error.contains(locale.as_str()), "{error}");
}
}
#[test]
fn display_round_trips_through_parse() {
for locale in Locale::all() {
assert_eq!(locale.to_string().parse::<Locale>(), Ok(locale));
}
}
}