#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CountryCode([u8; 2]);
impl CountryCode {
pub fn new(code: &str) -> Option<Self> {
let bytes = code.as_bytes();
if bytes.len() == 2 && bytes.iter().all(u8::is_ascii_alphabetic) {
Some(Self([
bytes[0].to_ascii_uppercase(),
bytes[1].to_ascii_uppercase(),
]))
} else {
None
}
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).expect("country code is valid ASCII by construction")
}
}
impl std::fmt::Display for CountryCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Locale {
pub country: CountryCode,
pub accept_language: String,
pub languages: Vec<String>,
pub timezone: String,
}
impl Locale {
pub fn for_country(country: CountryCode) -> Option<Self> {
let (accept_language, languages, timezone): (&str, &[&str], &str) = match country.as_str() {
"US" => ("en-US,en;q=0.9", &["en-US", "en"], "America/New_York"),
"GB" => ("en-GB,en;q=0.9", &["en-GB", "en"], "Europe/London"),
"CA" => (
"en-CA,en;q=0.9,fr-CA;q=0.8",
&["en-CA", "en", "fr-CA"],
"America/Toronto",
),
"AU" => ("en-AU,en;q=0.9", &["en-AU", "en"], "Australia/Sydney"),
"DE" => (
"de-DE,de;q=0.9,en;q=0.8",
&["de-DE", "de", "en"],
"Europe/Berlin",
),
"FR" => (
"fr-FR,fr;q=0.9,en;q=0.8",
&["fr-FR", "fr", "en"],
"Europe/Paris",
),
"ES" => (
"es-ES,es;q=0.9,en;q=0.8",
&["es-ES", "es", "en"],
"Europe/Madrid",
),
"IT" => (
"it-IT,it;q=0.9,en;q=0.8",
&["it-IT", "it", "en"],
"Europe/Rome",
),
"NL" => (
"nl-NL,nl;q=0.9,en;q=0.8",
&["nl-NL", "nl", "en"],
"Europe/Amsterdam",
),
"PL" => (
"pl-PL,pl;q=0.9,en;q=0.8",
&["pl-PL", "pl", "en"],
"Europe/Warsaw",
),
"SE" => (
"sv-SE,sv;q=0.9,en;q=0.8",
&["sv-SE", "sv", "en"],
"Europe/Stockholm",
),
"BR" => (
"pt-BR,pt;q=0.9,en;q=0.8",
&["pt-BR", "pt", "en"],
"America/Sao_Paulo",
),
"MX" => (
"es-MX,es;q=0.9,en;q=0.8",
&["es-MX", "es", "en"],
"America/Mexico_City",
),
"JP" => (
"ja-JP,ja;q=0.9,en;q=0.8",
&["ja-JP", "ja", "en"],
"Asia/Tokyo",
),
"IN" => (
"en-IN,en;q=0.9,hi;q=0.8",
&["en-IN", "en", "hi"],
"Asia/Kolkata",
),
_ => return None,
};
Some(Self {
country,
accept_language: accept_language.to_string(),
languages: languages.iter().map(|s| (*s).to_string()).collect(),
timezone: timezone.to_string(),
})
}
pub fn primary_language(&self) -> &str {
self.languages.first().map_or("en-US", String::as_str)
}
}
pub trait GeoResolver: Send + Sync {
fn country_of(&self, proxy_url: &str) -> Option<CountryCode>;
}
pub fn languages_from_accept_language(accept_language: &str) -> Vec<String> {
accept_language
.split(',')
.filter_map(|part| {
let tag = part.split(';').next()?.trim();
(!tag.is_empty()).then(|| tag.to_string())
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn country_code_parses_and_normalises() {
assert_eq!(CountryCode::new("de").unwrap().as_str(), "DE");
assert_eq!(CountryCode::new("US").unwrap().to_string(), "US");
assert!(CountryCode::new("USA").is_none());
assert!(CountryCode::new("1").is_none());
assert!(CountryCode::new("u1").is_none());
}
#[test]
fn locale_for_known_country_is_coherent() {
let de = Locale::for_country(CountryCode::new("DE").unwrap()).unwrap();
assert_eq!(de.timezone, "Europe/Berlin");
assert_eq!(de.languages, vec!["de-DE", "de", "en"]);
assert!(de.accept_language.starts_with("de-DE"));
assert_eq!(de.primary_language(), "de-DE");
}
#[test]
fn locale_for_unknown_country_is_none() {
assert!(Locale::for_country(CountryCode::new("ZZ").unwrap()).is_none());
}
#[test]
fn locale_table_is_coherent_for_every_supported_country() {
let supported = [
"US", "GB", "CA", "AU", "DE", "FR", "ES", "IT", "NL", "PL", "SE", "BR", "MX", "JP",
"IN",
];
for code in supported {
let cc = CountryCode::new(code).unwrap();
let loc =
Locale::for_country(cc).unwrap_or_else(|| panic!("missing locale for {code}"));
assert_eq!(loc.country, cc);
assert!(!loc.accept_language.is_empty(), "{code} accept_language");
assert!(!loc.languages.is_empty(), "{code} languages");
assert!(
loc.timezone.contains('/'),
"{code} timezone looks like IANA"
);
assert_eq!(loc.primary_language(), loc.languages[0]);
}
}
#[test]
fn primary_language_falls_back_when_empty() {
let loc = Locale {
country: CountryCode::new("US").unwrap(),
accept_language: String::new(),
languages: Vec::new(),
timezone: String::new(),
};
assert_eq!(loc.primary_language(), "en-US");
}
#[test]
fn languages_from_accept_language_strips_quality() {
assert_eq!(
languages_from_accept_language("de-DE,de;q=0.9,en;q=0.8"),
vec!["de-DE", "de", "en"]
);
assert_eq!(
languages_from_accept_language("en-US,en;q=0.9"),
vec!["en-US", "en"]
);
assert!(languages_from_accept_language("").is_empty());
}
}