diff --git a/crates/jacquard-common/src/cowstr.rs b/crates/jacquard-common/src/cowstr.rs index 33218216..c6b0f958 100644 --- a/crates/jacquard-common/src/cowstr.rs +++ b/crates/jacquard-common/src/cowstr.rs @@ -335,6 +335,14 @@ where } } +impl core::str::FromStr for CowStr<'_> { + type Err = core::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(CowStr::copy_from_str(s)) + } +} + /// Convert to a CowStr. pub trait ToCowStr { /// Convert to a CowStr. diff --git a/crates/jacquard-common/src/types/aturi.rs b/crates/jacquard-common/src/types/aturi.rs index d806de12..d1073850 100644 --- a/crates/jacquard-common/src/types/aturi.rs +++ b/crates/jacquard-common/src/types/aturi.rs @@ -1,13 +1,15 @@ -use crate::cowstr::ToCowStr; +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; -use crate::{CowStr, IntoStatic}; +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"))] @@ -22,7 +24,45 @@ use smol_str::{SmolStr, ToSmolStr}; use super::Lazy; -/// AT Protocol URI (`at://`) for referencing records in repositories +/// Byte indices of delimiter positions within an AT URI string. +/// +/// Each index points at the delimiter character itself (`/` or `#`). +/// Uses `NonZeroU16` for niche optimisation — `Option` is 2 bytes. +/// Safe because AT URIs start with `at://` (5 bytes), so any delimiter is at index >= 5. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct AtUriIndices { + /// Index of the `/` separating authority from collection. + first_slash: Option, + /// Index of the `/` separating collection from rkey. + second_slash: Option, + /// Index of the `#` starting the fragment. + hash: Option, +} + +impl AtUriIndices { + /// End of the authority component. + fn authority_end(&self, len: usize) -> usize { + self.first_slash + .or(self.hash) + .map(|n| n.get() as usize) + .unwrap_or(len) + } + + /// End of the collection component (only valid if first_slash is Some). + fn collection_end(&self, len: usize) -> usize { + self.second_slash + .or(self.hash) + .map(|n| n.get() as usize) + .unwrap_or(len) + } + + /// End of the rkey component (only valid if second_slash is Some). + fn rkey_end(&self, len: usize) -> usize { + self.hash.map(|n| n.get() as usize).unwrap_or(len) + } +} + +/// AT Protocol URI (`at://`) for referencing records in repositories. /// /// AT URIs provide a way to reference records using either a DID or handle as the authority. /// They're not content-addressed, so the record's contents can change over time. @@ -38,84 +78,77 @@ use super::Lazy; /// - `at://did:plc:abc123/app.bsky.feed.post/3jk5` /// /// See: -#[derive(PartialEq, Eq, Debug)] -pub struct AtUri<'u> { - inner: Inner<'u>, +#[derive(Clone, Debug)] +pub struct AtUri + AsRef = DefaultStr> { + uri: S, + indices: AtUriIndices, } -#[ouroboros::self_referencing] -#[derive(PartialEq, Eq, Debug)] -struct Inner<'u> { - uri: CowStr<'u>, - #[borrows(uri)] - #[covariant] - pub authority: AtIdentifier>, - #[borrows(uri)] - #[covariant] - pub path: Option>, - #[borrows(uri)] - #[covariant] - pub fragment: Option>, +impl + AsRef> PartialEq for AtUri { + fn eq(&self, other: &Self) -> bool { + self.uri.as_ref() == other.uri.as_ref() + } +} + +impl + AsRef> Eq for AtUri {} + +impl + AsRef> Hash for AtUri { + fn hash(&self, state: &mut H) { + self.uri.as_ref().hash(state); + } +} + +/// Path component of an AT URI (collection and optional record key). +/// +/// Represents the `/COLLECTION[/RKEY]` portion of an AT URI. +pub struct RepoPath + AsRef = DefaultStr> { + /// Collection NSID (e.g., `app.bsky.feed.post`). + pub collection: Nsid, + /// Optional record key identifying a specific record. + pub rkey: Option>>, } -impl Clone for AtUri<'_> { +impl + AsRef + Clone> Clone for RepoPath { fn clone(&self) -> Self { - let uri = self.inner.borrow_uri(); - - Self { - inner: Inner::new( - CowStr::Owned(uri.as_ref().to_smolstr()), - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - AtIdentifier::new_cow(parts.name("authority").unwrap().as_str().to_cowstr()) - .unwrap() - }, - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - if let Some(collection) = parts.name("collection") { - let collection = - unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - } - }, - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }) - }, - ), + RepoPath { + collection: self.collection.clone(), + rkey: self.rkey.clone(), } } } -impl Hash for AtUri<'_> { +impl + AsRef> PartialEq for RepoPath { + 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 + AsRef> Eq for RepoPath {} + +impl + AsRef> Hash for RepoPath { fn hash(&self, state: &mut H) { - self.inner.borrow_uri().hash(state); + self.collection.as_str().hash(state); + if let Some(rkey) = &self.rkey { + rkey.as_ref().hash(state); + } } } -/// Path component of an AT URI (collection and optional record key) -/// -/// Represents the `/COLLECTION[/RKEY]` portion of an AT URI. -#[derive(Clone, PartialEq, Eq, Hash, Debug)] -pub struct RepoPath<'u> { - /// Collection NSID (e.g., `app.bsky.feed.post`) - pub collection: Nsid>, - /// Optional record key identifying a specific record - pub rkey: Option>>>, +impl + AsRef> fmt::Debug for RepoPath { + 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 fmt::Display for RepoPath<'_> { +impl + AsRef> fmt::Display for RepoPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "/{}", self.collection)?; if let Some(rkey) = &self.rkey { @@ -125,8 +158,11 @@ impl fmt::Display for RepoPath<'_> { } } -impl IntoStatic for RepoPath<'_> { - type Output = RepoPath<'static>; +impl + AsRef + IntoStatic> IntoStatic for RepoPath +where + S::Output: Bos + AsRef, +{ + type Output = RepoPath; fn into_static(self) -> Self::Output { RepoPath { @@ -136,235 +172,158 @@ impl IntoStatic for RepoPath<'_> { } } -/// Owned (static lifetime) version of `RepoPath` -pub type UriPathBuf = RepoPath<'static>; +/// Owned (static lifetime) version of `RepoPath`. +pub type UriPathBuf = RepoPath; -/// Regex for AT URI validation per AT Protocol spec +/// Regex for AT URI validation per AT Protocol spec. pub static ATURI_REGEX: Lazy = Lazy::new(|| { // Fragment allows: / and \ and other special chars. In raw string, backslashes are literal. Regex::new(r##"^at://(?[a-zA-Z0-9._:%-]+)(/(?[a-zA-Z0-9-.]+)(/(?[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?/[a-zA-Z0-9._~:@!$&%')(*+,;=\-\[\]/\\]*))?$"##).unwrap() }); -impl<'u> AtUri<'u> { - /// Fallible constructor, validates, borrows from input - pub fn new(uri: &'u str) -> Result { - if let Some(parts) = ATURI_REGEX.captures(uri) { - if let Some(authority) = parts.name("authority") { - let authority = AtIdentifier::new_cow(authority.as_str().to_cowstr()) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) - })?); - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - }; - let fragment = parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }); - Ok(AtUri { - inner: InnerBuilder { - uri: CowStr::Borrowed(uri), - authority_builder: |_| authority, - path_builder: |_| path, - fragment_builder: |_| fragment, - } - .build(), - }) - } else { - Err(AtStrError::missing("at-uri-scheme", uri, "authority")) - } - } else { - Err(AtStrError::regex( - "at-uri-scheme", - uri, - SmolStr::new_static("doesn't match schema"), - )) - } - } +// --------------------------------------------------------------------------- +// Internal validation +// --------------------------------------------------------------------------- - /// Infallible constructor for when you know the URI is valid - /// - /// Panics on invalid URIs. Use this when manually constructing URIs from trusted sources. - pub fn raw(uri: &'u str) -> Self { - if let Some(parts) = ATURI_REGEX.captures(uri) { - if let Some(authority) = parts.name("authority") { - let authority = AtIdentifier::new_cow(authority.as_str().to_cowstr()).unwrap(); - let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())).unwrap(); - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).unwrap()); - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - }; - let fragment = parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }); - AtUri { - inner: InnerBuilder { - uri: CowStr::Borrowed(uri), - authority_builder: |_| authority, - path_builder: |_| path, - fragment_builder: |_| fragment, - } - .build(), - } - } else { - panic!("at:// URI missing authority") - } - } else { - panic!("Invalid at:// URI via regex") +/// Validate an AT URI string and extract delimiter indices. +/// +/// Runs the regex once. Does not parse components into typed wrappers — that happens +/// lazily via the accessor methods. +pub(crate) fn validate_and_index(uri: &str) -> Result { + 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")); + }; + + // Validate the authority as a DID or handle. + AtIdentifier::new(authority.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; + + // Validate collection if present. + if let Some(collection) = parts.name("collection") { + Nsid::new(collection.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; + + // Validate rkey if present. + if let Some(rkey) = parts.name("rkey") { + Rkey::new(rkey.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; } } - /// Unchecked borrowing constructor. This one does do some validation but if that fails will just - /// dump everything in the authority field. - /// - /// TODO: do some fallback splitting, but really, if you use this on something invalid, you deserve it. - pub unsafe fn unchecked(uri: &'u str) -> Self { - if let Some(parts) = ATURI_REGEX.captures(uri) { - if let Some(authority) = parts.name("authority") { - let authority = - unsafe { AtIdentifier::unchecked_cow(authority.as_str().to_cowstr()) }; - let path = if let Some(collection) = parts.name("collection") { - let collection = - unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - }; - let fragment = parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }); - AtUri { - inner: InnerBuilder { - uri: CowStr::Borrowed(uri), - authority_builder: |_| authority, - path_builder: |_| path, - fragment_builder: |_| fragment, - } - .build(), - } - } else { - // let mut uriParts = uri.split('#'); - // let mut parts = uriParts.next().unwrap_or(uri).split('/'); - // let auth = parts.next().unwrap_or(uri); - Self { - inner: InnerBuilder { - uri: CowStr::Borrowed(uri), - authority_builder: |_| unsafe { - AtIdentifier::unchecked_cow(uri.to_cowstr()) - }, - path_builder: |_| None, - fragment_builder: |_| None, - } - .build(), - } + Ok(extract_indices(uri)) +} + +/// Extract delimiter indices from a URI string that has already been validated. +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; + + // Start after "at://" (5 bytes). Walk until we find delimiters. + let mut i = 5; + while i < bytes.len() { + match bytes[i] { + b'/' if first_slash.is_none() => { + first_slash = NonZeroU16::new(i as u16); } - } else { - Self { - inner: InnerBuilder { - uri: CowStr::Borrowed(uri), - authority_builder: |_| unsafe { AtIdentifier::unchecked_cow(uri.to_cowstr()) }, - path_builder: |_| None, - fragment_builder: |_| None, - } - .build(), + b'/' if second_slash.is_none() => { + second_slash = NonZeroU16::new(i as u16); + } + b'#' => { + hash = NonZeroU16::new(i as u16); + break; // Fragment is always last. } + _ => {} } + i += 1; } - /// Clone method that should be O(1) in terms of time - /// - /// Calling on a borrowed variant will turn it into an owned variant, taking a little - /// more time and allocating memory for each part. Calling it on an owned variant will - /// increment all the internal reference counters (or, if constructed from a `&'static str`, - /// essentially do nothing). - pub fn fast_clone(&self) -> AtUri<'static> { - self.inner.with(move |u| { - let uri = u.uri.clone().into_static(); - let authority = u.authority.clone().into_static(); - let path = u.path.clone().into_static(); - let fragment = u.fragment.clone().into_static(); - AtUri { - inner: InnerBuilder { - uri, - authority_builder: |_| authority, - path_builder: |_| path, - fragment_builder: |_| fragment, - } - .build(), - } - }) + AtUriIndices { + first_slash, + second_slash, + hash, } +} - /// Get the full URI as a string slice - pub fn as_str(&self) -> &str { - { - let this = &self.inner.borrow_uri(); - this - } - } +// --------------------------------------------------------------------------- +// Borrowed construction +// --------------------------------------------------------------------------- - /// Get the authority component (DID or handle) - pub fn authority(&self) -> &AtIdentifier> { - self.inner.borrow_authority() - } +// --------------------------------------------------------------------------- +// Generic unchecked construction +// --------------------------------------------------------------------------- - /// Get the path component (collection and optional rkey) - pub fn path(&self) -> &Option> { - self.inner.borrow_path() +impl + AsRef> AtUri { + /// Unchecked constructor from a pre-validated URI string. + /// + /// Extracts indices but does not validate components. Use when the URI + /// has already been validated externally (e.g., by `AtprotoStr::new()`). + /// + /// # Safety + /// + /// Callers must ensure the URI is a valid AT URI. Accessor methods will + /// produce typed wrappers via `unchecked` constructors. + pub unsafe fn unchecked(uri: S) -> Self { + let indices = extract_indices(uri.as_ref()); + AtUri { uri, indices } } - /// Get the fragment component if present - pub fn fragment(&self) -> &Option> { - self.inner.borrow_fragment() + /// Construct from a pre-validated URI string and pre-computed indices. + /// + /// # Safety + /// + /// Callers must ensure the URI is valid and the indices are correct. + pub(crate) unsafe fn from_parts(uri: S, indices: AtUriIndices) -> Self { + AtUri { uri, indices } } +} - /// Get the collection NSID from the path, if present - pub fn collection(&self) -> Option<&Nsid>> { - self.inner.borrow_path().as_ref().map(|p| &p.collection) - } +// --------------------------------------------------------------------------- +// Generic construction +// --------------------------------------------------------------------------- - /// Get the record key from the path, if present - pub fn rkey(&self) -> Option<&RecordKey>>> { - self.inner - .borrow_path() - .as_ref() - .and_then(|p| p.rkey.as_ref()) +impl + AsRef> AtUri { + /// Fallible constructor, validates, wraps the input directly. + pub fn new(uri: S) -> Result { + let indices = validate_and_index(uri.as_ref())?; + Ok(AtUri { uri, indices }) } - /// Fallible constructor, validates, borrows from input if possible - pub fn new_cow(uri: CowStr<'u>) -> Result { - Self::try_from(uri) + /// Infallible constructor. Panics on invalid URIs. + pub fn raw(uri: S) -> Self { + Self::new(uri).expect("valid AT URI") } } -impl AtUri<'static> { - /// Fallible owned constructor from typical parts +// --------------------------------------------------------------------------- +// Owned construction +// --------------------------------------------------------------------------- + +impl + AsRef + FromStr> AtUri { + /// Fallible owned constructor. + pub fn new_owned(uri: impl AsRef) -> Result { + 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 }) + } + + /// Fallible constructor from typical parts. pub fn from_parts_owned( authority: impl AsRef, collection: impl AsRef, @@ -378,10 +337,10 @@ impl AtUri<'static> { &format!("at://{}/{}/{}", authority, collection, rkey), "correct uri path", )) - } else if !authority.is_empty() && collection.is_empty() && rkey.is_empty() { + } else if collection.is_empty() && rkey.is_empty() { let uri = format!("at://{}", authority); Self::new_owned(uri) - } else if !collection.is_empty() && rkey.is_empty() { + } else if rkey.is_empty() { let uri = format!("at://{}/{}", authority, collection); Self::new_owned(uri) } else { @@ -389,331 +348,165 @@ impl AtUri<'static> { Self::new_owned(uri) } } - /// Owned constructor - /// - /// Uses ouroboros self-referential tricks internally to make sure everything - /// borrows efficiently from the uri `CowStr<'static>`. - /// - /// Performs validation up-front, but is slower than the borrowing constructor - /// due to currently having to re-run the main regex, in addition to allocating. - /// - /// `.into_static()` and Clone implementations have similar limitations. - /// - /// O(1) clone mathod is AtUri::fast_clone(). - /// - /// Future optimization involves working out the indices borrowed and either using those - /// to avoid re-computing in some places, or, for a likely fully optimal version, only storing - /// the indices and constructing the borrowed components unsafely when asked. - pub fn new_owned(uri: impl AsRef) -> Result { - if let Some(parts) = ATURI_REGEX.captures(uri.as_ref()) { - if let Some(authority) = parts.name("authority") { - let _authority = AtIdentifier::new(authority.as_str()) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e))?; - let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())).map_err(|e| { - AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e) - })?; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { - AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e) - })?); - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - }; - - Ok(AtUri { - inner: Inner::new( - CowStr::Owned(uri.as_ref().to_smolstr()), - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - unsafe { - AtIdentifier::unchecked_cow( - parts.name("authority").unwrap().as_str().to_cowstr(), - ) - } - }, - |uri| { - if path.is_some() { - let parts = ATURI_REGEX.captures(uri).unwrap(); - if let Some(collection) = parts.name("collection") { - let collection = unsafe { - Nsid::unchecked(CowStr::Borrowed(collection.as_str())) - }; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = unsafe { - RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) - }; - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - } - } else { - None - } - }, - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }) - }, - ), - }) - } else { - Err(AtStrError::missing( - "at-uri-scheme", - &uri.as_ref(), - "authority", - )) - } - } else { - Err(AtStrError::regex( - "at-uri-scheme", - &uri.as_ref(), - SmolStr::new_static("doesn't match schema"), - )) - } - } - /// Fallible constructor, validates, doesn't allocate (static lifetime) + /// Fallible constructor for static strings. pub fn new_static(uri: &'static str) -> Result { - let uri = uri.as_ref(); - if let Some(parts) = ATURI_REGEX.captures(uri) { - if let Some(authority) = parts.name("authority") { - let authority = AtIdentifier::new_static(authority.as_str()) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) - })?); - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - }; - let fragment = parts.name("fragment").map(|fragment| { - let fragment = CowStr::new_static(fragment.as_str()); - fragment - }); - Ok(AtUri { - inner: InnerBuilder { - uri: CowStr::new_static(uri), - authority_builder: |_| authority, - path_builder: |_| path, - fragment_builder: |_| fragment, - } - .build(), - }) - } else { - Err(AtStrError::missing("at-uri-scheme", uri, "authority")) - } - } else { - Err(AtStrError::regex( - "at-uri-scheme", - uri, - SmolStr::new_static("doesn't match schema"), - )) - } + 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 FromStr for AtUri<'_> { - type Err = AtStrError; +// --------------------------------------------------------------------------- +// Accessors +// --------------------------------------------------------------------------- - /// Has to take ownership due to the lifetime constraints of the FromStr trait. - /// Prefer `AtUri::new()` or `AtUri::raw()` if you want to borrow. - fn from_str(uri: &str) -> Result { - if let Some(parts) = ATURI_REGEX.captures(uri.as_ref()) { - if let Some(authority) = parts.name("authority") { - let _authority = AtIdentifier::new(authority.as_str()) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) - })?); - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - }; - - Ok(AtUri { - inner: Inner::new( - CowStr::Owned(uri.to_smolstr()), - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - unsafe { - AtIdentifier::unchecked_cow( - parts.name("authority").unwrap().as_str().to_cowstr(), - ) - } - }, - |uri| { - if path.is_some() { - let parts = ATURI_REGEX.captures(uri).unwrap(); - if let Some(collection) = parts.name("collection") { - let collection = unsafe { - Nsid::unchecked(CowStr::Borrowed(collection.as_str())) - }; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = unsafe { - RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) - }; - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - } - } else { - None - } - }, - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }) - }, - ), - }) - } else { - Err(AtStrError::missing( - "at-uri-scheme", - &uri.as_ref(), - "authority", - )) - } - } else { - Err(AtStrError::regex( - "at-uri-scheme", - &uri.as_ref(), - SmolStr::new_static("doesn't match schema"), - )) - } +impl + AsRef> AtUri { + /// Get the full URI as a string slice. + pub fn as_str(&self) -> &str { + self.uri.as_ref() + } + + /// Get the authority component (DID or handle). + /// + /// Uses `BorrowOrShare` split lifetimes: when `S = &'d str`, the returned + /// `AtIdentifier<&'d str>` can outlive the borrow of `self`. + 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()); + // Safety: constructor validated the authority. `unchecked` classifies DID vs handle + // but won't reject valid input. + unsafe { AtIdentifier::unchecked(&s[5..end]) } + } + + /// Get the collection NSID from the path, if present. + pub fn collection<'i, 'o>(&'i self) -> Option> + 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]) }) + } + + /// Get the record key from the path, if present. + pub fn rkey<'i, 'o>(&'i self) -> Option> + 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]) }) + } + + /// Get the path component (collection and optional rkey). + pub fn path<'i, 'o>(&'i self) -> Option> + 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 }) + } + + /// Get the fragment component if present. + 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 IntoStatic for AtUri<'_> { - type Output = AtUri<'static>; +// --------------------------------------------------------------------------- +// IntoStatic +// --------------------------------------------------------------------------- - fn into_static(self) -> AtUri<'static> { +impl + AsRef + IntoStatic> IntoStatic for AtUri +where + S::Output: Bos + AsRef, +{ + type Output = AtUri; + + fn into_static(self) -> AtUri { AtUri { - inner: Inner::new( - self.inner.borrow_uri().clone().into_static(), - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - unsafe { - AtIdentifier::unchecked_cow( - parts.name("authority").unwrap().as_str().to_cowstr(), - ) - } - }, - |uri| { - if self.inner.borrow_path().is_some() { - let parts = ATURI_REGEX.captures(uri).unwrap(); - if let Some(collection) = parts.name("collection") { - let collection = - unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = - unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - } - } else { - None - } - }, - |uri| { - if self.inner.borrow_fragment().is_some() { - let parts = ATURI_REGEX.captures(uri).unwrap(); - parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }) - } else { - None - } - }, - ), + uri: self.uri.into_static(), + indices: self.indices, } } } -impl<'de, 'a> Deserialize<'de> for AtUri<'a> +// --------------------------------------------------------------------------- +// Serde +// --------------------------------------------------------------------------- + +impl<'de, S> Deserialize<'de> for AtUri where - 'de: 'a, + S: Bos + AsRef + Deserialize<'de>, { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - let value = Deserialize::deserialize(deserializer)?; - Self::new_cow(value).map_err(D::Error::custom) + let s = S::deserialize(deserializer)?; + let indices = validate_and_index(s.as_ref()).map_err(D::Error::custom)?; + Ok(AtUri { uri: s, indices }) } } -impl Serialize for AtUri<'_> { - fn serialize(&self, serializer: S) -> Result +impl + AsRef> Serialize for AtUri { + fn serialize(&self, serializer: Ser) -> Result where - S: Serializer, + Ser: Serializer, { - serializer.serialize_str(&self.inner.borrow_uri()) + serializer.serialize_str(self.uri.as_ref()) } } -impl fmt::Display for AtUri<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.inner.borrow_uri()) +// --------------------------------------------------------------------------- +// FromStr +// --------------------------------------------------------------------------- + +impl FromStr for AtUri { + type Err = AtStrError; + + fn from_str(uri: &str) -> Result { + Self::new_owned(uri) } } -impl<'d> From> for String { - fn from(value: AtUri<'d>) -> Self { - value.inner.borrow_uri().to_string() +// --------------------------------------------------------------------------- +// Display, conversions +// --------------------------------------------------------------------------- + +impl + AsRef> fmt::Display for AtUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.uri.as_ref()) } } -impl<'d> From> for CowStr<'d> { - fn from(value: AtUri<'d>) -> Self { - value.inner.borrow_uri().clone() +impl + AsRef> From> for String { + fn from(value: AtUri) -> Self { + value.uri.as_ref().to_string() } } -impl TryFrom for AtUri<'static> { +impl TryFrom for AtUri { type Error = AtStrError; fn try_from(value: String) -> Result { @@ -721,100 +514,32 @@ impl TryFrom for AtUri<'static> { } } -impl<'d> TryFrom> for AtUri<'d> { +impl<'d> TryFrom> for AtUri> { type Error = AtStrError; + fn try_from(uri: CowStr<'d>) -> Result { - if let Some(parts) = ATURI_REGEX.captures(uri.as_ref()) { - if let Some(authority) = parts.name("authority") { - let _authority = AtIdentifier::new(authority.as_str()) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let _path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) - })?); - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - }; - drop(parts); - - Ok(AtUri { - inner: Inner::new( - uri, - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - unsafe { - AtIdentifier::unchecked_cow( - parts.name("authority").unwrap().as_str().to_cowstr(), - ) - } - }, - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - if let Some(collection) = parts.name("collection") { - let collection = unsafe { - Nsid::unchecked(CowStr::Borrowed(collection.as_str())) - }; - let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = - unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; - Some(rkey) - } else { - None - }; - Some(RepoPath { collection, rkey }) - } else { - None - } - }, - |uri| { - let parts = ATURI_REGEX.captures(uri).unwrap(); - parts.name("fragment").map(|fragment| { - let fragment = CowStr::Borrowed(fragment.as_str()); - fragment - }) - }, - ), - }) - } else { - Err(AtStrError::missing( - "at-uri-scheme", - &uri.as_ref(), - "authority", - )) - } - } else { - Err(AtStrError::regex( - "at-uri-scheme", - &uri.as_ref(), - SmolStr::new_static("doesn't match schema"), - )) - } + Self::new(uri) } } -impl AsRef for AtUri<'_> { +impl + AsRef> AsRef for AtUri { fn as_ref(&self) -> &str { - &self.inner.borrow_uri().as_ref() + self.uri.as_ref() } } -impl Deref for AtUri<'_> { +impl + AsRef> Deref for AtUri { type Target = str; fn deref(&self) -> &Self::Target { - self.inner.borrow_uri().as_ref() + self.uri.as_ref() } } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -854,9 +579,9 @@ mod tests { #[test] fn with_fragment() { let uri = AtUri::new("at://alice.test/com.example.foo/123#/path").unwrap(); - assert_eq!(uri.fragment().as_ref().unwrap().as_ref(), "/path"); + assert_eq!(uri.fragment().unwrap(), "/path"); - // Fragment must start with / + // Fragment must start with /. assert!(AtUri::new("at://alice.test#path").is_err()); assert!(AtUri::new("at://alice.test#/foo/bar").is_ok()); } @@ -881,11 +606,81 @@ mod tests { #[test] fn max_length() { - // Spec says 8KB max + // Spec says 8KB max. let long_did = format!("did:plc:{}", "a".repeat(8000)); let uri = format!("at://{}", long_did); assert!(uri.len() < 8192); - // Should work if components are valid + // Should work if components are valid. // (our DID will fail at 2048 chars, but this tests the URI doesn't impose extra limits) } + + #[test] + fn clone_preserves_indices() { + let uri = AtUri::new("at://alice.test/com.example.foo/123").unwrap(); + let owned: AtUri = + 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"); + + // Borrowed clone. + 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 = 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 = AtUri::new_owned(original).unwrap(); + let json = serde_json::to_string(&owned).unwrap(); + assert_eq!(json, format!("\"{}\"", original)); + let deserialized: AtUri = 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() { + // Verify that AtUri<&str> accessors can outlive the borrow. + let s = String::from("at://alice.test/com.example.foo/123"); + let uri = AtUri::new(s).unwrap(); + let authority = uri.authority(); + // authority borrows from s, not from uri — this is the BOS magic. + assert_eq!(authority.as_str(), "alice.test"); + } } diff --git a/crates/jacquard-common/src/types/collection.rs b/crates/jacquard-common/src/types/collection.rs index bc91379c..c4658aff 100644 --- a/crates/jacquard-common/src/types/collection.rs +++ b/crates/jacquard-common/src/types/collection.rs @@ -1,5 +1,6 @@ use alloc::string::String; use core::fmt; +use core::str::FromStr; use serde::{Deserialize, Serialize}; @@ -10,7 +11,7 @@ use crate::types::{ recordkey::{RecordKey, RecordKeyType, Rkey}, }; use crate::xrpc::XrpcResp; -use crate::{CowStr, IntoStatic}; +use crate::{BorrowOrShare, Bos, CowStr, IntoStatic}; /// Trait for a collection of records that can be stored in a repository. /// @@ -35,8 +36,8 @@ pub trait Collection: fmt::Debug + Serialize { /// Panics if [`Self::NSID`] is not a valid NSID. /// /// [`Nsid`]: crate::types::string::Nsid - fn nsid() -> crate::types::nsid::Nsid> { - Nsid::new_static(Self::NSID).expect("should be valid NSID") + fn nsid() -> crate::types::nsid::Nsid<&'static str> { + unsafe { Nsid::unchecked(Self::NSID) } } /// Returns the repo path for a record in this collection with the given record key. @@ -48,14 +49,15 @@ pub trait Collection: fmt::Debug + Serialize { /// /// [Repo Data Structure v3]: https://atproto.com/specs/repository#repo-data-structure-v3 /// [`Nsid`]: crate::types::string::Nsid - fn repo_path<'u, T: RecordKeyType>( - rkey: &'u crate::types::recordkey::RecordKey, - ) -> RepoPath<'u> { + fn repo_path(rkey: &crate::types::recordkey::RecordKey) -> RepoPath<&str> + where + T: RecordKeyType + Bos + AsRef, + { RepoPath { collection: Self::nsid(), // Borrow the record key string with the caller's lifetime via CowStr. rkey: Some( - RecordKey::any_cow(CowStr::Borrowed(rkey.as_ref())) + RecordKey::any(rkey.0.borrow_or_share()) .expect("RecordKey implements RecordKeyType, which guarantees a valid rkey"), ), } diff --git a/crates/jacquard-common/src/types/did.rs b/crates/jacquard-common/src/types/did.rs index 8d4cf3c4..6a883ed0 100644 --- a/crates/jacquard-common/src/types/did.rs +++ b/crates/jacquard-common/src/types/did.rs @@ -1,5 +1,5 @@ use crate::bos::{Bos, DefaultStr}; -use crate::types::string::AtStrError; +use crate::types::string::{AtStrError, StrParseKind}; use crate::{CowStr, IntoStatic}; use alloc::string::{String, ToString}; use core::fmt; @@ -80,63 +80,49 @@ impl> Did { } // --------------------------------------------------------------------------- -// Borrowed construction +// Generic construction // --------------------------------------------------------------------------- -impl<'d> Did<&'d str> { - /// Fallible constructor, validates, borrows from input. - /// Accepts (and strips) preceding 'at://' if present. - pub fn new(did: &'d str) -> Result { - let stripped = strip_did_prefix(did); - validate_did(stripped)?; - Ok(Self(stripped)) +impl + AsRef> Did { + /// Fallible constructor, validates, wraps the input directly. + /// + /// Does NOT strip `at://` prefix — use `new_owned()` for that. + pub fn new(s: S) -> Result { + validate_did(s.as_ref())?; + Ok(Did(s)) } /// Infallible constructor. Panics on invalid DIDs. - pub fn raw(did: &'d str) -> Self { - Self::new(did).expect("invalid DID") + pub fn raw(s: S) -> Self { + Self::new(s).expect("invalid DID") } } // --------------------------------------------------------------------------- -// Owned construction +// Owned construction (with prefix stripping) // --------------------------------------------------------------------------- -impl + From> Did { +impl + FromStr> Did { /// Fallible constructor, validates, takes ownership. + /// + /// Accepts (and strips) preceding `at://` if present. pub fn new_owned(did: impl AsRef) -> Result { let did = did.as_ref(); let stripped = strip_did_prefix(did); validate_did(stripped)?; - Ok(Self(S::from(stripped.to_smolstr()))) + // FromStr for backing types (SmolStr, String, CowStr) is infallible. + let s = S::from_str(stripped) + .map_err(|_| AtStrError::new("did", stripped.to_string(), StrParseKind::Conversion))?; + Ok(Self(s)) } - /// Fallible constructor for static strings. Zero-alloc if possible. + /// Fallible constructor for static strings. pub fn new_static(did: &'static str) -> Result { let stripped = strip_did_prefix(did); validate_did(stripped)?; - Ok(Self(S::from(SmolStr::new_static(stripped)))) - } -} - -// --------------------------------------------------------------------------- -// CowStr construction -// --------------------------------------------------------------------------- - -impl<'d> Did> { - /// Fallible constructor, borrows if possible. - pub fn new_cow(did: CowStr<'d>) -> Result { - let did = if let Some(stripped) = did.strip_prefix("at://") { - CowStr::copy_from_str(stripped) - } else { - did - }; - validate_did(&did)?; - Ok(Self(did)) - } - - pub unsafe fn unchecked_cow(did: CowStr<'d>) -> Self { - Self(did) + let s = S::from_str(stripped) + .map_err(|_| AtStrError::new("did", stripped.to_string(), StrParseKind::Conversion))?; + Ok(Self(s)) } } @@ -229,7 +215,7 @@ impl From for Did { impl<'d> From> for Did> { fn from(value: CowStr<'d>) -> Self { - Self::new_cow(value).unwrap() + Self::new(value).unwrap() } } @@ -269,8 +255,10 @@ mod tests { #[test] fn prefix_stripping() { + // new() does not strip — use new_owned() for that. + assert!(Did::<&str>::new("at://did:plc:foo").is_err()); assert_eq!( - Did::<&str>::new("at://did:plc:foo").unwrap().as_str(), + Did::::new_owned("at://did:plc:foo").unwrap().as_str(), "did:plc:foo" ); assert_eq!( diff --git a/crates/jacquard-common/src/types/did_doc.rs b/crates/jacquard-common/src/types/did_doc.rs index 4455857f..8f57a487 100644 --- a/crates/jacquard-common/src/types/did_doc.rs +++ b/crates/jacquard-common/src/types/did_doc.rs @@ -108,13 +108,15 @@ where S: Bos + AsRef + Clone, { /// Extract validated handles from `alsoKnownAs` entries like `at://\`. - pub fn handles(&self) -> Vec { + pub fn handles(&self) -> Vec> { self.also_known_as .as_ref() .map(|v| { v.iter() - .filter_map(|h| Handle::new(h.as_ref()).ok()) - .map(|h| h.into_static()) + .filter_map(|h| { + let s = h.as_ref().strip_prefix("at://").unwrap_or(h.as_ref()); + Handle::new(s).ok() + }) .collect() }) .unwrap_or_default() diff --git a/crates/jacquard-common/src/types/handle.rs b/crates/jacquard-common/src/types/handle.rs index a3d7c3aa..0aed5141 100644 --- a/crates/jacquard-common/src/types/handle.rs +++ b/crates/jacquard-common/src/types/handle.rs @@ -1,8 +1,9 @@ use crate::bos::{Bos, DefaultStr}; -use crate::types::string::AtStrError; +use crate::types::string::{AtStrError, StrParseKind}; use crate::types::{DISALLOWED_TLDS, ends_with}; use crate::{CowStr, IntoStatic}; use alloc::string::String; +use alloc::string::ToString; use core::fmt; use core::hash::{Hash, Hasher}; use core::ops::Deref; @@ -100,79 +101,62 @@ impl> Handle { // Borrowed construction: Handle<&'h str> // --------------------------------------------------------------------------- -impl<'h> Handle<&'h str> { - /// Fallible constructor, validates, borrows from input. +impl + AsRef> Handle { + /// Fallible constructor, validates, wraps the input directly. /// - /// Rejects uppercase input — use `Handle::::new_owned()` for - /// case-insensitive construction. - /// Accepts (and strips) preceding '@' or 'at://' if present. - pub fn new(handle: &'h str) -> Result { - if handle.contains(|c: char| c.is_ascii_uppercase()) { + /// Rejects uppercase input — use `new_owned()` for case-insensitive construction. + /// Does NOT strip `@` or `at://` prefix — use `new_owned()` for that. + pub fn new(s: S) -> Result { + let r = s.as_ref(); + if r.contains(|c: char| c.is_ascii_uppercase()) { return Err(AtStrError::regex( "handle", - handle, + r, SmolStr::new_static("contains uppercase (use new_owned for normalisation)"), )); } - let stripped = strip_handle_prefix(handle); - validate_handle(stripped)?; - Ok(Self(stripped)) + validate_handle(r)?; + Ok(Self(s)) } /// Infallible constructor. Panics on invalid handles. - pub fn raw(handle: &'h str) -> Self { - Self::new(handle).expect("invalid handle") + pub fn raw(s: S) -> Self { + Self::new(s).expect("invalid handle") } } // --------------------------------------------------------------------------- -// Owned construction: any S that can be built from SmolStr +// Owned construction (with prefix stripping and normalisation) // --------------------------------------------------------------------------- -impl + From> Handle { +impl + FromStr> Handle { /// Fallible constructor, validates, takes ownership. Normalises to lowercase. + /// + /// Accepts (and strips) preceding `@` or `at://` if present. pub fn new_owned(handle: impl AsRef) -> Result { let handle = handle.as_ref(); let stripped = strip_handle_prefix(handle); let normalized = stripped.to_lowercase_smolstr(); validate_handle(&normalized)?; - Ok(Self(S::from(normalized))) + let s = S::from_str(&normalized).map_err(|_| { + AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion) + })?; + Ok(Self(s)) } - /// Fallible constructor for static strings. Zero-alloc if already lowercase. + /// Fallible constructor for static strings. Normalises to lowercase. pub fn new_static(handle: &'static str) -> Result { let stripped = strip_handle_prefix(handle); - let smol = if stripped.contains(|c: char| c.is_ascii_uppercase()) { + let normalized = if stripped.contains(|c: char| c.is_ascii_uppercase()) { stripped.to_lowercase_smolstr() } else { SmolStr::new_static(stripped) }; - validate_handle(&smol)?; - Ok(Self(S::from(smol))) - } -} - -// --------------------------------------------------------------------------- -// CowStr construction -// --------------------------------------------------------------------------- - -impl<'h> Handle> { - /// Fallible constructor, borrows if possible, allocates for uppercase/prefix. - pub fn new_cow(handle: CowStr<'h>) -> Result { - if handle.contains(|c: char| c.is_ascii_uppercase()) { - return Handle::>::new_owned(handle); - } - let handle = if handle.starts_with("at://") || handle.starts_with('@') { - CowStr::copy_from_str(strip_handle_prefix(&handle)) - } else { - handle - }; - validate_handle(&handle)?; - Ok(Self(handle)) - } - - pub unsafe fn unchecked_cow(handle: CowStr<'h>) -> Self { - Self(handle) + validate_handle(&normalized)?; + let s = S::from_str(&normalized).map_err(|_| { + AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion) + })?; + Ok(Self(s)) } } @@ -319,7 +303,7 @@ impl From for Handle { impl<'h> From> for Handle> { fn from(value: CowStr<'h>) -> Self { - Self::new_cow(value).unwrap() + Self::new(value).unwrap() } } @@ -364,12 +348,15 @@ mod tests { #[test] fn prefix_stripping() { + // new() does not strip — use new_owned() for that. + assert!(Handle::<&str>::new("@alice.test").is_err()); + assert!(Handle::<&str>::new("at://alice.test").is_err()); assert_eq!( - Handle::<&str>::new("@alice.test").unwrap().as_str(), + Handle::::new_owned("@alice.test").unwrap().as_str(), "alice.test" ); assert_eq!( - Handle::<&str>::new("at://alice.test").unwrap().as_str(), + Handle::::new_owned("at://alice.test").unwrap().as_str(), "alice.test" ); assert_eq!( diff --git a/crates/jacquard-common/src/types/ident.rs b/crates/jacquard-common/src/types/ident.rs index 8b967c70..a728eb05 100644 --- a/crates/jacquard-common/src/types/ident.rs +++ b/crates/jacquard-common/src/types/ident.rs @@ -1,7 +1,10 @@ use crate::bos::{Bos, DefaultStr}; use crate::types::handle::Handle; use crate::types::string::AtStrError; -use crate::{CowStr, IntoStatic, types::did::Did}; +use crate::{ + CowStr, IntoStatic, + types::did::{Did, validate_did}, +}; use alloc::string::String; use alloc::string::ToString; use core::fmt; @@ -46,21 +49,27 @@ impl + AsRef> AtIdentifier { } // --------------------------------------------------------------------------- -// Borrowed construction +// Generic construction // --------------------------------------------------------------------------- -impl<'i> AtIdentifier<&'i str> { - /// Fallible constructor, validates, borrows from input. - pub fn new(ident: &'i str) -> Result { - if let Ok(did) = Did::new(ident) { - Ok(AtIdentifier::Did(did)) +impl + AsRef> AtIdentifier { + /// Fallible constructor, validates, wraps the input directly. + /// + /// Tries DID first, then handle. Rejects uppercase handles — use + /// `new_owned()` for case-insensitive construction. + pub fn new(ident: S) -> Result { + let s = ident.as_ref(); + if validate_did(s).is_ok() { + drop(s); + Ok(AtIdentifier::Did(unsafe { Did::unchecked(ident) })) } else { + drop(s); Handle::new(ident).map(AtIdentifier::Handle) } } /// Infallible constructor. Panics on invalid identifiers. - pub fn raw(ident: &'i str) -> Self { + pub fn raw(ident: S) -> Self { Self::new(ident).expect("valid identifier") } @@ -69,9 +78,9 @@ impl<'i> AtIdentifier<&'i str> { /// # Safety /// /// Validates DIDs, treats anything else as a valid handle. - pub unsafe fn unchecked(ident: &'i str) -> Self { - if let Ok(did) = Did::new(ident) { - AtIdentifier::Did(did) + pub unsafe fn unchecked(ident: S) -> Self { + if validate_did(ident.as_ref()).is_ok() { + AtIdentifier::Did(unsafe { Did::unchecked(ident) }) } else { unsafe { AtIdentifier::Handle(Handle::unchecked(ident)) } } @@ -82,8 +91,9 @@ impl<'i> AtIdentifier<&'i str> { // Owned construction // --------------------------------------------------------------------------- -impl + AsRef + From> AtIdentifier { +impl + AsRef + FromStr> AtIdentifier { /// Fallible constructor, validates, takes ownership. + /// Strips prefixes and normalises handle case. pub fn new_owned(ident: impl AsRef) -> Result { let ident = ident.as_ref(); if let Ok(did) = Did::new_owned(ident) { @@ -103,31 +113,6 @@ impl + AsRef + From> AtIdentifier { } } -// --------------------------------------------------------------------------- -// CowStr construction -// --------------------------------------------------------------------------- - -impl<'i> AtIdentifier> { - /// Fallible constructor, borrows if possible. - pub fn new_cow(ident: CowStr<'i>) -> Result { - if let Ok(did) = Did::new_cow(ident.clone()) { - Ok(AtIdentifier::Did(did)) - } else { - Handle::new_cow(ident).map(AtIdentifier::Handle) - } - } - - pub unsafe fn unchecked_cow(ident: CowStr<'i>) -> Self { - unsafe { - if let Ok(did) = Did::new_cow(ident.clone()) { - AtIdentifier::Did(did) - } else { - AtIdentifier::Handle(Handle::unchecked_cow(ident)) - } - } - } -} - // --------------------------------------------------------------------------- // Trait impls // --------------------------------------------------------------------------- @@ -199,7 +184,7 @@ impl From for AtIdentifier { impl<'i> From> for AtIdentifier> { fn from(value: CowStr<'i>) -> Self { - Self::new_cow(value).expect("valid identifier") + Self::new(value).expect("valid identifier") } } @@ -246,7 +231,7 @@ mod tests { let ident: AtIdentifier = did.into(); assert!(matches!(ident, AtIdentifier::Did(_))); - let handle = Handle::new_cow("alice.test".to_cowstr()).unwrap(); + let handle = Handle::new("alice.test".to_cowstr()).unwrap(); let ident: AtIdentifier = handle.into(); assert!(matches!(ident, AtIdentifier::Handle(_))); } diff --git a/crates/jacquard-common/src/types/nsid.rs b/crates/jacquard-common/src/types/nsid.rs index 42e3c1cb..924bdc47 100644 --- a/crates/jacquard-common/src/types/nsid.rs +++ b/crates/jacquard-common/src/types/nsid.rs @@ -1,6 +1,6 @@ use crate::bos::{Bos, DefaultStr}; use crate::types::recordkey::RecordKeyType; -use crate::types::string::AtStrError; +use crate::types::string::{AtStrError, StrParseKind}; use crate::{CowStr, IntoStatic}; use alloc::string::{String, ToString}; use core::fmt; @@ -74,39 +74,35 @@ impl> Nsid { } } -impl<'n> Nsid<&'n str> { - /// Fallible constructor, validates, borrows from input. - pub fn new(nsid: &'n str) -> Result { - validate_nsid(nsid)?; - Ok(Self(nsid)) +impl + AsRef> Nsid { + /// Fallible constructor, validates, wraps the input directly. + pub fn new(s: S) -> Result { + validate_nsid(s.as_ref())?; + Ok(Self(s)) } /// Infallible constructor. Panics on invalid NSIDs. - pub fn raw(nsid: &'n str) -> Self { - Self::new(nsid).expect("invalid NSID") + pub fn raw(s: S) -> Self { + Self::new(s).expect("invalid NSID") } } -impl + From> Nsid { +impl + FromStr> Nsid { /// Fallible constructor, validates, takes ownership. pub fn new_owned(nsid: impl AsRef) -> Result { let nsid = nsid.as_ref(); validate_nsid(nsid)?; - Ok(Self(S::from(nsid.to_smolstr()))) + let s = S::from_str(nsid) + .map_err(|_| AtStrError::new("nsid", nsid.to_string(), StrParseKind::Conversion))?; + Ok(Self(s)) } - /// Fallible constructor for static strings. Zero-alloc if possible. + /// Fallible constructor for static strings. pub fn new_static(nsid: &'static str) -> Result { validate_nsid(nsid)?; - Ok(Self(S::from(SmolStr::new_static(nsid)))) - } -} - -impl<'n> Nsid> { - /// Fallible constructor, borrows if possible. - pub fn new_cow(nsid: CowStr<'n>) -> Result { - validate_nsid(&nsid)?; - Ok(Self(nsid)) + let s = S::from_str(nsid) + .map_err(|_| AtStrError::new("nsid", nsid.to_string(), StrParseKind::Conversion))?; + Ok(Self(s)) } } @@ -191,7 +187,7 @@ impl From for Nsid { impl<'n> From> for Nsid> { fn from(value: CowStr<'n>) -> Self { - Self::new_cow(value).unwrap() + Self::new(value).unwrap() } } diff --git a/crates/jacquard-common/src/types/recordkey.rs b/crates/jacquard-common/src/types/recordkey.rs index c9bc3534..ad663542 100644 --- a/crates/jacquard-common/src/types/recordkey.rs +++ b/crates/jacquard-common/src/types/recordkey.rs @@ -1,6 +1,6 @@ use crate::bos::{Bos, DefaultStr}; use crate::types::Literal; -use crate::types::string::AtStrError; +use crate::types::string::{AtStrError, StrParseKind}; use crate::{CowStr, IntoStatic}; use alloc::string::{String, ToString}; use core::fmt; @@ -27,8 +27,8 @@ use super::Lazy; /// # Safety /// Implementations must ensure the string representation matches [`RKEY_REGEX`] and /// is not "." or "..". Built-in implementations: `Tid`, `Nsid`, `Literal`, `Rkey<'_>`. -pub unsafe trait RecordKeyType: Clone + Serialize { - /// Get the record key as a string slice +pub unsafe trait RecordKeyType { + /// Get the record key as a string slice. fn as_str(&self) -> &str; } @@ -41,14 +41,7 @@ pub unsafe trait RecordKeyType: Clone + Serialize { #[repr(transparent)] pub struct RecordKey(pub T); -impl<'a> RecordKey> { - /// Create a new `RecordKey` from a string slice. - pub fn any(str: &'a str) -> Result { - Ok(RecordKey(Rkey::new(str)?)) - } -} - -impl + AsRef + Clone + Serialize + From> RecordKey> { +impl + AsRef + FromStr> RecordKey> { /// Create a new `RecordKey` from a static string slice. pub fn any_static(str: &'static str) -> Result { Ok(RecordKey(Rkey::new_static(str)?)) @@ -60,10 +53,10 @@ impl + AsRef + Clone + Serialize + From> RecordKey RecordKey>> { - /// Create a new `RecordKey` from a CowStr. - pub fn any_cow(str: CowStr<'a>) -> Result { - Ok(RecordKey(Rkey::new_cow(str)?)) +impl + AsRef> RecordKey> { + /// Create a new `RecordKey` wrapping a pre-validated Rkey. + pub fn any(s: S) -> Result { + Ok(RecordKey(Rkey::new(s)?)) } } @@ -126,7 +119,7 @@ where #[repr(transparent)] pub struct Rkey = DefaultStr>(pub(crate) S); -unsafe impl + AsRef + Clone + Serialize> RecordKeyType for Rkey { +unsafe impl + AsRef> RecordKeyType for Rkey { fn as_str(&self) -> &str { self.0.as_ref() } @@ -166,44 +159,51 @@ impl> Rkey { } } -impl<'r> Rkey<&'r str> { - /// Fallible constructor, validates, borrows from input. - pub fn new(rkey: &'r str) -> Result { - validate_rkey(rkey)?; - Ok(Self(rkey)) +impl + AsRef> Rkey { + /// Fallible constructor, validates, wraps the input directly. + pub fn new(s: S) -> Result { + validate_rkey(s.as_ref())?; + Ok(Self(s)) } /// Infallible constructor. Panics on invalid rkeys. - pub fn raw(rkey: &'r str) -> Self { - Self::new(rkey).expect("invalid rkey") + pub fn raw(s: S) -> Self { + Self::new(s).expect("invalid rkey") } } -impl + From> Rkey { +impl + FromStr> Rkey { /// Fallible constructor, validates, takes ownership. pub fn new_owned(rkey: impl AsRef) -> Result { let rkey = rkey.as_ref(); validate_rkey(rkey)?; - Ok(Self(S::from(rkey.to_smolstr()))) + let s = S::from_str(rkey).map_err(|_| { + AtStrError::new("record-key", rkey.to_string(), StrParseKind::Conversion) + })?; + Ok(Self(s)) } /// Fallible constructor for static strings. pub fn new_static(rkey: &'static str) -> Result { validate_rkey(rkey)?; - Ok(Self(S::from(SmolStr::new_static(rkey)))) + let s = S::from_str(rkey).map_err(|_| { + AtStrError::new("record-key", rkey.to_string(), StrParseKind::Conversion) + })?; + Ok(Self(s)) } } -impl<'r> Rkey> { - /// Fallible constructor, borrows if possible. - pub fn new_cow(rkey: CowStr<'r>) -> Result { - validate_rkey(&rkey)?; - Ok(Self(rkey)) - } +impl Bos for RecordKey +where + T: RecordKeyType + Bos + AsRef, +{ + type Ref<'this> + = &'this str + where + Self: 'this; - /// Infallible unchecked constructor for CowStr. - pub unsafe fn unchecked_cow(rkey: CowStr<'r>) -> Self { - Self(rkey) + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this.as_ref() } } @@ -288,7 +288,7 @@ impl From for Rkey { impl<'r> From> for Rkey> { fn from(value: CowStr<'r>) -> Self { - Self::new_cow(value).unwrap() + Self::new(value).unwrap() } } @@ -306,6 +306,20 @@ impl + AsRef> Deref for Rkey { } } +impl Bos for Rkey +where + S: Bos + AsRef, +{ + type Ref<'this> + = &'this str + where + Self: 'this; + + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this.as_str() + } +} + /// ATProto Record Key (type `literal:`) /// Zero-sized type, literal is associated constant of type parameter /// @@ -493,11 +507,11 @@ mod tests { assert!(Rkey::new("a").is_ok()); // min 1 let valid_512 = "a".repeat(512); assert_eq!(valid_512.len(), 512); - assert!(Rkey::new(&valid_512).is_ok()); + assert!(Rkey::new(valid_512).is_ok()); let too_long_513 = "a".repeat(513); assert_eq!(too_long_513.len(), 513); - assert!(Rkey::new(&too_long_513).is_err()); + assert!(Rkey::new(too_long_513).is_err()); } #[test] diff --git a/crates/jacquard-common/src/types/string.rs b/crates/jacquard-common/src/types/string.rs index a2617094..83759e61 100644 --- a/crates/jacquard-common/src/types/string.rs +++ b/crates/jacquard-common/src/types/string.rs @@ -73,12 +73,10 @@ pub enum AtprotoStr + AsRef + Clone + Serialize = DefaultStr> { Handle(Handle), /// Identifier (DID or handle) AtIdentifier(AtIdentifier), - // TODO(bos-migration): parameterise on S once AtUri is migrated. /// AT URI - AtUri(AtUri<'static>), - // TODO(bos-migration): parameterise on S once UriValue is migrated. + AtUri(AtUri), /// Generic URI - Uri(UriValue<'static>), + Uri(UriValue), /// Content identifier Cid(Cid), /// Record key @@ -123,18 +121,28 @@ impl + AsRef + Clone + Serialize> AtprotoStr { if validate_nsid(s).is_ok() { return Self::Nsid(unsafe { Nsid::unchecked(string) }); } - // TODO(bos-migration): AtUri and UriValue still use lifetimes. - // For now, construct owned versions for those variants. - if let Ok(aturi) = AtUri::new_owned(s) { - return Self::AtUri(aturi); + if crate::types::aturi::validate_and_index(s).is_ok() { + return Self::AtUri(unsafe { AtUri::unchecked(string) }); } - if let Ok(uri) = UriValue::new_owned(s) { - return Self::Uri(uri); + // URI schemes that UriValue handles - check prefix, wrap S directly. + if s.starts_with("https://") || s.starts_with("wss://") || s.starts_with("ipld://") { + if let Ok(uri) = UriValue::new(s) { + // we don't want to always Any here, it's better to fall back to the String variant. + match uri { + UriValue::Any(_) => {} + _ => { + drop(s); + return Self::Uri(UriValue::new(string).expect("already checked")); + } + } + } } + let s: &str = string.as_ref(); // CID: try to parse as IPLD first, otherwise wrap as string CID. if IpldCid::try_from(s).is_ok() || s.starts_with("bafy") { return Self::Cid(unsafe { Cid::unchecked_str(string) }); } + drop(s); // Fallback: plain string. Self::String(string) } @@ -227,8 +235,8 @@ where AtprotoStr::Handle(handle) => AtprotoStr::Handle(handle.into_static()), AtprotoStr::AtIdentifier(ident) => AtprotoStr::AtIdentifier(ident.into_static()), // AtUri and UriValue are already 'static in this enum. - AtprotoStr::AtUri(at_uri) => AtprotoStr::AtUri(at_uri), - AtprotoStr::Uri(uri) => AtprotoStr::Uri(uri), + AtprotoStr::AtUri(at_uri) => AtprotoStr::AtUri(at_uri.into_static()), + AtprotoStr::Uri(uri) => AtprotoStr::Uri(uri.into_static()), AtprotoStr::Cid(cid) => AtprotoStr::Cid(cid.into_static()), AtprotoStr::RecordKey(record_key) => AtprotoStr::RecordKey(record_key.into_static()), AtprotoStr::String(s) => AtprotoStr::String(s.into_static()), @@ -480,4 +488,8 @@ pub enum StrParseKind { #[source] err: Arc, }, + /// Wraps another error with additional context + #[error("converting from a string slice")] + #[cfg_attr(feature = "std", diagnostic(code(jacquard::atstr::conversion)))] + Conversion, } diff --git a/crates/jacquard-common/src/types/uri.rs b/crates/jacquard-common/src/types/uri.rs index f2c1e0cf..82121f5c 100644 --- a/crates/jacquard-common/src/types/uri.rs +++ b/crates/jacquard-common/src/types/uri.rs @@ -1,129 +1,147 @@ -use crate::cowstr::ToCowStr; +use crate::bos::Bos; use crate::deps::fluent_uri::Uri; use crate::{ - CowStr, IntoStatic, + CowStr, DefaultStr, IntoStatic, types::{ - aturi::AtUri, cid::Cid, collection::Collection, did::Did, nsid::Nsid, string::AtStrError, + aturi::{AtUri, validate_and_index}, + cid::Cid, + collection::Collection, + did::{Did, validate_did}, + nsid::Nsid, + string::{AtStrError, StrParseKind}, }, }; -use alloc::string::String; +use alloc::string::{String, ToString}; use core::{fmt::Display, marker::PhantomData, ops::Deref, str::FromStr}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use smol_str::ToSmolStr; +use smol_str::{SmolStr, ToSmolStr}; -/// Generic URI with type-specific parsing +/// Generic URI with type-specific parsing. /// /// Automatically detects and parses URIs into the appropriate variant based on /// the scheme prefix. Used in lexicon where URIs can be of various types. /// /// Variants are checked by prefix: `did:`, `at://`, `https://`, `wss://`, `ipld://` #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum UriValue<'u> { - /// DID URI (did:) - Did(Did>), - /// AT Protocol URI (at://) - At(AtUri<'u>), - /// HTTPS URL +pub enum UriValue + AsRef = DefaultStr> { + /// DID URI (did:). + Did(Did), + /// AT Protocol URI (at://). + At(AtUri), + /// HTTPS URL. Https(Uri), - /// WebSocket Secure URL + /// WebSocket Secure URL. Wss(Uri), - /// IPLD CID URI - Cid(Cid>), - /// Unrecognized URI scheme (catch-all) - Any(CowStr<'u>), + /// IPLD CID URI. + Cid(Cid), + /// Unrecognized URI scheme (catch-all). + Any(S), } -/// Errors that can occur when parsing URIs +/// Errors that can occur when parsing URIs. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum UriParseError { - /// AT Protocol string parsing error + /// AT Protocol string parsing error. #[error("Invalid atproto string: {0}")] At(#[from] AtStrError), - /// URI parsing error + /// URI parsing error. #[error(transparent)] Uri(#[from] crate::deps::fluent_uri::ParseError), - /// CID parsing error + /// CID parsing error. #[error(transparent)] Cid(#[from] crate::types::cid::Error), } -impl<'u> UriValue<'u> { - /// Parse a URI from a string slice, borrowing - pub fn new(uri: &'u str) -> Result { - if uri.starts_with("did:") { - Ok(UriValue::Did(Did::new_cow(uri.to_cowstr())?)) - } else if uri.starts_with("at://") { - Ok(UriValue::At(AtUri::new(uri)?)) - } else if uri.starts_with("https://") { - Ok(UriValue::Https(Uri::parse(uri)?.to_owned())) - } else if uri.starts_with("wss://") { - Ok(UriValue::Wss(Uri::parse(uri)?.to_owned())) - } else if uri.starts_with("ipld://") { - // Borrow the slice after "ipld://" prefix (7 bytes) from the input &'u str. - let cid_part = &uri[7..]; - if cid_part.is_empty() { - Ok(UriValue::Any(CowStr::Borrowed(uri))) - } else { - Ok(UriValue::Cid(Cid::cow_str(CowStr::Borrowed(cid_part)))) +// --------------------------------------------------------------------------- +// Generic construction +// --------------------------------------------------------------------------- + +impl + AsRef> UriValue { + /// Parse a URI, validate by prefix, wrap `S` into the matching variant. + /// + /// `Https` and `Wss` variants always allocate a `Uri` regardless of `S`. + pub fn new(uri: S) -> Result { + let s = uri.as_ref(); + if s.starts_with("did:") { + if validate_did(s).is_ok() { + return Ok(UriValue::Did(unsafe { Did::unchecked(uri) })); } - } else { - Ok(UriValue::Any(CowStr::Borrowed(uri))) + } else if s.starts_with("at://") { + if let Ok(indices) = validate_and_index(s) { + return Ok(UriValue::At(unsafe { AtUri::from_parts(uri, indices) })); + } + } else if s.starts_with("https://") { + if let Ok(parsed) = Uri::parse(s) { + return Ok(UriValue::Https(parsed.to_owned())); + } + } else if s.starts_with("wss://") { + if let Ok(parsed) = Uri::parse(s) { + return Ok(UriValue::Wss(parsed.to_owned())); + } + } else if s.starts_with("ipld://") { + return Ok(UriValue::Cid(unsafe { Cid::unchecked_str(uri) })); } + Ok(UriValue::Any(uri)) } +} - /// Parse a URI from a string, taking ownership - pub fn new_owned(uri: impl AsRef) -> Result, UriParseError> { - let uri = uri.as_ref(); - if uri.starts_with("did:") { - Ok(UriValue::Did(Did::new_owned(uri)?)) - } else if uri.starts_with("at://") { - Ok(UriValue::At(AtUri::new_owned(uri)?)) - } else if uri.starts_with("https://") { - Ok(UriValue::Https(Uri::parse(uri)?.to_owned())) - } else if uri.starts_with("wss://") { - Ok(UriValue::Wss(Uri::parse(uri)?.to_owned())) - } else if uri.starts_with("ipld://") { - // Owned context: use SmolStr via CowStr::Owned. - let cid_part = &uri[7..]; +// --------------------------------------------------------------------------- +// Owned construction +// --------------------------------------------------------------------------- + +impl + AsRef + FromStr> UriValue { + /// Parse a URI from a string, taking ownership. + pub fn new_owned(uri: impl AsRef) -> Result { + let uri_str = uri.as_ref(); + if uri_str.starts_with("did:") { + Ok(UriValue::Did(Did::new_owned(uri_str)?)) + } else if uri_str.starts_with("at://") { + Ok(UriValue::At(AtUri::new_owned(uri_str)?)) + } else if uri_str.starts_with("https://") { + Ok(UriValue::Https(Uri::parse(uri_str)?.to_owned())) + } else if uri_str.starts_with("wss://") { + Ok(UriValue::Wss(Uri::parse(uri_str)?.to_owned())) + } else if uri_str.starts_with("ipld://") { + let cid_part = &uri_str[7..]; if cid_part.is_empty() { - Ok(UriValue::Any(CowStr::Owned(uri.to_smolstr()))) + let s = S::from_str(uri_str).map_err(|_| { + UriParseError::At(AtStrError::new( + "uri", + uri_str.to_string(), + StrParseKind::Conversion, + )) + })?; + Ok(UriValue::Any(s)) } else { - Ok(UriValue::Cid(Cid::cow_str(CowStr::Owned(cid_part.to_smolstr())))) + let s = S::from_str(cid_part).map_err(|_| { + UriParseError::At(AtStrError::new( + "uri", + cid_part.to_string(), + StrParseKind::Conversion, + )) + })?; + Ok(UriValue::Cid(unsafe { Cid::unchecked_str(s) })) } } else { - Ok(UriValue::Any(CowStr::Owned(uri.to_smolstr()))) + let s = S::from_str(uri_str).map_err(|_| { + UriParseError::At(AtStrError::new( + "uri", + uri_str.to_string(), + StrParseKind::Conversion, + )) + })?; + Ok(UriValue::Any(s)) } } +} - /// Parse a URI from a CowStr, borrowing where possible - pub fn new_cow(uri: CowStr<'u>) -> Result { - if uri.starts_with("did:") { - Ok(UriValue::Did(Did::new_cow(uri)?)) - } else if uri.starts_with("at://") { - Ok(UriValue::At(AtUri::new_cow(uri)?)) - } else if uri.starts_with("https://") { - Ok(UriValue::Https(Uri::parse(uri.as_ref())?.to_owned())) - } else if uri.starts_with("wss://") { - Ok(UriValue::Wss(Uri::parse(uri.as_ref())?.to_owned())) - } else if uri.starts_with("ipld://") { - // Determine whether the CID part (after "ipld://") is non-empty before consuming uri. - if uri.as_ref()[7..].is_empty() { - Ok(UriValue::Any(uri)) - } else { - // Build a CowStr for the CID part, preserving the ownership variant. - let cid_cow: CowStr<'u> = match uri { - CowStr::Borrowed(s) => CowStr::Borrowed(&s[7..]), - CowStr::Owned(ref s) => CowStr::Owned(s[7..].to_smolstr()), - }; - Ok(UriValue::Cid(Cid::cow_str(cid_cow))) - } - } else { - Ok(UriValue::Any(uri)) - } - } +// --------------------------------------------------------------------------- +// Accessors +// --------------------------------------------------------------------------- - /// Get the URI as a string slice +impl + AsRef> UriValue { + /// Get the URI as a string slice. pub fn as_str(&self) -> &str { match self { UriValue::Did(did) => did.as_str(), @@ -136,44 +154,54 @@ impl<'u> UriValue<'u> { } } -impl Serialize for UriValue<'_> { - fn serialize(&self, serializer: S) -> Result +// --------------------------------------------------------------------------- +// Serde +// --------------------------------------------------------------------------- + +impl + AsRef> Serialize for UriValue { + fn serialize(&self, serializer: Ser) -> Result where - S: Serializer, + Ser: Serializer, { serializer.serialize_str(self.as_str()) } } -impl<'de, 'a> Deserialize<'de> for UriValue<'a> -where - 'de: 'a, -{ +impl<'de> Deserialize<'de> for UriValue> { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - use serde::de::Error; - let value = Deserialize::deserialize(deserializer)?; - Self::new_cow(value).map_err(D::Error::custom) + let value: CowStr<'de> = Deserialize::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) } } -impl<'s> AsRef for UriValue<'s> { +impl<'de> Deserialize<'de> for UriValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s: SmolStr = Deserialize::deserialize(deserializer)?; + Self::new(s).map_err(serde::de::Error::custom) + } +} + +// --------------------------------------------------------------------------- +// AsRef, IntoStatic +// --------------------------------------------------------------------------- + +impl + AsRef> AsRef for UriValue { fn as_ref(&self) -> &str { - match self { - UriValue::Did(did) => did.as_str(), - UriValue::At(at_uri) => at_uri.as_str(), - UriValue::Https(url) => url.as_str(), - UriValue::Wss(url) => url.as_str(), - UriValue::Cid(cid) => cid.as_str(), - UriValue::Any(s) => s.as_ref(), - } + self.as_str() } } -impl IntoStatic for UriValue<'_> { - type Output = UriValue<'static>; +impl + AsRef + IntoStatic> IntoStatic for UriValue +where + S::Output: Bos + AsRef, +{ + type Output = UriValue; fn into_static(self) -> Self::Output { match self { @@ -187,15 +215,19 @@ impl IntoStatic for UriValue<'_> { } } +// --------------------------------------------------------------------------- +// RecordUri +// --------------------------------------------------------------------------- + #[repr(transparent)] -/// Collection type-annotated at:// URI +/// Collection type-annotated at:// URI. /// -/// Carries the corresponding collection type for fetching records easily -pub struct RecordUri<'a, R: Collection>(AtUri<'a>, PhantomData); +/// Carries the corresponding collection type for fetching records easily. +pub struct RecordUri + AsRef, R: Collection>(AtUri, PhantomData); -impl<'a, R: Collection> RecordUri<'a, R> { - /// attepts to parse an at-uri as the corresponding collection - pub fn try_from_uri(uri: AtUri<'a>) -> Result { +impl + AsRef, R: Collection> RecordUri { + /// Attempts to parse an at-uri as the corresponding collection. + pub fn try_from_uri(uri: AtUri) -> Result { if let Some(collection) = uri.collection() { if collection.as_str() == R::NSID { return Ok(Self(uri, PhantomData)); @@ -209,31 +241,31 @@ impl<'a, R: Collection> RecordUri<'a, R> { }) } - /// Spits out the internal un-typed AtUri - pub fn into_inner(self) -> AtUri<'a> { + /// Returns the internal un-typed AtUri. + pub fn into_inner(self) -> AtUri { self.0 } - /// Accesses the internal AtUri for use - pub fn as_uri(&self) -> &AtUri<'a> { + /// Accesses the internal AtUri for use. + pub fn as_uri(&self) -> &AtUri { &self.0 } } -impl Display for RecordUri<'_, R> { +impl + AsRef, R: Collection> Display for RecordUri { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { self.0.fmt(f) } } -impl<'a, R: Collection> AsRef> for RecordUri<'a, R> { - fn as_ref(&self) -> &AtUri<'a> { +impl + AsRef, R: Collection> AsRef> for RecordUri { + fn as_ref(&self) -> &AtUri { &self.0 } } -impl<'a, R: Collection> Deref for RecordUri<'a, R> { - type Target = AtUri<'a>; +impl + AsRef, R: Collection> Deref for RecordUri { + type Target = AtUri; fn deref(&self) -> &Self::Target { &self.0 @@ -242,17 +274,17 @@ impl<'a, R: Collection> Deref for RecordUri<'a, R> { #[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] -/// Errors that can occur when parsing or validating collection type-annotated URIs +/// Errors that can occur when parsing or validating collection type-annotated URIs. pub enum UriError { - /// Given at-uri didn't have the matching collection for the record + /// Given at-uri didn't have the matching collection for the record. #[error("Collection mismatch: expected {expected}, found {found:?}")] CollectionMismatch { - /// The collection of the record + /// The collection of the record. expected: &'static str, - /// What the at-uri had + /// What the at-uri had. found: Option, }, - /// Couldn't parse the string as an AtUri + /// Couldn't parse the string as an AtUri. #[error("Invalid URI: {0}")] InvalidUri(#[from] AtStrError), } @@ -263,7 +295,6 @@ mod tests { #[test] fn test_wss_variant_parsing() { - // Test that wss:// URIs are parsed as UriValue::Wss, not UriValue::Https let uri = UriValue::new("wss://example.com/path").expect("valid wss uri"); assert!( matches!(uri, UriValue::Wss(_)), @@ -274,7 +305,6 @@ mod tests { #[test] fn test_https_variant_parsing() { - // Test that https:// URIs are parsed as UriValue::Https let uri = UriValue::new("https://example.com/path").expect("valid https uri"); assert!( matches!(uri, UriValue::Https(_)), @@ -285,8 +315,8 @@ mod tests { #[test] fn test_wss_owned_variant_parsing() { - // Test that owned wss:// parsing works correctly - let uri = UriValue::new_owned("wss://example.com").expect("valid wss uri"); + let uri: UriValue = + UriValue::new_owned("wss://example.com").expect("valid wss uri"); assert!( matches!(uri, UriValue::Wss(_)), "owned wss:// should parse to UriValue::Wss" @@ -296,8 +326,8 @@ mod tests { #[test] fn test_https_owned_variant_parsing() { - // Test that owned https:// parsing works correctly - let uri = UriValue::new_owned("https://example.com").expect("valid https uri"); + let uri: UriValue = + UriValue::new_owned("https://example.com").expect("valid https uri"); assert!( matches!(uri, UriValue::Https(_)), "owned https:// should parse to UriValue::Https" @@ -307,8 +337,7 @@ mod tests { #[test] fn test_wss_cow_variant_parsing() { - // Test that cow variant parsing works correctly for wss:// - let uri = UriValue::new_cow(CowStr::Borrowed("wss://example.com")).expect("valid wss uri"); + let uri = UriValue::new(CowStr::Borrowed("wss://example.com")).expect("valid wss uri"); assert!( matches!(uri, UriValue::Wss(_)), "cow wss:// should parse to UriValue::Wss" @@ -318,9 +347,7 @@ mod tests { #[test] fn test_https_cow_variant_parsing() { - // Test that cow variant parsing works correctly for https:// - let uri = - UriValue::new_cow(CowStr::Borrowed("https://example.com")).expect("valid https uri"); + let uri = UriValue::new(CowStr::Borrowed("https://example.com")).expect("valid https uri"); assert!( matches!(uri, UriValue::Https(_)), "cow https:// should parse to UriValue::Https" @@ -330,22 +357,20 @@ mod tests { #[test] fn test_uri_display() { - // Test that Display output preserves the original scheme - let wss = UriValue::new_owned("wss://example.com").unwrap(); + let wss: UriValue = UriValue::new_owned("wss://example.com").unwrap(); assert_eq!(wss.as_str(), "wss://example.com"); - let https = UriValue::new_owned("https://example.com").unwrap(); + let https: UriValue = UriValue::new_owned("https://example.com").unwrap(); assert_eq!(https.as_str(), "https://example.com"); } #[test] fn test_into_static_preserves_variant() { - // Test that IntoStatic conversion preserves the variant type - let wss = UriValue::new_owned("wss://example.com").unwrap(); + let wss: UriValue = UriValue::new_owned("wss://example.com").unwrap(); let static_wss = wss.into_static(); assert!(matches!(static_wss, UriValue::Wss(_))); - let https = UriValue::new_owned("https://example.com").unwrap(); + let https: UriValue = UriValue::new_owned("https://example.com").unwrap(); let static_https = https.into_static(); assert!(matches!(static_https, UriValue::Https(_))); } diff --git a/crates/jacquard-common/src/types/value/parsing.rs b/crates/jacquard-common/src/types/value/parsing.rs index 125b4f6c..6b9b2f0a 100644 --- a/crates/jacquard-common/src/types/value/parsing.rs +++ b/crates/jacquard-common/src/types/value/parsing.rs @@ -42,7 +42,7 @@ pub fn insert_string<'s>( } } LexiconStringType::AtUri => { - if let Ok(value) = AtUri::new(value) { + if let Ok(value) = AtUri::new(value.to_cowstr()) { // AtprotoStr::AtUri stores AtUri<'static>; convert to owned. map.insert( key.to_smolstr(), @@ -56,7 +56,7 @@ pub fn insert_string<'s>( } } LexiconStringType::Did => { - if let Ok(value) = Did::new_cow(value.to_cowstr()) { + if let Ok(value) = Did::new(value.to_cowstr()) { map.insert(key.to_smolstr(), Data::String(AtprotoStr::Did(value))); } else { map.insert( @@ -66,7 +66,7 @@ pub fn insert_string<'s>( } } LexiconStringType::Handle => { - if let Ok(value) = Handle::new_cow(value.into()) { + if let Ok(value) = Handle::new(value.to_cowstr()) { map.insert(key.to_smolstr(), Data::String(AtprotoStr::Handle(value))); } else { map.insert( @@ -76,7 +76,7 @@ pub fn insert_string<'s>( } } LexiconStringType::AtIdentifier => { - if let Ok(value) = AtIdentifier::new_cow(value.to_cowstr()) { + if let Ok(value) = AtIdentifier::new(value.to_cowstr()) { map.insert( key.to_smolstr(), Data::String(AtprotoStr::AtIdentifier(value)), @@ -89,7 +89,7 @@ pub fn insert_string<'s>( } } LexiconStringType::Nsid => { - if let Ok(value) = Nsid::new_cow(value.to_cowstr()) { + if let Ok(value) = Nsid::new(value.to_cowstr()) { map.insert(key.to_smolstr(), Data::String(AtprotoStr::Nsid(value))); } else { map.insert( @@ -135,8 +135,7 @@ pub fn insert_string<'s>( key.to_smolstr(), // Rkey already validated above; borrow the original &'s str directly. Data::String(AtprotoStr::RecordKey( - RecordKey::any_cow(CowStr::Borrowed(value)) - .expect("Rkey validation passed"), + RecordKey::any(CowStr::Borrowed(value)).expect("Rkey validation passed"), )), ); } else { @@ -167,7 +166,7 @@ pub fn insert_string<'s>( /// smarter parsing to avoid trying as many posibilities. pub fn parse_string<'s>(string: &'s str) -> AtprotoStr> { if string.len() < 2048 && string.starts_with("did:") { - if let Ok(did) = Did::new_cow(string.to_cowstr()) { + if let Ok(did) = Did::new(string.to_cowstr()) { return AtprotoStr::Did(did); } } else if string.starts_with("20") && string.ends_with("Z") { @@ -177,8 +176,8 @@ pub fn parse_string<'s>(string: &'s str) -> AtprotoStr> { } } else if string.starts_with("at://") { // AtprotoStr::AtUri stores AtUri<'static>; convert to owned. - if let Ok(uri) = AtUri::new(string) { - return AtprotoStr::AtUri(uri.into_static()); + if let Ok(uri) = AtUri::new(string.to_cowstr()) { + return AtprotoStr::AtUri(uri); } } else if string.starts_with("https://") { if let Ok(uri) = Uri::parse(string) { @@ -209,29 +208,29 @@ pub fn parse_string<'s>(string: &'s str) -> AtprotoStr> { // First segment is a known TLD → reverse domain order → try NSID first. if first_is_tld { - if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { + if let Ok(nsid) = Nsid::new(string.to_cowstr()) { return AtprotoStr::Nsid(nsid); } } // Last segment is a known TLD and first is not → normal domain order → handle. if last_is_tld && !first_is_tld { - if let Ok(handle) = AtIdentifier::new_cow(string.to_cowstr()) { + if let Ok(handle) = AtIdentifier::new(string.to_cowstr()) { return AtprotoStr::AtIdentifier(handle); } } // camelCase in last segment → NSID (e.g., "com.atproto.repo.getRecord"). if has_upper_last_segment { - if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { + if let Ok(nsid) = Nsid::new(string.to_cowstr()) { return AtprotoStr::Nsid(nsid); } } // Fallback: try both, preferring handle. - if let Ok(handle) = AtIdentifier::new_cow(string.to_cowstr()) { + if let Ok(handle) = AtIdentifier::new(string.to_cowstr()) { return AtprotoStr::AtIdentifier(handle); - } else if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { + } else if let Ok(nsid) = Nsid::new(string.to_cowstr()) { return AtprotoStr::Nsid(nsid); } else if string.contains("://") && Uri::<&str>::parse(string).is_ok() { // AtprotoStr::Uri stores UriValue<'static>; convert to owned. diff --git a/crates/jacquard-common/src/types/value/serde_impl.rs b/crates/jacquard-common/src/types/value/serde_impl.rs index 33b1d4a8..1ac05e3d 100644 --- a/crates/jacquard-common/src/types/value/serde_impl.rs +++ b/crates/jacquard-common/src/types/value/serde_impl.rs @@ -384,7 +384,7 @@ fn refine_string_by_type<'s>(s: CowStr<'s>, string_type: LexiconStringType) -> D LexiconStringType::Tid => Tid::new(s.clone()) .map(|tid| Data::String(AtprotoStr::Tid(tid))) .unwrap_or_else(|_| Data::String(AtprotoStr::String(s.clone()))), - LexiconStringType::RecordKey => Rkey::new_cow(s.clone()) + LexiconStringType::RecordKey => Rkey::new(s.clone()) .map(|rkey| Data::String(AtprotoStr::RecordKey(RecordKey(rkey)))) .unwrap_or_else(|_| Data::String(AtprotoStr::String(s.clone()))), LexiconStringType::Uri(_) => UriValue::new_owned(s.clone()) diff --git a/crates/jacquard-common/src/types/value/tests.rs b/crates/jacquard-common/src/types/value/tests.rs index 7e47df2c..3fcef20c 100644 --- a/crates/jacquard-common/src/types/value/tests.rs +++ b/crates/jacquard-common/src/types/value/tests.rs @@ -1,3 +1,5 @@ +use crate::cowstr::ToCowStr; + use super::*; use core::str::FromStr; @@ -560,7 +562,7 @@ fn test_aturi_deserialization() { #[derive(Debug, Deserialize)] struct WithAtUri<'a> { #[serde(borrow)] - uri: AtUri<'a>, + uri: AtUri>, did: Did>, } @@ -568,7 +570,7 @@ fn test_aturi_deserialization() { map.insert( SmolStr::new_static("uri"), Data::String(AtprotoStr::AtUri( - AtUri::new("at://alice.bsky.social/app.bsky.feed.post/3jk5").unwrap(), + AtUri::new("at://alice.bsky.social/app.bsky.feed.post/3jk5".to_cowstr()).unwrap(), )), ); map.insert( @@ -586,28 +588,27 @@ fn test_aturi_deserialization() { } #[test] -fn test_aturi_zero_copy() { - use serde::Deserialize; - - #[derive(Debug, Deserialize)] - struct WithAtUri<'a> { - #[serde(borrow)] - uri: AtUri<'a>, - } - - // Use borrowed CowStr to create the AtUri +fn test_aturi_zero_copy_borrowed() { + // AtUri<&str> should point at the original buffer. let uri_str = "at://alice.bsky.social/app.bsky.feed.post/3jk5"; - let mut map = BTreeMap::new(); - map.insert( - SmolStr::new_static("uri"), - Data::String(AtprotoStr::AtUri(AtUri::new(uri_str).unwrap())), - ); - let data = Data::Object(Object(map)); + let uri = AtUri::new(uri_str).unwrap(); + assert_eq!(uri.as_str().as_ptr(), uri_str.as_ptr()); +} - let result: WithAtUri = from_data(&data).unwrap(); +#[test] +fn test_aturi_zero_copy_cowstr() { + // AtUri> with a borrowed CowStr should point at the original buffer. + let uri_str = "at://alice.bsky.social/app.bsky.feed.post/3jk5"; + let uri = AtUri::new(CowStr::Borrowed(uri_str)).unwrap(); + assert_eq!(uri.as_str().as_ptr(), uri_str.as_ptr()); +} - // Check if the AtUri borrowed from the original string - assert_eq!(result.uri.as_str().as_ptr(), uri_str.as_ptr()); +#[test] +fn test_aturi_owned_allocates() { + // AtUri allocates — pointers should NOT match. + let uri_str = "at://alice.bsky.social/app.bsky.feed.post/3jk5"; + let uri: AtUri = AtUri::new_owned(uri_str).unwrap(); + assert_ne!(uri.as_str().as_ptr(), uri_str.as_ptr()); } #[test] diff --git a/crates/jacquard-common/src/xrpc/atproto.rs b/crates/jacquard-common/src/xrpc/atproto.rs index 26469c7d..d1fc1c88 100644 --- a/crates/jacquard-common/src/xrpc/atproto.rs +++ b/crates/jacquard-common/src/xrpc/atproto.rs @@ -89,7 +89,7 @@ pub struct ListRecordsRecord<'a> { #[serde(borrow)] pub cid: Option>>, #[serde(borrow)] - pub uri: AtUri<'a>, + pub uri: AtUri>, #[serde(borrow)] pub value: Data<'a>, } @@ -164,7 +164,7 @@ pub struct GetRecordOutput<'a> { #[serde(borrow)] pub cid: Option>>, #[serde(borrow)] - pub uri: AtUri<'a>, + pub uri: AtUri>, #[serde(borrow)] pub value: Data<'a>, } @@ -453,13 +453,13 @@ where #[cfg(test)] mod tests { use super::*; - use crate::IntoStatic; + use crate::{IntoStatic, cowstr::ToCowStr}; #[test] fn test_list_records_serializes() { let req = ListRecords { - repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), - collection: Nsid::new_cow("app.bsky.feed.post".into()) + repo: AtIdentifier::new("test.bsky.social".to_cowstr()).unwrap(), + collection: Nsid::new("app.bsky.feed.post".to_cowstr()) .unwrap() .into_static(), cursor: None, @@ -552,8 +552,8 @@ mod tests { #[test] fn test_types_implement_into_static() { let list_records = ListRecords { - repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), - collection: Nsid::new_cow("app.bsky.feed.post".into()) + repo: AtIdentifier::new("test.bsky.social".to_cowstr()).unwrap(), + collection: Nsid::new("app.bsky.feed.post".to_cowstr()) .unwrap() .into_static(), cursor: None, @@ -563,8 +563,8 @@ mod tests { let _static = list_records.into_static(); let get_record = GetRecord { - repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), - collection: Nsid::new_cow("app.bsky.feed.post".into()) + repo: AtIdentifier::new("test.bsky.social".to_cowstr()).unwrap(), + collection: Nsid::new("app.bsky.feed.post".to_cowstr()) .unwrap() .into_static(), rkey: CowStr::from("abc123").into_static(),