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