proto_blue_syntax/
nsid.rs1use regex::Regex;
7use std::fmt;
8use std::str::FromStr;
9
10const MAX_NSID_LENGTH: usize = 317;
12
13const MAX_SEGMENT_LENGTH: usize = 63;
15
16const MIN_SEGMENTS: usize = 3;
18
19static NSID_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
20 Regex::new(
21 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})?)$",
22 )
23 .unwrap()
24});
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub struct Nsid(String);
31
32#[derive(Debug, Clone, thiserror::Error)]
34#[error("Invalid NSID: {reason}")]
35pub struct InvalidNsidError {
36 pub reason: String,
37}
38
39impl Nsid {
40 pub fn new(s: &str) -> Result<Self, InvalidNsidError> {
42 ensure_valid_nsid(s)?;
43 Ok(Self(s.to_string()))
44 }
45
46 #[must_use]
48 pub fn is_valid(s: &str) -> bool {
49 ensure_valid_nsid(s).is_ok()
50 }
51
52 pub fn create(authority: &str, name: &str) -> Result<Self, InvalidNsidError> {
58 Self::new(&format!("{authority}.{name}"))
59 }
60
61 #[must_use]
65 pub fn authority(&self) -> &str {
66 let last_dot = self.0.rfind('.').unwrap();
67 &self.0[..last_dot]
68 }
69
70 #[must_use]
74 pub fn name(&self) -> &str {
75 let last_dot = self.0.rfind('.').unwrap();
76 &self.0[last_dot + 1..]
77 }
78
79 #[must_use]
81 pub fn segments(&self) -> Vec<&str> {
82 self.0.split('.').collect()
83 }
84
85 #[must_use]
87 pub fn as_str(&self) -> &str {
88 &self.0
89 }
90
91 #[must_use]
93 pub fn into_inner(self) -> String {
94 self.0
95 }
96}
97
98fn ensure_valid_nsid(s: &str) -> Result<(), InvalidNsidError> {
99 let err = |reason: &str| InvalidNsidError {
100 reason: reason.to_string(),
101 };
102
103 if s.len() > MAX_NSID_LENGTH {
104 return Err(err(&format!(
105 "NSID is too long ({} chars, max {})",
106 s.len(),
107 MAX_NSID_LENGTH
108 )));
109 }
110
111 if !s.is_ascii() {
112 return Err(err("NSID must be ASCII only"));
113 }
114
115 let segments: Vec<&str> = s.split('.').collect();
116
117 if segments.len() < MIN_SEGMENTS {
118 return Err(err(&format!(
119 "NSID must have at least {} segments, found {}",
120 MIN_SEGMENTS,
121 segments.len()
122 )));
123 }
124
125 for segment in &segments {
126 if segment.is_empty() {
127 return Err(err("NSID segments must not be empty"));
128 }
129 if segment.len() > MAX_SEGMENT_LENGTH {
130 return Err(err(&format!(
131 "NSID segment too long ({} chars, max {})",
132 segment.len(),
133 MAX_SEGMENT_LENGTH
134 )));
135 }
136 }
137
138 if let Some(name) = segments.last() {
140 if name.starts_with(|c: char| c.is_ascii_digit()) {
141 return Err(err("NSID name segment must not start with a digit"));
142 }
143 if name.contains('-') {
144 return Err(err("NSID name segment must not contain hyphens"));
145 }
146 }
147
148 if let Some(first) = segments.first()
150 && first.starts_with(|c: char| c.is_ascii_digit())
151 {
152 return Err(err("NSID first segment must not start with a digit"));
153 }
154
155 if !NSID_REGEX.is_match(s) {
156 return Err(err("NSID format is invalid"));
157 }
158
159 Ok(())
160}
161
162impl fmt::Display for Nsid {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.write_str(&self.0)
165 }
166}
167
168impl FromStr for Nsid {
169 type Err = InvalidNsidError;
170 fn from_str(s: &str) -> Result<Self, Self::Err> {
171 Self::new(s)
172 }
173}
174
175impl AsRef<str> for Nsid {
176 fn as_ref(&self) -> &str {
177 &self.0
178 }
179}
180
181impl serde::Serialize for Nsid {
182 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
183 self.0.serialize(serializer)
184 }
185}
186
187impl<'de> serde::Deserialize<'de> for Nsid {
188 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
189 let s = String::deserialize(deserializer)?;
190 Self::new(&s).map_err(serde::de::Error::custom)
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn valid_nsids() {
200 let cases = [
201 "com.atproto.repo.createRecord",
202 "app.bsky.feed.post",
203 "com.example.fooBar",
204 "io.github.test",
205 "a.b.c",
206 ];
207 for nsid in &cases {
208 assert!(Nsid::new(nsid).is_ok(), "should be valid: {nsid}");
209 }
210 }
211
212 #[test]
213 fn invalid_nsids() {
214 assert!(Nsid::new("").is_err(), "empty");
215 assert!(Nsid::new("com.example").is_err(), "only 2 segments");
216 assert!(
217 Nsid::new("com.example.123").is_err(),
218 "name starts with digit"
219 );
220 assert!(Nsid::new("com.example.foo-bar").is_err(), "name has hyphen");
221 assert!(Nsid::new("com..example.test").is_err(), "empty segment");
222 }
223
224 #[test]
225 fn authority_and_name() {
226 let nsid = Nsid::new("com.atproto.repo.createRecord").unwrap();
227 assert_eq!(nsid.authority(), "com.atproto.repo");
228 assert_eq!(nsid.name(), "createRecord");
229 }
230
231 #[test]
232 fn segments() {
233 let nsid = Nsid::new("app.bsky.feed.post").unwrap();
234 assert_eq!(nsid.segments(), vec!["app", "bsky", "feed", "post"]);
235 }
236
237 #[test]
238 fn serde_roundtrip() {
239 let nsid = Nsid::new("com.atproto.repo.createRecord").unwrap();
240 let json = serde_json::to_string(&nsid).unwrap();
241 let parsed: Nsid = serde_json::from_str(&json).unwrap();
242 assert_eq!(parsed, nsid);
243 }
244}