Skip to main content

jacquard_common/types/
nsid.rs

1use crate::bos::{Bos, DefaultStr};
2use crate::types::recordkey::RecordKeyType;
3use crate::types::string::{AtStrError, StrParseKind};
4use crate::{CowStr, IntoStatic};
5use alloc::string::{String, ToString};
6use core::fmt;
7use core::ops::Deref;
8use core::str::FromStr;
9#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
10use regex::Regex;
11#[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))]
12use regex_automata::meta::Regex;
13#[cfg(target_arch = "wasm32")]
14use regex_lite::Regex;
15use serde::{Deserialize, Deserializer, Serialize};
16use smol_str::{SmolStr, ToSmolStr};
17
18use super::Lazy;
19
20/// Namespaced Identifier (NSID) for Lexicon schemas and XRPC endpoints.
21///
22/// See: <https://atproto.com/specs/nsid>
23#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
24#[serde(transparent)]
25#[repr(transparent)]
26pub struct Nsid<S: Bos<str> = DefaultStr>(pub(crate) S);
27
28/// Regex for NSID validation per AT Protocol spec.
29pub static NSID_REGEX: Lazy<Regex> = Lazy::new(|| {
30    Regex::new(r"^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z][a-zA-Z0-9]{0,62})$").unwrap()
31});
32
33/// Validate an NSID string without constructing an `Nsid<S>`.
34///
35/// Checks length (≤317) and format against `NSID_REGEX`. Returns `Ok(())`
36/// if valid. Use this when you need validation without allocation.
37pub fn validate_nsid(nsid: &str) -> Result<(), AtStrError> {
38    if nsid.len() > 317 {
39        Err(AtStrError::too_long("nsid", nsid, 317, nsid.len()))
40    } else if !NSID_REGEX.is_match(nsid) {
41        Err(AtStrError::regex(
42            "nsid",
43            nsid,
44            SmolStr::new_static("invalid"),
45        ))
46    } else {
47        Ok(())
48    }
49}
50
51impl<S: Bos<str> + AsRef<str>> Nsid<S> {
52    /// Get the NSID as a string slice.
53    pub fn as_str(&self) -> &str {
54        self.0.as_ref()
55    }
56
57    /// Returns the domain authority part of the NSID.
58    pub fn domain_authority(&self) -> &str {
59        let s = self.as_str();
60        let split = s.rfind('.').expect("enforced by constructor");
61        &s[..split]
62    }
63
64    /// Returns the name segment of the NSID.
65    pub fn name(&self) -> &str {
66        let s = self.as_str();
67        let split = s.rfind('.').expect("enforced by constructor");
68        &s[split + 1..]
69    }
70}
71
72impl<S: Bos<str>> Nsid<S> {
73    /// # Safety
74    ///
75    /// The caller must ensure the NSID is valid.
76    pub unsafe fn unchecked(nsid: S) -> Self {
77        Nsid(nsid)
78    }
79
80    /// Borrow as an `Nsid<&str>`, analogous to `Uri::borrow()`.
81    pub fn borrow(&self) -> Nsid<&str>
82    where
83        S: AsRef<str>,
84    {
85        // SAFETY: self is already validated.
86        unsafe { Nsid::unchecked(self.0.as_ref()) }
87    }
88}
89
90impl<S: Bos<str> + AsRef<str>> Nsid<S> {
91    /// Fallible constructor, validates, wraps the input directly.
92    pub fn new(s: S) -> Result<Self, AtStrError> {
93        validate_nsid(s.as_ref())?;
94        Ok(Self(s))
95    }
96
97    /// Infallible constructor. Panics on invalid NSIDs.
98    pub fn raw(s: S) -> Self {
99        Self::new(s).expect("invalid NSID")
100    }
101}
102
103impl<S: Bos<str> + FromStr> Nsid<S> {
104    /// Fallible constructor, validates, takes ownership.
105    pub fn new_owned(nsid: impl AsRef<str>) -> Result<Self, AtStrError> {
106        let nsid = nsid.as_ref();
107        validate_nsid(nsid)?;
108        let s = S::from_str(nsid)
109            .map_err(|_| AtStrError::new("nsid", nsid.to_string(), StrParseKind::Conversion))?;
110        Ok(Self(s))
111    }
112
113    /// Fallible constructor for static strings.
114    pub fn new_static(nsid: &'static str) -> Result<Self, AtStrError> {
115        validate_nsid(nsid)?;
116        let s = S::from_str(nsid)
117            .map_err(|_| AtStrError::new("nsid", nsid.to_string(), StrParseKind::Conversion))?;
118        Ok(Self(s))
119    }
120}
121
122impl<'de, S> Deserialize<'de> for Nsid<S>
123where
124    S: Bos<str> + AsRef<str> + Deserialize<'de>,
125{
126    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127    where
128        D: Deserializer<'de>,
129    {
130        let s = S::deserialize(deserializer)?;
131        validate_nsid(s.as_ref()).map_err(serde::de::Error::custom)?;
132        Ok(Nsid(s))
133    }
134}
135
136impl<S: Bos<str> + IntoStatic> IntoStatic for Nsid<S>
137where
138    S::Output: Bos<str>,
139{
140    type Output = Nsid<S::Output>;
141
142    fn into_static(self) -> Self::Output {
143        Nsid(self.0.into_static())
144    }
145}
146
147impl<S: Bos<str>> Nsid<S> {
148    /// Convert to an `Nsid` with a different backing type.
149    pub fn convert<B: Bos<str> + From<S>>(self) -> Nsid<B> {
150        Nsid(B::from(self.0))
151    }
152}
153
154impl<S: Bos<str> + FromStr> FromStr for Nsid<S> {
155    type Err = AtStrError;
156
157    fn from_str(s: &str) -> Result<Self, Self::Err> {
158        Self::new_owned(s)
159    }
160}
161
162impl<S: Bos<str> + AsRef<str>> fmt::Display for Nsid<S> {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.write_str(self.as_str())
165    }
166}
167
168impl<S: Bos<str> + AsRef<str>> fmt::Debug for Nsid<S> {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        write!(f, "at://{}", self.as_str())
171    }
172}
173
174impl<S: Bos<str> + AsRef<str>> From<Nsid<S>> for String {
175    fn from(value: Nsid<S>) -> Self {
176        value.as_str().to_string()
177    }
178}
179
180impl<S: Bos<str> + AsRef<str>> From<Nsid<S>> for SmolStr {
181    fn from(value: Nsid<S>) -> Self {
182        value.as_str().to_smolstr()
183    }
184}
185
186impl From<String> for Nsid {
187    fn from(value: String) -> Self {
188        Self::new_owned(value).unwrap()
189    }
190}
191
192impl<'n> From<CowStr<'n>> for Nsid<CowStr<'n>> {
193    fn from(value: CowStr<'n>) -> Self {
194        Self::new(value).unwrap()
195    }
196}
197
198impl From<SmolStr> for Nsid {
199    fn from(value: SmolStr) -> Self {
200        Self::new_owned(value).unwrap()
201    }
202}
203
204impl<S: Bos<str> + AsRef<str>> AsRef<str> for Nsid<S> {
205    fn as_ref(&self) -> &str {
206        self.as_str()
207    }
208}
209
210impl<S: Bos<str> + AsRef<str>> Deref for Nsid<S> {
211    type Target = str;
212
213    fn deref(&self) -> &Self::Target {
214        self.as_str()
215    }
216}
217
218unsafe impl<S: Bos<str> + AsRef<str> + Clone + Serialize> RecordKeyType for Nsid<S> {
219    fn as_str(&self) -> &str {
220        self.as_str()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn valid_nsids() {
230        assert!(Nsid::<&str>::new("com.example.foo").is_ok());
231        assert!(Nsid::<&str>::new("com.example.fooBar").is_ok());
232        assert!(Nsid::<&str>::new("com.long-domain.foo").is_ok());
233        assert!(Nsid::<&str>::new("a.b.c").is_ok());
234        assert!(Nsid::<&str>::new("a1.b2.c3").is_ok());
235    }
236
237    #[test]
238    fn minimum_segments() {
239        assert!(Nsid::<&str>::new("a.b.c").is_ok());
240        assert!(Nsid::<&str>::new("a.b").is_err());
241        assert!(Nsid::<&str>::new("a").is_err());
242    }
243
244    #[test]
245    fn domain_and_name_parsing() {
246        let nsid = Nsid::<&str>::new("com.example.fooBar").unwrap();
247        assert_eq!(nsid.domain_authority(), "com.example");
248        assert_eq!(nsid.name(), "fooBar");
249    }
250
251    #[test]
252    fn max_length() {
253        let s1 = format!("a{}a", "b".repeat(61));
254        let s2 = format!("c{}c", "d".repeat(61));
255        let s3 = format!("e{}e", "f".repeat(61));
256        let s4 = format!("g{}g", "h".repeat(61));
257        let s5 = format!("i{}i", "j".repeat(59));
258        let valid_317 = format!("{s1}.{s2}.{s3}.{s4}.{s5}");
259        assert_eq!(valid_317.len(), 317);
260        assert!(Nsid::<&str>::new(&valid_317).is_ok());
261
262        let s5_long = format!("i{}i", "j".repeat(60));
263        let too_long_318 = format!("{s1}.{s2}.{s3}.{s4}.{s5_long}");
264        assert_eq!(too_long_318.len(), 318);
265        assert!(Nsid::<&str>::new(&too_long_318).is_err());
266    }
267
268    #[test]
269    fn segment_length() {
270        let valid_63 = format!("{}.{}.foo", "a".repeat(63), "b".repeat(63));
271        assert!(Nsid::<&str>::new(&valid_63).is_ok());
272
273        let too_long_64 = format!("{}.b.foo", "a".repeat(64));
274        assert!(Nsid::<&str>::new(&too_long_64).is_err());
275    }
276
277    #[test]
278    fn first_segment_cannot_start_with_digit() {
279        assert!(Nsid::<&str>::new("com.example.foo").is_ok());
280        assert!(Nsid::<&str>::new("9com.example.foo").is_err());
281    }
282
283    #[test]
284    fn name_segment_rules() {
285        assert!(Nsid::<&str>::new("com.example.foo").is_ok());
286        assert!(Nsid::<&str>::new("com.example.fooBar123").is_ok());
287        assert!(Nsid::<&str>::new("com.example.9foo").is_err());
288        assert!(Nsid::<&str>::new("com.example.foo-bar").is_err());
289    }
290
291    #[test]
292    fn domain_segment_rules() {
293        assert!(Nsid::<&str>::new("foo-bar.example.baz").is_ok());
294        assert!(Nsid::<&str>::new("foo.bar-baz.qux").is_ok());
295        assert!(Nsid::<&str>::new("-foo.bar.baz").is_err());
296        assert!(Nsid::<&str>::new("foo-.bar.baz").is_err());
297    }
298
299    #[test]
300    fn case_sensitivity() {
301        assert!(Nsid::<&str>::new("com.example.fooBar").is_ok());
302        assert!(Nsid::<&str>::new("com.example.FooBar").is_ok());
303    }
304
305    #[test]
306    fn into_static() {
307        let n = Nsid::<&str>::new("com.example.foo").unwrap();
308        let owned: Nsid<SmolStr> = n.into_static();
309        assert_eq!(owned.as_str(), "com.example.foo");
310    }
311}