use crate::bos::{BorrowOrShare, Bos};
use crate::types::ident::AtIdentifier;
use crate::types::nsid::Nsid;
use crate::types::recordkey::{RecordKey, Rkey};
use crate::types::string::{AtStrError, StrParseKind};
use crate::{CowStr, DefaultStr, IntoStatic};
use alloc::format;
use alloc::string::String;
use alloc::string::ToString;
use core::fmt;
use core::hash::{Hash, Hasher};
use core::num::NonZeroU16;
use core::ops::Deref;
use core::str::FromStr;
#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
use regex::Regex;
#[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))]
use regex_automata::meta::Regex;
#[cfg(target_arch = "wasm32")]
use regex_lite::Regex;
use serde::Serializer;
use serde::{Deserialize, Deserializer, Serialize, de::Error};
use smol_str::SmolStr;
use super::Lazy;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct AtUriIndices {
first_slash: Option<NonZeroU16>,
second_slash: Option<NonZeroU16>,
hash: Option<NonZeroU16>,
}
impl AtUriIndices {
fn authority_end(&self, len: usize) -> usize {
self.first_slash
.or(self.hash)
.map(|n| n.get() as usize)
.unwrap_or(len)
}
fn collection_end(&self, len: usize) -> usize {
self.second_slash
.or(self.hash)
.map(|n| n.get() as usize)
.unwrap_or(len)
}
fn rkey_end(&self, len: usize) -> usize {
self.hash.map(|n| n.get() as usize).unwrap_or(len)
}
}
#[derive(Clone, Debug)]
pub struct AtUri<S: Bos<str> + AsRef<str> = DefaultStr> {
uri: S,
indices: AtUriIndices,
}
impl<S: Bos<str> + AsRef<str>> PartialEq for AtUri<S> {
fn eq(&self, other: &Self) -> bool {
self.uri.as_ref() == other.uri.as_ref()
}
}
impl<S: Bos<str> + AsRef<str>> Eq for AtUri<S> {}
impl<S: Bos<str> + AsRef<str>> Hash for AtUri<S> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.uri.as_ref().hash(state);
}
}
pub struct RepoPath<S: Bos<str> + AsRef<str> = DefaultStr> {
pub collection: Nsid<S>,
pub rkey: Option<RecordKey<Rkey<S>>>,
}
impl<S: Bos<str> + AsRef<str> + Clone> Clone for RepoPath<S> {
fn clone(&self) -> Self {
RepoPath {
collection: self.collection.clone(),
rkey: self.rkey.clone(),
}
}
}
impl<S: Bos<str> + AsRef<str>> PartialEq for RepoPath<S> {
fn eq(&self, other: &Self) -> bool {
self.collection.as_str() == other.collection.as_str()
&& match (&self.rkey, &other.rkey) {
(Some(a), Some(b)) => a.as_ref() == b.as_ref(),
(None, None) => true,
_ => false,
}
}
}
impl<S: Bos<str> + AsRef<str>> Eq for RepoPath<S> {}
impl<S: Bos<str> + AsRef<str>> Hash for RepoPath<S> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.collection.as_str().hash(state);
if let Some(rkey) = &self.rkey {
rkey.as_ref().hash(state);
}
}
}
impl<S: Bos<str> + AsRef<str>> fmt::Debug for RepoPath<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RepoPath")
.field("collection", &self.collection.as_str())
.field("rkey", &self.rkey.as_ref().map(|r| r.as_ref()))
.finish()
}
}
impl<S: Bos<str> + AsRef<str>> fmt::Display for RepoPath<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "/{}", self.collection)?;
if let Some(rkey) = &self.rkey {
write!(f, "/{}", rkey.as_ref())?;
}
Ok(())
}
}
impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for RepoPath<S>
where
S::Output: Bos<str> + AsRef<str>,
{
type Output = RepoPath<S::Output>;
fn into_static(self) -> Self::Output {
RepoPath {
collection: self.collection.into_static(),
rkey: self.rkey.map(|rkey| rkey.into_static()),
}
}
}
impl<S: Bos<str> + AsRef<str>> RepoPath<S> {
pub fn convert<B: Bos<str> + AsRef<str> + From<S>>(self) -> RepoPath<B> {
RepoPath {
collection: self.collection.convert(),
rkey: self.rkey.map(|rkey| RecordKey(rkey.0.convert())),
}
}
}
pub type UriPathBuf = RepoPath<SmolStr>;
pub static ATURI_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r##"^at://(?<authority>[a-zA-Z0-9._:%-]+)(/(?<collection>[a-zA-Z0-9-.]+)(/(?<rkey>[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?<fragment>/[a-zA-Z0-9._~:@!$&%')(*+,;=\-\[\]/\\]*))?$"##).unwrap()
});
pub(crate) fn validate_and_index(uri: &str) -> Result<AtUriIndices, AtStrError> {
let Some(parts) = ATURI_REGEX.captures(uri) else {
return Err(AtStrError::regex(
"at-uri-scheme",
uri,
SmolStr::new_static("doesn't match schema"),
));
};
let Some(authority) = parts.name("authority") else {
return Err(AtStrError::missing("at-uri-scheme", uri, "authority"));
};
AtIdentifier::new(authority.as_str())
.map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?;
if let Some(collection) = parts.name("collection") {
Nsid::new(collection.as_str())
.map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?;
if let Some(rkey) = parts.name("rkey") {
Rkey::new(rkey.as_str())
.map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?;
}
}
Ok(extract_indices(uri))
}
fn extract_indices(uri: &str) -> AtUriIndices {
let bytes = uri.as_bytes();
let mut first_slash = None;
let mut second_slash = None;
let mut hash = None;
let mut i = 5;
while i < bytes.len() {
match bytes[i] {
b'/' if first_slash.is_none() => {
first_slash = NonZeroU16::new(i as u16);
}
b'/' if second_slash.is_none() => {
second_slash = NonZeroU16::new(i as u16);
}
b'#' => {
hash = NonZeroU16::new(i as u16);
break; }
_ => {}
}
i += 1;
}
AtUriIndices {
first_slash,
second_slash,
hash,
}
}
impl<S: Bos<str> + AsRef<str>> AtUri<S> {
pub unsafe fn unchecked(uri: S) -> Self {
let indices = extract_indices(uri.as_ref());
AtUri { uri, indices }
}
pub(crate) unsafe fn from_parts(uri: S, indices: AtUriIndices) -> Self {
AtUri { uri, indices }
}
}
impl<S: Bos<str> + AsRef<str>> AtUri<S> {
pub fn new(uri: S) -> Result<Self, AtStrError> {
let indices = validate_and_index(uri.as_ref())?;
Ok(AtUri { uri, indices })
}
pub fn raw(uri: S) -> Self {
Self::new(uri).expect("valid AT URI")
}
}
impl<S: Bos<str> + AsRef<str> + FromStr> AtUri<S> {
pub fn new_owned(uri: impl AsRef<str>) -> Result<Self, AtStrError> {
let uri_str = uri.as_ref();
let indices = validate_and_index(uri_str)?;
let s = S::from_str(uri_str).map_err(|_| {
AtStrError::new(
"at-uri-scheme",
uri_str.to_string(),
StrParseKind::Conversion,
)
})?;
Ok(AtUri { uri: s, indices })
}
pub fn from_parts_owned(
authority: impl AsRef<str>,
collection: impl AsRef<str>,
rkey: impl AsRef<str>,
) -> Result<Self, AtStrError> {
let (authority, collection, rkey) =
(authority.as_ref(), collection.as_ref(), rkey.as_ref());
if authority.is_empty() || (collection.is_empty() && !rkey.is_empty()) {
Err(AtStrError::missing(
"at-uri-scheme",
&format!("at://{}/{}/{}", authority, collection, rkey),
"correct uri path",
))
} else if collection.is_empty() && rkey.is_empty() {
let uri = format!("at://{}", authority);
Self::new_owned(uri)
} else if rkey.is_empty() {
let uri = format!("at://{}/{}", authority, collection);
Self::new_owned(uri)
} else {
let uri = format!("at://{}/{}/{}", authority, collection, rkey);
Self::new_owned(uri)
}
}
pub fn new_static(uri: &'static str) -> Result<Self, AtStrError> {
let indices = validate_and_index(uri)?;
let s = S::from_str(uri).map_err(|_| {
AtStrError::new("at-uri-scheme", uri.to_string(), StrParseKind::Conversion)
})?;
Ok(AtUri { uri: s, indices })
}
}
impl<S: Bos<str> + AsRef<str>> AtUri<S> {
pub fn as_str(&self) -> &str {
self.uri.as_ref()
}
pub fn authority<'i, 'o>(&'i self) -> AtIdentifier<&'o str>
where
S: BorrowOrShare<'i, 'o, str>,
{
let s: &'o str = self.uri.borrow_or_share();
let end = self.indices.authority_end(s.len());
unsafe { AtIdentifier::unchecked(&s[5..end]) }
}
pub fn collection<'i, 'o>(&'i self) -> Option<Nsid<&'o str>>
where
S: BorrowOrShare<'i, 'o, str>,
{
let idx = self.indices.first_slash?.get() as usize;
let s: &'o str = self.uri.borrow_or_share();
let end = self.indices.collection_end(s.len());
Some(unsafe { Nsid::unchecked(&s[idx + 1..end]) })
}
pub fn rkey<'i, 'o>(&'i self) -> Option<Rkey<&'o str>>
where
S: BorrowOrShare<'i, 'o, str>,
{
let idx = self.indices.second_slash?.get() as usize;
let s: &'o str = self.uri.borrow_or_share();
let end = self.indices.rkey_end(s.len());
Some(unsafe { Rkey::unchecked(&s[idx + 1..end]) })
}
pub fn path<'i, 'o>(&'i self) -> Option<RepoPath<&'o str>>
where
S: BorrowOrShare<'i, 'o, str>,
{
let slash = self.indices.first_slash?.get() as usize;
let s: &'o str = self.uri.borrow_or_share();
let col_end = self.indices.collection_end(s.len());
let collection = unsafe { Nsid::unchecked(&s[slash + 1..col_end]) };
let rkey = self.indices.second_slash.map(|idx| {
let rkey_end = self.indices.rkey_end(s.len());
RecordKey(unsafe { Rkey::unchecked(&s[idx.get() as usize + 1..rkey_end]) })
});
Some(RepoPath { collection, rkey })
}
pub fn fragment<'i, 'o>(&'i self) -> Option<&'o str>
where
S: BorrowOrShare<'i, 'o, str>,
{
let idx = self.indices.hash?.get() as usize;
let s: &'o str = self.uri.borrow_or_share();
Some(&s[idx + 1..])
}
}
impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for AtUri<S>
where
S::Output: Bos<str> + AsRef<str>,
{
type Output = AtUri<S::Output>;
fn into_static(self) -> AtUri<S::Output> {
AtUri {
uri: self.uri.into_static(),
indices: self.indices,
}
}
}
impl<S: Bos<str> + AsRef<str>> AtUri<S> {
pub fn convert<B: Bos<str> + AsRef<str> + From<S>>(self) -> AtUri<B> {
AtUri {
uri: B::from(self.uri),
indices: self.indices,
}
}
}
impl<'de, S> Deserialize<'de> for AtUri<S>
where
S: Bos<str> + AsRef<str> + Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = S::deserialize(deserializer)?;
let indices = validate_and_index(s.as_ref()).map_err(D::Error::custom)?;
Ok(AtUri { uri: s, indices })
}
}
impl<S: Bos<str> + AsRef<str>> Serialize for AtUri<S> {
fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
where
Ser: Serializer,
{
serializer.serialize_str(self.uri.as_ref())
}
}
impl FromStr for AtUri<SmolStr> {
type Err = AtStrError;
fn from_str(uri: &str) -> Result<Self, Self::Err> {
Self::new_owned(uri)
}
}
impl<S: Bos<str> + AsRef<str>> fmt::Display for AtUri<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.uri.as_ref())
}
}
impl<S: Bos<str> + AsRef<str>> From<AtUri<S>> for String {
fn from(value: AtUri<S>) -> Self {
value.uri.as_ref().to_string()
}
}
impl TryFrom<String> for AtUri<SmolStr> {
type Error = AtStrError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new_owned(&value)
}
}
impl<'d> TryFrom<CowStr<'d>> for AtUri<CowStr<'d>> {
type Error = AtStrError;
fn try_from(uri: CowStr<'d>) -> Result<Self, Self::Error> {
Self::new(uri)
}
}
impl<S: Bos<str> + AsRef<str>> AsRef<str> for AtUri<S> {
fn as_ref(&self) -> &str {
self.uri.as_ref()
}
}
impl<S: Bos<str> + AsRef<str>> Deref for AtUri<S> {
type Target = str;
fn deref(&self) -> &Self::Target {
self.uri.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_at_uris() {
assert!(AtUri::new("at://did:plc:foo").is_ok());
assert!(AtUri::new("at://alice.bsky.social").is_ok());
assert!(AtUri::new("at://did:plc:foo/com.example.post").is_ok());
assert!(AtUri::new("at://did:plc:foo/com.example.post/123").is_ok());
}
#[test]
fn authority_only() {
let uri = AtUri::new("at://alice.test").unwrap();
assert_eq!(uri.authority().as_str(), "alice.test");
assert!(uri.collection().is_none());
assert!(uri.rkey().is_none());
}
#[test]
fn authority_and_collection() {
let uri = AtUri::new("at://alice.test/com.example.foo").unwrap();
assert_eq!(uri.authority().as_str(), "alice.test");
assert_eq!(uri.collection().unwrap().as_str(), "com.example.foo");
assert!(uri.rkey().is_none());
}
#[test]
fn full_uri() {
let uri = AtUri::new("at://alice.test/com.example.foo/123").unwrap();
assert_eq!(uri.authority().as_str(), "alice.test");
assert_eq!(uri.collection().unwrap().as_str(), "com.example.foo");
assert_eq!(uri.rkey().unwrap().as_ref(), "123");
}
#[test]
fn with_fragment() {
let uri = AtUri::new("at://alice.test/com.example.foo/123#/path").unwrap();
assert_eq!(uri.fragment().unwrap(), "/path");
assert!(AtUri::new("at://alice.test#path").is_err());
assert!(AtUri::new("at://alice.test#/foo/bar").is_ok());
}
#[test]
fn no_trailing_slash() {
assert!(AtUri::new("at://alice.test/").is_err());
assert!(AtUri::new("at://alice.test/com.example.foo/").is_err());
}
#[test]
fn must_have_authority() {
assert!(AtUri::new("at://").is_err());
assert!(AtUri::new("at:///com.example.foo").is_err());
}
#[test]
fn must_start_with_at_scheme() {
assert!(AtUri::new("alice.test").is_err());
assert!(AtUri::new("https://alice.test").is_err());
}
#[test]
fn max_length() {
let long_did = format!("did:plc:{}", "a".repeat(8000));
let uri = format!("at://{}", long_did);
assert!(uri.len() < 8192);
}
#[test]
fn clone_preserves_indices() {
let uri = AtUri::new("at://alice.test/com.example.foo/123").unwrap();
let owned: AtUri<SmolStr> =
AtUri::new_owned("at://alice.test/com.example.foo/123").unwrap();
let cloned = owned.clone();
assert_eq!(owned.as_str(), cloned.as_str());
assert_eq!(cloned.authority().as_str(), "alice.test");
assert_eq!(cloned.collection().unwrap().as_str(), "com.example.foo");
assert_eq!(cloned.rkey().unwrap().as_ref(), "123");
let cloned_borrowed = uri.clone();
assert_eq!(cloned_borrowed.authority().as_str(), "alice.test");
}
#[test]
fn into_static_preserves_components() {
let uri = AtUri::new("at://did:plc:foo/com.example.post/abc").unwrap();
let owned: AtUri<SmolStr> = uri.into_static();
assert_eq!(owned.authority().as_str(), "did:plc:foo");
assert_eq!(owned.collection().unwrap().as_str(), "com.example.post");
assert_eq!(owned.rkey().unwrap().as_ref(), "abc");
}
#[test]
fn path_accessor() {
let uri = AtUri::new("at://alice.test/com.example.foo/123").unwrap();
let path = uri.path().unwrap();
assert_eq!(path.collection.as_str(), "com.example.foo");
assert_eq!(path.rkey.unwrap().as_ref(), "123");
let uri2 = AtUri::new("at://alice.test/com.example.foo").unwrap();
let path2 = uri2.path().unwrap();
assert_eq!(path2.collection.as_str(), "com.example.foo");
assert!(path2.rkey.is_none());
let uri3 = AtUri::new("at://alice.test").unwrap();
assert!(uri3.path().is_none());
}
#[test]
fn serde_roundtrip() {
let original = "at://did:plc:foo/com.example.post/123";
let owned: AtUri<SmolStr> = AtUri::new_owned(original).unwrap();
let json = serde_json::to_string(&owned).unwrap();
assert_eq!(json, format!("\"{}\"", original));
let deserialized: AtUri<SmolStr> = serde_json::from_str(&json).unwrap();
assert_eq!(owned, deserialized);
}
#[test]
fn fragment_only_uri() {
let uri = AtUri::new("at://alice.test#/foo/bar").unwrap();
assert_eq!(uri.authority().as_str(), "alice.test");
assert!(uri.collection().is_none());
assert!(uri.rkey().is_none());
assert_eq!(uri.fragment().unwrap(), "/foo/bar");
}
#[test]
fn bos_lifetime_semantics() {
let s = String::from("at://alice.test/com.example.foo/123");
let uri = AtUri::new(s).unwrap();
let authority = uri.authority();
assert_eq!(authority.as_str(), "alice.test");
}
}