Skip to main content

jacquard_common/types/
language.rs

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