use std::fmt;
use std::str::FromStr;
use language_tags::LanguageTag;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Language {
canonical: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum LanguageError {
#[error("language tag is empty")]
Empty,
#[error("invalid BCP 47 language tag {input:?}: {reason}")]
Invalid {
input: String,
reason: String,
},
}
impl Language {
pub fn parse(input: impl Into<String>) -> Result<Self, LanguageError> {
let canonical: String = input.into();
if canonical.is_empty() {
return Err(LanguageError::Empty);
}
LanguageTag::parse(&canonical).map_err(|e| LanguageError::Invalid {
input: canonical.clone(),
reason: e.to_string(),
})?;
Ok(Self { canonical })
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.canonical
}
pub fn to_language_tag(&self) -> Result<LanguageTag, language_tags::ParseError> {
LanguageTag::parse(&self.canonical)
}
}
impl fmt::Display for Language {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.canonical)
}
}
impl FromStr for Language {
type Err = LanguageError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for Language {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl std::ops::Deref for Language {
type Target = str;
fn deref(&self) -> &str {
&self.canonical
}
}
impl std::borrow::Borrow<str> for Language {
fn borrow(&self) -> &str {
&self.canonical
}
}
impl Serialize for Language {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.canonical.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Language {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_primary_language_only() {
let l = Language::parse("en").unwrap();
assert_eq!(l.as_str(), "en");
}
#[test]
fn parses_language_region() {
let l = Language::parse("en-US").unwrap();
assert_eq!(l.as_str(), "en-US");
}
#[test]
fn parses_language_script_region() {
let l = Language::parse("zh-Hant-TW").unwrap();
assert_eq!(l.as_str(), "zh-Hant-TW");
}
#[test]
fn parses_language_with_variant() {
let l = Language::parse("de-DE-1996").unwrap();
assert_eq!(l.as_str(), "de-DE-1996");
}
#[test]
fn parses_grandfathered_tag() {
let l = Language::parse("i-klingon").unwrap();
assert_eq!(l.as_str(), "i-klingon");
}
#[test]
fn preserves_input_case() {
let l = Language::parse("en-us").unwrap();
assert_eq!(l.as_str(), "en-us");
}
#[test]
fn rejects_empty() {
assert_eq!(Language::parse(""), Err(LanguageError::Empty));
}
#[test]
fn rejects_double_dash() {
let result = Language::parse("en--US");
assert!(matches!(result, Err(LanguageError::Invalid { .. })));
}
#[test]
fn rejects_subtag_too_long() {
let result = Language::parse("abcdefghi");
assert!(matches!(result, Err(LanguageError::Invalid { .. })));
}
#[test]
fn rejects_leading_dash() {
let result = Language::parse("-en");
assert!(matches!(result, Err(LanguageError::Invalid { .. })));
}
#[test]
fn round_trips_through_serde() {
let l = Language::parse("zh-Hant-TW").unwrap();
let json = serde_json::to_string(&l).unwrap();
assert_eq!(json, "\"zh-Hant-TW\"");
let back: Language = serde_json::from_str(&json).unwrap();
assert_eq!(back, l);
}
#[test]
fn from_str_works() {
let l: Language = "en-GB".parse().unwrap();
assert_eq!(l.as_str(), "en-GB");
}
#[test]
fn to_language_tag_exposes_subtag_access() {
let l = Language::parse("zh-Hant-TW").unwrap();
let tag = l.to_language_tag().unwrap();
assert_eq!(tag.primary_language(), "zh");
assert_eq!(tag.script(), Some("Hant"));
assert_eq!(tag.region(), Some("TW"));
}
}