jacquard_common/types/
language.rs

1use serde::{Deserialize, Deserializer, Serialize, de::Error};
2use smol_str::{SmolStr, ToSmolStr};
3use std::fmt;
4use std::{ops::Deref, str::FromStr};
5
6use crate::CowStr;
7
8/// IETF BCP 47 language tag for AT Protocol
9///
10/// Language tags identify natural languages following the BCP 47 standard. They consist of
11/// a 2-3 character language code (e.g., "en", "ja") with optional regional subtags (e.g., "pt-BR").
12///
13/// Examples: `"ja"` (Japanese), `"pt-BR"` (Brazilian Portuguese), `"en-US"` (US English)
14///
15/// Language tags require semantic parsing rather than simple string comparison.
16/// Uses the `langtag` crate for validation but stores as `SmolStr` for efficiency.
17/// TODO: Implement langtag-style semantic matching for this type, delegating to langtag
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
19#[serde(transparent)]
20#[repr(transparent)]
21pub struct Language(SmolStr);
22
23impl Language {
24    /// Parses an IETF language tag from the given string.
25    pub fn new<T>(lang: &T) -> Result<Self, langtag::InvalidLangTag<&T>>
26    where
27        T: AsRef<str> + ?Sized,
28    {
29        let tag = langtag::LangTag::new(lang)?;
30        Ok(Language(SmolStr::new(tag.as_str())))
31    }
32
33    /// Parses an IETF language tag from a static string.
34    pub fn new_static(lang: &'static str) -> Result<Self, langtag::InvalidLangTag<&'static str>> {
35        let tag = langtag::LangTag::new(lang)?;
36        Ok(Language(SmolStr::new_static(tag.as_str())))
37    }
38
39    /// Infallible constructor for when you *know* the string is a valid IETF language tag.
40    /// Will panic on invalid tag. If you're manually decoding atproto records
41    /// or API values you know are valid (rather than using serde), this is the one to use.
42    /// The `From<String>` and `From<CowStr>` impls use the same logic.
43    pub fn raw(lang: impl AsRef<str>) -> Self {
44        let lang = lang.as_ref();
45        let tag = langtag::LangTag::new(lang).expect("valid IETF language tag");
46        Language(SmolStr::new(tag.as_str()))
47    }
48
49    /// Infallible constructor for when you *know* the string is a valid IETF language tag.
50    /// Marked unsafe because responsibility for upholding the invariant is on the developer.
51    pub unsafe fn unchecked(lang: impl AsRef<str>) -> Self {
52        let lang = lang.as_ref();
53        Self(SmolStr::new(lang))
54    }
55
56    /// Returns the LANG as a string slice.
57    pub fn as_str(&self) -> &str {
58        {
59            let this = &self.0;
60            this
61        }
62    }
63}
64
65impl FromStr for Language {
66    type Err = SmolStr;
67
68    fn from_str(s: &str) -> Result<Self, Self::Err> {
69        Self::new(s).map_err(|e| e.0.to_smolstr())
70    }
71}
72
73impl<'de> Deserialize<'de> for Language {
74    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
75    where
76        D: Deserializer<'de>,
77    {
78        let value: &str = Deserialize::deserialize(deserializer)?;
79        Self::new(value).map_err(D::Error::custom)
80    }
81}
82
83impl fmt::Display for Language {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.write_str(&self.0)
86    }
87}
88
89impl From<Language> for String {
90    fn from(value: Language) -> Self {
91        value.0.to_string()
92    }
93}
94
95impl From<Language> for SmolStr {
96    fn from(value: Language) -> Self {
97        value.0
98    }
99}
100
101impl From<String> for Language {
102    fn from(value: String) -> Self {
103        Self::raw(&value)
104    }
105}
106
107impl<'t> From<CowStr<'t>> for Language {
108    fn from(value: CowStr<'t>) -> Self {
109        Self::raw(&value)
110    }
111}
112
113impl AsRef<str> for Language {
114    fn as_ref(&self) -> &str {
115        self.as_str()
116    }
117}
118
119impl Deref for Language {
120    type Target = str;
121
122    fn deref(&self) -> &Self::Target {
123        self.as_str()
124    }
125}
126
127impl crate::IntoStatic for Language {
128    type Output = Language;
129
130    fn into_static(self) -> Self::Output {
131        self
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn valid_language_tags() {
141        assert!(Language::new("en").is_ok());
142        assert!(Language::new("en-US").is_ok());
143        assert!(Language::new("zh-Hans").is_ok());
144        assert!(Language::new("es-419").is_ok());
145    }
146
147    #[test]
148    fn case_insensitive_but_preserves() {
149        let lang = Language::new("en-US").unwrap();
150        assert_eq!(lang.as_str(), "en-US");
151    }
152
153    #[test]
154    fn invalid_tags() {
155        assert!(Language::new("").is_err());
156        assert!(Language::new("not_a_tag").is_err());
157        assert!(Language::new("123").is_err());
158    }
159}