use regex::Regex;
use std::fmt;
use std::str::FromStr;
const MAX_NSID_LENGTH: usize = 317;
const MAX_SEGMENT_LENGTH: usize = 63;
const MIN_SEGMENTS: usize = 3;
static NSID_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
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()
});
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Nsid(String);
#[derive(Debug, Clone, thiserror::Error)]
#[error("Invalid NSID: {reason}")]
pub struct InvalidNsidError {
pub reason: String,
}
impl Nsid {
pub fn new(s: &str) -> Result<Self, InvalidNsidError> {
ensure_valid_nsid(s)?;
Ok(Self(s.to_string()))
}
#[must_use]
pub fn is_valid(s: &str) -> bool {
ensure_valid_nsid(s).is_ok()
}
pub fn create(authority: &str, name: &str) -> Result<Self, InvalidNsidError> {
Self::new(&format!("{authority}.{name}"))
}
#[must_use]
pub fn authority(&self) -> &str {
let last_dot = self.0.rfind('.').unwrap();
&self.0[..last_dot]
}
#[must_use]
pub fn name(&self) -> &str {
let last_dot = self.0.rfind('.').unwrap();
&self.0[last_dot + 1..]
}
#[must_use]
pub fn segments(&self) -> Vec<&str> {
self.0.split('.').collect()
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
fn ensure_valid_nsid(s: &str) -> Result<(), InvalidNsidError> {
let err = |reason: &str| InvalidNsidError {
reason: reason.to_string(),
};
if s.len() > MAX_NSID_LENGTH {
return Err(err(&format!(
"NSID is too long ({} chars, max {})",
s.len(),
MAX_NSID_LENGTH
)));
}
if !s.is_ascii() {
return Err(err("NSID must be ASCII only"));
}
let segments: Vec<&str> = s.split('.').collect();
if segments.len() < MIN_SEGMENTS {
return Err(err(&format!(
"NSID must have at least {} segments, found {}",
MIN_SEGMENTS,
segments.len()
)));
}
for segment in &segments {
if segment.is_empty() {
return Err(err("NSID segments must not be empty"));
}
if segment.len() > MAX_SEGMENT_LENGTH {
return Err(err(&format!(
"NSID segment too long ({} chars, max {})",
segment.len(),
MAX_SEGMENT_LENGTH
)));
}
}
if let Some(name) = segments.last() {
if name.starts_with(|c: char| c.is_ascii_digit()) {
return Err(err("NSID name segment must not start with a digit"));
}
if name.contains('-') {
return Err(err("NSID name segment must not contain hyphens"));
}
}
if let Some(first) = segments.first()
&& first.starts_with(|c: char| c.is_ascii_digit())
{
return Err(err("NSID first segment must not start with a digit"));
}
if !NSID_REGEX.is_match(s) {
return Err(err("NSID format is invalid"));
}
Ok(())
}
impl fmt::Display for Nsid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Nsid {
type Err = InvalidNsidError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl AsRef<str> for Nsid {
fn as_ref(&self) -> &str {
&self.0
}
}
impl serde::Serialize for Nsid {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for Nsid {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_nsids() {
let cases = [
"com.atproto.repo.createRecord",
"app.bsky.feed.post",
"com.example.fooBar",
"io.github.test",
"a.b.c",
];
for nsid in &cases {
assert!(Nsid::new(nsid).is_ok(), "should be valid: {nsid}");
}
}
#[test]
fn invalid_nsids() {
assert!(Nsid::new("").is_err(), "empty");
assert!(Nsid::new("com.example").is_err(), "only 2 segments");
assert!(
Nsid::new("com.example.123").is_err(),
"name starts with digit"
);
assert!(Nsid::new("com.example.foo-bar").is_err(), "name has hyphen");
assert!(Nsid::new("com..example.test").is_err(), "empty segment");
}
#[test]
fn authority_and_name() {
let nsid = Nsid::new("com.atproto.repo.createRecord").unwrap();
assert_eq!(nsid.authority(), "com.atproto.repo");
assert_eq!(nsid.name(), "createRecord");
}
#[test]
fn segments() {
let nsid = Nsid::new("app.bsky.feed.post").unwrap();
assert_eq!(nsid.segments(), vec!["app", "bsky", "feed", "post"]);
}
#[test]
fn serde_roundtrip() {
let nsid = Nsid::new("com.atproto.repo.createRecord").unwrap();
let json = serde_json::to_string(&nsid).unwrap();
let parsed: Nsid = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, nsid);
}
}