A better Rust ATProto crate
102
fork

Configure Feed

Select the types of activity you want to include in your feed.

AtUri and deps migrated, fewer temporary cows

+850 -1036
+8
crates/jacquard-common/src/cowstr.rs
··· 335 335 } 336 336 } 337 337 338 + impl core::str::FromStr for CowStr<'_> { 339 + type Err = core::convert::Infallible; 340 + 341 + fn from_str(s: &str) -> Result<Self, Self::Err> { 342 + Ok(CowStr::copy_from_str(s)) 343 + } 344 + } 345 + 338 346 /// Convert to a CowStr. 339 347 pub trait ToCowStr { 340 348 /// Convert to a CowStr.
+435 -640
crates/jacquard-common/src/types/aturi.rs
··· 1 - use crate::cowstr::ToCowStr; 1 + use crate::bos::{BorrowOrShare, Bos}; 2 2 use crate::types::ident::AtIdentifier; 3 3 use crate::types::nsid::Nsid; 4 4 use crate::types::recordkey::{RecordKey, Rkey}; 5 - use crate::types::string::AtStrError; 6 - use crate::{CowStr, IntoStatic}; 5 + use crate::types::string::{AtStrError, StrParseKind}; 6 + use crate::{CowStr, DefaultStr, IntoStatic}; 7 + use alloc::format; 7 8 use alloc::string::String; 8 9 use alloc::string::ToString; 9 10 use core::fmt; 10 11 use core::hash::{Hash, Hasher}; 12 + use core::num::NonZeroU16; 11 13 use core::ops::Deref; 12 14 use core::str::FromStr; 13 15 #[cfg(all(not(target_arch = "wasm32"), feature = "std"))] ··· 22 24 23 25 use super::Lazy; 24 26 25 - /// AT Protocol URI (`at://`) for referencing records in repositories 27 + /// Byte indices of delimiter positions within an AT URI string. 28 + /// 29 + /// Each index points at the delimiter character itself (`/` or `#`). 30 + /// Uses `NonZeroU16` for niche optimisation — `Option<NonZeroU16>` is 2 bytes. 31 + /// Safe because AT URIs start with `at://` (5 bytes), so any delimiter is at index >= 5. 32 + #[derive(Clone, Copy, PartialEq, Eq, Debug)] 33 + pub(crate) struct AtUriIndices { 34 + /// Index of the `/` separating authority from collection. 35 + first_slash: Option<NonZeroU16>, 36 + /// Index of the `/` separating collection from rkey. 37 + second_slash: Option<NonZeroU16>, 38 + /// Index of the `#` starting the fragment. 39 + hash: Option<NonZeroU16>, 40 + } 41 + 42 + impl AtUriIndices { 43 + /// End of the authority component. 44 + fn authority_end(&self, len: usize) -> usize { 45 + self.first_slash 46 + .or(self.hash) 47 + .map(|n| n.get() as usize) 48 + .unwrap_or(len) 49 + } 50 + 51 + /// End of the collection component (only valid if first_slash is Some). 52 + fn collection_end(&self, len: usize) -> usize { 53 + self.second_slash 54 + .or(self.hash) 55 + .map(|n| n.get() as usize) 56 + .unwrap_or(len) 57 + } 58 + 59 + /// End of the rkey component (only valid if second_slash is Some). 60 + fn rkey_end(&self, len: usize) -> usize { 61 + self.hash.map(|n| n.get() as usize).unwrap_or(len) 62 + } 63 + } 64 + 65 + /// AT Protocol URI (`at://`) for referencing records in repositories. 26 66 /// 27 67 /// AT URIs provide a way to reference records using either a DID or handle as the authority. 28 68 /// They're not content-addressed, so the record's contents can change over time. ··· 38 78 /// - `at://did:plc:abc123/app.bsky.feed.post/3jk5` 39 79 /// 40 80 /// See: <https://atproto.com/specs/at-uri-scheme> 41 - #[derive(PartialEq, Eq, Debug)] 42 - pub struct AtUri<'u> { 43 - inner: Inner<'u>, 81 + #[derive(Clone, Debug)] 82 + pub struct AtUri<S: Bos<str> + AsRef<str> = DefaultStr> { 83 + uri: S, 84 + indices: AtUriIndices, 85 + } 86 + 87 + impl<S: Bos<str> + AsRef<str>> PartialEq for AtUri<S> { 88 + fn eq(&self, other: &Self) -> bool { 89 + self.uri.as_ref() == other.uri.as_ref() 90 + } 91 + } 92 + 93 + impl<S: Bos<str> + AsRef<str>> Eq for AtUri<S> {} 94 + 95 + impl<S: Bos<str> + AsRef<str>> Hash for AtUri<S> { 96 + fn hash<H: Hasher>(&self, state: &mut H) { 97 + self.uri.as_ref().hash(state); 98 + } 44 99 } 45 100 46 - #[ouroboros::self_referencing] 47 - #[derive(PartialEq, Eq, Debug)] 48 - struct Inner<'u> { 49 - uri: CowStr<'u>, 50 - #[borrows(uri)] 51 - #[covariant] 52 - pub authority: AtIdentifier<CowStr<'this>>, 53 - #[borrows(uri)] 54 - #[covariant] 55 - pub path: Option<RepoPath<'this>>, 56 - #[borrows(uri)] 57 - #[covariant] 58 - pub fragment: Option<CowStr<'this>>, 101 + /// Path component of an AT URI (collection and optional record key). 102 + /// 103 + /// Represents the `/COLLECTION[/RKEY]` portion of an AT URI. 104 + pub struct RepoPath<S: Bos<str> + AsRef<str> = DefaultStr> { 105 + /// Collection NSID (e.g., `app.bsky.feed.post`). 106 + pub collection: Nsid<S>, 107 + /// Optional record key identifying a specific record. 108 + pub rkey: Option<RecordKey<Rkey<S>>>, 59 109 } 60 110 61 - impl Clone for AtUri<'_> { 111 + impl<S: Bos<str> + AsRef<str> + Clone> Clone for RepoPath<S> { 62 112 fn clone(&self) -> Self { 63 - let uri = self.inner.borrow_uri(); 113 + RepoPath { 114 + collection: self.collection.clone(), 115 + rkey: self.rkey.clone(), 116 + } 117 + } 118 + } 64 119 65 - Self { 66 - inner: Inner::new( 67 - CowStr::Owned(uri.as_ref().to_smolstr()), 68 - |uri| { 69 - let parts = ATURI_REGEX.captures(uri).unwrap(); 70 - AtIdentifier::new_cow(parts.name("authority").unwrap().as_str().to_cowstr()) 71 - .unwrap() 72 - }, 73 - |uri| { 74 - let parts = ATURI_REGEX.captures(uri).unwrap(); 75 - if let Some(collection) = parts.name("collection") { 76 - let collection = 77 - unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; 78 - let rkey = if let Some(rkey) = parts.name("rkey") { 79 - let rkey = unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; 80 - Some(rkey) 81 - } else { 82 - None 83 - }; 84 - Some(RepoPath { collection, rkey }) 85 - } else { 86 - None 87 - } 88 - }, 89 - |uri| { 90 - let parts = ATURI_REGEX.captures(uri).unwrap(); 91 - parts.name("fragment").map(|fragment| { 92 - let fragment = CowStr::Borrowed(fragment.as_str()); 93 - fragment 94 - }) 95 - }, 96 - ), 97 - } 120 + impl<S: Bos<str> + AsRef<str>> PartialEq for RepoPath<S> { 121 + fn eq(&self, other: &Self) -> bool { 122 + self.collection.as_str() == other.collection.as_str() 123 + && match (&self.rkey, &other.rkey) { 124 + (Some(a), Some(b)) => a.as_ref() == b.as_ref(), 125 + (None, None) => true, 126 + _ => false, 127 + } 98 128 } 99 129 } 100 130 101 - impl Hash for AtUri<'_> { 131 + impl<S: Bos<str> + AsRef<str>> Eq for RepoPath<S> {} 132 + 133 + impl<S: Bos<str> + AsRef<str>> Hash for RepoPath<S> { 102 134 fn hash<H: Hasher>(&self, state: &mut H) { 103 - self.inner.borrow_uri().hash(state); 135 + self.collection.as_str().hash(state); 136 + if let Some(rkey) = &self.rkey { 137 + rkey.as_ref().hash(state); 138 + } 104 139 } 105 140 } 106 141 107 - /// Path component of an AT URI (collection and optional record key) 108 - /// 109 - /// Represents the `/COLLECTION[/RKEY]` portion of an AT URI. 110 - #[derive(Clone, PartialEq, Eq, Hash, Debug)] 111 - pub struct RepoPath<'u> { 112 - /// Collection NSID (e.g., `app.bsky.feed.post`) 113 - pub collection: Nsid<CowStr<'u>>, 114 - /// Optional record key identifying a specific record 115 - pub rkey: Option<RecordKey<Rkey<CowStr<'u>>>>, 142 + impl<S: Bos<str> + AsRef<str>> fmt::Debug for RepoPath<S> { 143 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 144 + f.debug_struct("RepoPath") 145 + .field("collection", &self.collection.as_str()) 146 + .field("rkey", &self.rkey.as_ref().map(|r| r.as_ref())) 147 + .finish() 148 + } 116 149 } 117 150 118 - impl fmt::Display for RepoPath<'_> { 151 + impl<S: Bos<str> + AsRef<str>> fmt::Display for RepoPath<S> { 119 152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 120 153 write!(f, "/{}", self.collection)?; 121 154 if let Some(rkey) = &self.rkey { ··· 125 158 } 126 159 } 127 160 128 - impl IntoStatic for RepoPath<'_> { 129 - type Output = RepoPath<'static>; 161 + impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for RepoPath<S> 162 + where 163 + S::Output: Bos<str> + AsRef<str>, 164 + { 165 + type Output = RepoPath<S::Output>; 130 166 131 167 fn into_static(self) -> Self::Output { 132 168 RepoPath { ··· 136 172 } 137 173 } 138 174 139 - /// Owned (static lifetime) version of `RepoPath` 140 - pub type UriPathBuf = RepoPath<'static>; 175 + /// Owned (static lifetime) version of `RepoPath`. 176 + pub type UriPathBuf = RepoPath<SmolStr>; 141 177 142 - /// Regex for AT URI validation per AT Protocol spec 178 + /// Regex for AT URI validation per AT Protocol spec. 143 179 pub static ATURI_REGEX: Lazy<Regex> = Lazy::new(|| { 144 180 // Fragment allows: / and \ and other special chars. In raw string, backslashes are literal. 145 181 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() 146 182 }); 147 183 148 - impl<'u> AtUri<'u> { 149 - /// Fallible constructor, validates, borrows from input 150 - pub fn new(uri: &'u str) -> Result<Self, AtStrError> { 151 - if let Some(parts) = ATURI_REGEX.captures(uri) { 152 - if let Some(authority) = parts.name("authority") { 153 - let authority = AtIdentifier::new_cow(authority.as_str().to_cowstr()) 154 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 155 - let path = if let Some(collection) = parts.name("collection") { 156 - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 157 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 158 - let rkey = if let Some(rkey) = parts.name("rkey") { 159 - let rkey = 160 - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { 161 - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) 162 - })?); 163 - Some(rkey) 164 - } else { 165 - None 166 - }; 167 - Some(RepoPath { collection, rkey }) 168 - } else { 169 - None 170 - }; 171 - let fragment = parts.name("fragment").map(|fragment| { 172 - let fragment = CowStr::Borrowed(fragment.as_str()); 173 - fragment 174 - }); 175 - Ok(AtUri { 176 - inner: InnerBuilder { 177 - uri: CowStr::Borrowed(uri), 178 - authority_builder: |_| authority, 179 - path_builder: |_| path, 180 - fragment_builder: |_| fragment, 181 - } 182 - .build(), 183 - }) 184 - } else { 185 - Err(AtStrError::missing("at-uri-scheme", uri, "authority")) 186 - } 187 - } else { 188 - Err(AtStrError::regex( 189 - "at-uri-scheme", 190 - uri, 191 - SmolStr::new_static("doesn't match schema"), 192 - )) 193 - } 194 - } 184 + // --------------------------------------------------------------------------- 185 + // Internal validation 186 + // --------------------------------------------------------------------------- 195 187 196 - /// Infallible constructor for when you know the URI is valid 197 - /// 198 - /// Panics on invalid URIs. Use this when manually constructing URIs from trusted sources. 199 - pub fn raw(uri: &'u str) -> Self { 200 - if let Some(parts) = ATURI_REGEX.captures(uri) { 201 - if let Some(authority) = parts.name("authority") { 202 - let authority = AtIdentifier::new_cow(authority.as_str().to_cowstr()).unwrap(); 203 - let path = if let Some(collection) = parts.name("collection") { 204 - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())).unwrap(); 205 - let rkey = if let Some(rkey) = parts.name("rkey") { 206 - let rkey = RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).unwrap()); 207 - Some(rkey) 208 - } else { 209 - None 210 - }; 211 - Some(RepoPath { collection, rkey }) 212 - } else { 213 - None 214 - }; 215 - let fragment = parts.name("fragment").map(|fragment| { 216 - let fragment = CowStr::Borrowed(fragment.as_str()); 217 - fragment 218 - }); 219 - AtUri { 220 - inner: InnerBuilder { 221 - uri: CowStr::Borrowed(uri), 222 - authority_builder: |_| authority, 223 - path_builder: |_| path, 224 - fragment_builder: |_| fragment, 225 - } 226 - .build(), 227 - } 228 - } else { 229 - panic!("at:// URI missing authority") 230 - } 231 - } else { 232 - panic!("Invalid at:// URI via regex") 188 + /// Validate an AT URI string and extract delimiter indices. 189 + /// 190 + /// Runs the regex once. Does not parse components into typed wrappers — that happens 191 + /// lazily via the accessor methods. 192 + pub(crate) fn validate_and_index(uri: &str) -> Result<AtUriIndices, AtStrError> { 193 + let Some(parts) = ATURI_REGEX.captures(uri) else { 194 + return Err(AtStrError::regex( 195 + "at-uri-scheme", 196 + uri, 197 + SmolStr::new_static("doesn't match schema"), 198 + )); 199 + }; 200 + 201 + let Some(authority) = parts.name("authority") else { 202 + return Err(AtStrError::missing("at-uri-scheme", uri, "authority")); 203 + }; 204 + 205 + // Validate the authority as a DID or handle. 206 + AtIdentifier::new(authority.as_str()) 207 + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 208 + 209 + // Validate collection if present. 210 + if let Some(collection) = parts.name("collection") { 211 + Nsid::new(collection.as_str()) 212 + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 213 + 214 + // Validate rkey if present. 215 + if let Some(rkey) = parts.name("rkey") { 216 + Rkey::new(rkey.as_str()) 217 + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 233 218 } 234 219 } 235 220 236 - /// Unchecked borrowing constructor. This one does do some validation but if that fails will just 237 - /// dump everything in the authority field. 238 - /// 239 - /// TODO: do some fallback splitting, but really, if you use this on something invalid, you deserve it. 240 - pub unsafe fn unchecked(uri: &'u str) -> Self { 241 - if let Some(parts) = ATURI_REGEX.captures(uri) { 242 - if let Some(authority) = parts.name("authority") { 243 - let authority = 244 - unsafe { AtIdentifier::unchecked_cow(authority.as_str().to_cowstr()) }; 245 - let path = if let Some(collection) = parts.name("collection") { 246 - let collection = 247 - unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; 248 - let rkey = if let Some(rkey) = parts.name("rkey") { 249 - let rkey = unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; 250 - Some(rkey) 251 - } else { 252 - None 253 - }; 254 - Some(RepoPath { collection, rkey }) 255 - } else { 256 - None 257 - }; 258 - let fragment = parts.name("fragment").map(|fragment| { 259 - let fragment = CowStr::Borrowed(fragment.as_str()); 260 - fragment 261 - }); 262 - AtUri { 263 - inner: InnerBuilder { 264 - uri: CowStr::Borrowed(uri), 265 - authority_builder: |_| authority, 266 - path_builder: |_| path, 267 - fragment_builder: |_| fragment, 268 - } 269 - .build(), 270 - } 271 - } else { 272 - // let mut uriParts = uri.split('#'); 273 - // let mut parts = uriParts.next().unwrap_or(uri).split('/'); 274 - // let auth = parts.next().unwrap_or(uri); 275 - Self { 276 - inner: InnerBuilder { 277 - uri: CowStr::Borrowed(uri), 278 - authority_builder: |_| unsafe { 279 - AtIdentifier::unchecked_cow(uri.to_cowstr()) 280 - }, 281 - path_builder: |_| None, 282 - fragment_builder: |_| None, 283 - } 284 - .build(), 285 - } 221 + Ok(extract_indices(uri)) 222 + } 223 + 224 + /// Extract delimiter indices from a URI string that has already been validated. 225 + fn extract_indices(uri: &str) -> AtUriIndices { 226 + let bytes = uri.as_bytes(); 227 + let mut first_slash = None; 228 + let mut second_slash = None; 229 + let mut hash = None; 230 + 231 + // Start after "at://" (5 bytes). Walk until we find delimiters. 232 + let mut i = 5; 233 + while i < bytes.len() { 234 + match bytes[i] { 235 + b'/' if first_slash.is_none() => { 236 + first_slash = NonZeroU16::new(i as u16); 237 + } 238 + b'/' if second_slash.is_none() => { 239 + second_slash = NonZeroU16::new(i as u16); 286 240 } 287 - } else { 288 - Self { 289 - inner: InnerBuilder { 290 - uri: CowStr::Borrowed(uri), 291 - authority_builder: |_| unsafe { AtIdentifier::unchecked_cow(uri.to_cowstr()) }, 292 - path_builder: |_| None, 293 - fragment_builder: |_| None, 294 - } 295 - .build(), 241 + b'#' => { 242 + hash = NonZeroU16::new(i as u16); 243 + break; // Fragment is always last. 296 244 } 245 + _ => {} 297 246 } 247 + i += 1; 298 248 } 299 249 300 - /// Clone method that should be O(1) in terms of time 301 - /// 302 - /// Calling on a borrowed variant will turn it into an owned variant, taking a little 303 - /// more time and allocating memory for each part. Calling it on an owned variant will 304 - /// increment all the internal reference counters (or, if constructed from a `&'static str`, 305 - /// essentially do nothing). 306 - pub fn fast_clone(&self) -> AtUri<'static> { 307 - self.inner.with(move |u| { 308 - let uri = u.uri.clone().into_static(); 309 - let authority = u.authority.clone().into_static(); 310 - let path = u.path.clone().into_static(); 311 - let fragment = u.fragment.clone().into_static(); 312 - AtUri { 313 - inner: InnerBuilder { 314 - uri, 315 - authority_builder: |_| authority, 316 - path_builder: |_| path, 317 - fragment_builder: |_| fragment, 318 - } 319 - .build(), 320 - } 321 - }) 250 + AtUriIndices { 251 + first_slash, 252 + second_slash, 253 + hash, 322 254 } 255 + } 323 256 324 - /// Get the full URI as a string slice 325 - pub fn as_str(&self) -> &str { 326 - { 327 - let this = &self.inner.borrow_uri(); 328 - this 329 - } 330 - } 257 + // --------------------------------------------------------------------------- 258 + // Borrowed construction 259 + // --------------------------------------------------------------------------- 331 260 332 - /// Get the authority component (DID or handle) 333 - pub fn authority(&self) -> &AtIdentifier<CowStr<'_>> { 334 - self.inner.borrow_authority() 335 - } 261 + // --------------------------------------------------------------------------- 262 + // Generic unchecked construction 263 + // --------------------------------------------------------------------------- 336 264 337 - /// Get the path component (collection and optional rkey) 338 - pub fn path(&self) -> &Option<RepoPath<'_>> { 339 - self.inner.borrow_path() 265 + impl<S: Bos<str> + AsRef<str>> AtUri<S> { 266 + /// Unchecked constructor from a pre-validated URI string. 267 + /// 268 + /// Extracts indices but does not validate components. Use when the URI 269 + /// has already been validated externally (e.g., by `AtprotoStr::new()`). 270 + /// 271 + /// # Safety 272 + /// 273 + /// Callers must ensure the URI is a valid AT URI. Accessor methods will 274 + /// produce typed wrappers via `unchecked` constructors. 275 + pub unsafe fn unchecked(uri: S) -> Self { 276 + let indices = extract_indices(uri.as_ref()); 277 + AtUri { uri, indices } 340 278 } 341 279 342 - /// Get the fragment component if present 343 - pub fn fragment(&self) -> &Option<CowStr<'_>> { 344 - self.inner.borrow_fragment() 280 + /// Construct from a pre-validated URI string and pre-computed indices. 281 + /// 282 + /// # Safety 283 + /// 284 + /// Callers must ensure the URI is valid and the indices are correct. 285 + pub(crate) unsafe fn from_parts(uri: S, indices: AtUriIndices) -> Self { 286 + AtUri { uri, indices } 345 287 } 288 + } 346 289 347 - /// Get the collection NSID from the path, if present 348 - pub fn collection(&self) -> Option<&Nsid<CowStr<'_>>> { 349 - self.inner.borrow_path().as_ref().map(|p| &p.collection) 350 - } 290 + // --------------------------------------------------------------------------- 291 + // Generic construction 292 + // --------------------------------------------------------------------------- 351 293 352 - /// Get the record key from the path, if present 353 - pub fn rkey(&self) -> Option<&RecordKey<Rkey<CowStr<'_>>>> { 354 - self.inner 355 - .borrow_path() 356 - .as_ref() 357 - .and_then(|p| p.rkey.as_ref()) 294 + impl<S: Bos<str> + AsRef<str>> AtUri<S> { 295 + /// Fallible constructor, validates, wraps the input directly. 296 + pub fn new(uri: S) -> Result<Self, AtStrError> { 297 + let indices = validate_and_index(uri.as_ref())?; 298 + Ok(AtUri { uri, indices }) 358 299 } 359 300 360 - /// Fallible constructor, validates, borrows from input if possible 361 - pub fn new_cow(uri: CowStr<'u>) -> Result<Self, AtStrError> { 362 - Self::try_from(uri) 301 + /// Infallible constructor. Panics on invalid URIs. 302 + pub fn raw(uri: S) -> Self { 303 + Self::new(uri).expect("valid AT URI") 363 304 } 364 305 } 365 306 366 - impl AtUri<'static> { 367 - /// Fallible owned constructor from typical parts 307 + // --------------------------------------------------------------------------- 308 + // Owned construction 309 + // --------------------------------------------------------------------------- 310 + 311 + impl<S: Bos<str> + AsRef<str> + FromStr> AtUri<S> { 312 + /// Fallible owned constructor. 313 + pub fn new_owned(uri: impl AsRef<str>) -> Result<Self, AtStrError> { 314 + let uri_str = uri.as_ref(); 315 + let indices = validate_and_index(uri_str)?; 316 + let s = S::from_str(uri_str).map_err(|_| { 317 + AtStrError::new( 318 + "at-uri-scheme", 319 + uri_str.to_string(), 320 + StrParseKind::Conversion, 321 + ) 322 + })?; 323 + Ok(AtUri { uri: s, indices }) 324 + } 325 + 326 + /// Fallible constructor from typical parts. 368 327 pub fn from_parts_owned( 369 328 authority: impl AsRef<str>, 370 329 collection: impl AsRef<str>, ··· 378 337 &format!("at://{}/{}/{}", authority, collection, rkey), 379 338 "correct uri path", 380 339 )) 381 - } else if !authority.is_empty() && collection.is_empty() && rkey.is_empty() { 340 + } else if collection.is_empty() && rkey.is_empty() { 382 341 let uri = format!("at://{}", authority); 383 342 Self::new_owned(uri) 384 - } else if !collection.is_empty() && rkey.is_empty() { 343 + } else if rkey.is_empty() { 385 344 let uri = format!("at://{}/{}", authority, collection); 386 345 Self::new_owned(uri) 387 346 } else { ··· 389 348 Self::new_owned(uri) 390 349 } 391 350 } 392 - /// Owned constructor 351 + 352 + /// Fallible constructor for static strings. 353 + pub fn new_static(uri: &'static str) -> Result<Self, AtStrError> { 354 + let indices = validate_and_index(uri)?; 355 + let s = S::from_str(uri).map_err(|_| { 356 + AtStrError::new("at-uri-scheme", uri.to_string(), StrParseKind::Conversion) 357 + })?; 358 + Ok(AtUri { uri: s, indices }) 359 + } 360 + } 361 + 362 + // --------------------------------------------------------------------------- 363 + // Accessors 364 + // --------------------------------------------------------------------------- 365 + 366 + impl<S: Bos<str> + AsRef<str>> AtUri<S> { 367 + /// Get the full URI as a string slice. 368 + pub fn as_str(&self) -> &str { 369 + self.uri.as_ref() 370 + } 371 + 372 + /// Get the authority component (DID or handle). 393 373 /// 394 - /// Uses ouroboros self-referential tricks internally to make sure everything 395 - /// borrows efficiently from the uri `CowStr<'static>`. 396 - /// 397 - /// Performs validation up-front, but is slower than the borrowing constructor 398 - /// due to currently having to re-run the main regex, in addition to allocating. 399 - /// 400 - /// `.into_static()` and Clone implementations have similar limitations. 401 - /// 402 - /// O(1) clone mathod is AtUri::fast_clone(). 403 - /// 404 - /// Future optimization involves working out the indices borrowed and either using those 405 - /// to avoid re-computing in some places, or, for a likely fully optimal version, only storing 406 - /// the indices and constructing the borrowed components unsafely when asked. 407 - pub fn new_owned(uri: impl AsRef<str>) -> Result<Self, AtStrError> { 408 - if let Some(parts) = ATURI_REGEX.captures(uri.as_ref()) { 409 - if let Some(authority) = parts.name("authority") { 410 - let _authority = AtIdentifier::new(authority.as_str()) 411 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e))?; 412 - let path = if let Some(collection) = parts.name("collection") { 413 - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())).map_err(|e| { 414 - AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e) 415 - })?; 416 - let rkey = if let Some(rkey) = parts.name("rkey") { 417 - let rkey = RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { 418 - AtStrError::wrap("at-uri-scheme", uri.as_ref().to_string(), e) 419 - })?); 420 - Some(rkey) 421 - } else { 422 - None 423 - }; 424 - Some(RepoPath { collection, rkey }) 425 - } else { 426 - None 427 - }; 374 + /// Uses `BorrowOrShare` split lifetimes: when `S = &'d str`, the returned 375 + /// `AtIdentifier<&'d str>` can outlive the borrow of `self`. 376 + pub fn authority<'i, 'o>(&'i self) -> AtIdentifier<&'o str> 377 + where 378 + S: BorrowOrShare<'i, 'o, str>, 379 + { 380 + let s: &'o str = self.uri.borrow_or_share(); 381 + let end = self.indices.authority_end(s.len()); 382 + // Safety: constructor validated the authority. `unchecked` classifies DID vs handle 383 + // but won't reject valid input. 384 + unsafe { AtIdentifier::unchecked(&s[5..end]) } 385 + } 428 386 429 - Ok(AtUri { 430 - inner: Inner::new( 431 - CowStr::Owned(uri.as_ref().to_smolstr()), 432 - |uri| { 433 - let parts = ATURI_REGEX.captures(uri).unwrap(); 434 - unsafe { 435 - AtIdentifier::unchecked_cow( 436 - parts.name("authority").unwrap().as_str().to_cowstr(), 437 - ) 438 - } 439 - }, 440 - |uri| { 441 - if path.is_some() { 442 - let parts = ATURI_REGEX.captures(uri).unwrap(); 443 - if let Some(collection) = parts.name("collection") { 444 - let collection = unsafe { 445 - Nsid::unchecked(CowStr::Borrowed(collection.as_str())) 446 - }; 447 - let rkey = if let Some(rkey) = parts.name("rkey") { 448 - let rkey = unsafe { 449 - RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) 450 - }; 451 - Some(rkey) 452 - } else { 453 - None 454 - }; 455 - Some(RepoPath { collection, rkey }) 456 - } else { 457 - None 458 - } 459 - } else { 460 - None 461 - } 462 - }, 463 - |uri| { 464 - let parts = ATURI_REGEX.captures(uri).unwrap(); 465 - parts.name("fragment").map(|fragment| { 466 - let fragment = CowStr::Borrowed(fragment.as_str()); 467 - fragment 468 - }) 469 - }, 470 - ), 471 - }) 472 - } else { 473 - Err(AtStrError::missing( 474 - "at-uri-scheme", 475 - &uri.as_ref(), 476 - "authority", 477 - )) 478 - } 479 - } else { 480 - Err(AtStrError::regex( 481 - "at-uri-scheme", 482 - &uri.as_ref(), 483 - SmolStr::new_static("doesn't match schema"), 484 - )) 485 - } 387 + /// Get the collection NSID from the path, if present. 388 + pub fn collection<'i, 'o>(&'i self) -> Option<Nsid<&'o str>> 389 + where 390 + S: BorrowOrShare<'i, 'o, str>, 391 + { 392 + let idx = self.indices.first_slash?.get() as usize; 393 + let s: &'o str = self.uri.borrow_or_share(); 394 + let end = self.indices.collection_end(s.len()); 395 + Some(unsafe { Nsid::unchecked(&s[idx + 1..end]) }) 486 396 } 487 397 488 - /// Fallible constructor, validates, doesn't allocate (static lifetime) 489 - pub fn new_static(uri: &'static str) -> Result<Self, AtStrError> { 490 - let uri = uri.as_ref(); 491 - if let Some(parts) = ATURI_REGEX.captures(uri) { 492 - if let Some(authority) = parts.name("authority") { 493 - let authority = AtIdentifier::new_static(authority.as_str()) 494 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 495 - let path = if let Some(collection) = parts.name("collection") { 496 - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 497 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 498 - let rkey = if let Some(rkey) = parts.name("rkey") { 499 - let rkey = 500 - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { 501 - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) 502 - })?); 503 - Some(rkey) 504 - } else { 505 - None 506 - }; 507 - Some(RepoPath { collection, rkey }) 508 - } else { 509 - None 510 - }; 511 - let fragment = parts.name("fragment").map(|fragment| { 512 - let fragment = CowStr::new_static(fragment.as_str()); 513 - fragment 514 - }); 515 - Ok(AtUri { 516 - inner: InnerBuilder { 517 - uri: CowStr::new_static(uri), 518 - authority_builder: |_| authority, 519 - path_builder: |_| path, 520 - fragment_builder: |_| fragment, 521 - } 522 - .build(), 523 - }) 524 - } else { 525 - Err(AtStrError::missing("at-uri-scheme", uri, "authority")) 526 - } 527 - } else { 528 - Err(AtStrError::regex( 529 - "at-uri-scheme", 530 - uri, 531 - SmolStr::new_static("doesn't match schema"), 532 - )) 533 - } 398 + /// Get the record key from the path, if present. 399 + pub fn rkey<'i, 'o>(&'i self) -> Option<Rkey<&'o str>> 400 + where 401 + S: BorrowOrShare<'i, 'o, str>, 402 + { 403 + let idx = self.indices.second_slash?.get() as usize; 404 + let s: &'o str = self.uri.borrow_or_share(); 405 + let end = self.indices.rkey_end(s.len()); 406 + Some(unsafe { Rkey::unchecked(&s[idx + 1..end]) }) 534 407 } 535 - } 536 408 537 - impl FromStr for AtUri<'_> { 538 - type Err = AtStrError; 409 + /// Get the path component (collection and optional rkey). 410 + pub fn path<'i, 'o>(&'i self) -> Option<RepoPath<&'o str>> 411 + where 412 + S: BorrowOrShare<'i, 'o, str>, 413 + { 414 + let slash = self.indices.first_slash?.get() as usize; 415 + let s: &'o str = self.uri.borrow_or_share(); 416 + let col_end = self.indices.collection_end(s.len()); 417 + let collection = unsafe { Nsid::unchecked(&s[slash + 1..col_end]) }; 418 + let rkey = self.indices.second_slash.map(|idx| { 419 + let rkey_end = self.indices.rkey_end(s.len()); 420 + RecordKey(unsafe { Rkey::unchecked(&s[idx.get() as usize + 1..rkey_end]) }) 421 + }); 422 + Some(RepoPath { collection, rkey }) 423 + } 539 424 540 - /// Has to take ownership due to the lifetime constraints of the FromStr trait. 541 - /// Prefer `AtUri::new()` or `AtUri::raw()` if you want to borrow. 542 - fn from_str(uri: &str) -> Result<Self, Self::Err> { 543 - if let Some(parts) = ATURI_REGEX.captures(uri.as_ref()) { 544 - if let Some(authority) = parts.name("authority") { 545 - let _authority = AtIdentifier::new(authority.as_str()) 546 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 547 - let path = if let Some(collection) = parts.name("collection") { 548 - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 549 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 550 - let rkey = if let Some(rkey) = parts.name("rkey") { 551 - let rkey = 552 - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { 553 - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) 554 - })?); 555 - Some(rkey) 556 - } else { 557 - None 558 - }; 559 - Some(RepoPath { collection, rkey }) 560 - } else { 561 - None 562 - }; 563 - 564 - Ok(AtUri { 565 - inner: Inner::new( 566 - CowStr::Owned(uri.to_smolstr()), 567 - |uri| { 568 - let parts = ATURI_REGEX.captures(uri).unwrap(); 569 - unsafe { 570 - AtIdentifier::unchecked_cow( 571 - parts.name("authority").unwrap().as_str().to_cowstr(), 572 - ) 573 - } 574 - }, 575 - |uri| { 576 - if path.is_some() { 577 - let parts = ATURI_REGEX.captures(uri).unwrap(); 578 - if let Some(collection) = parts.name("collection") { 579 - let collection = unsafe { 580 - Nsid::unchecked(CowStr::Borrowed(collection.as_str())) 581 - }; 582 - let rkey = if let Some(rkey) = parts.name("rkey") { 583 - let rkey = unsafe { 584 - RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) 585 - }; 586 - Some(rkey) 587 - } else { 588 - None 589 - }; 590 - Some(RepoPath { collection, rkey }) 591 - } else { 592 - None 593 - } 594 - } else { 595 - None 596 - } 597 - }, 598 - |uri| { 599 - let parts = ATURI_REGEX.captures(uri).unwrap(); 600 - parts.name("fragment").map(|fragment| { 601 - let fragment = CowStr::Borrowed(fragment.as_str()); 602 - fragment 603 - }) 604 - }, 605 - ), 606 - }) 607 - } else { 608 - Err(AtStrError::missing( 609 - "at-uri-scheme", 610 - &uri.as_ref(), 611 - "authority", 612 - )) 613 - } 614 - } else { 615 - Err(AtStrError::regex( 616 - "at-uri-scheme", 617 - &uri.as_ref(), 618 - SmolStr::new_static("doesn't match schema"), 619 - )) 620 - } 425 + /// Get the fragment component if present. 426 + pub fn fragment<'i, 'o>(&'i self) -> Option<&'o str> 427 + where 428 + S: BorrowOrShare<'i, 'o, str>, 429 + { 430 + let idx = self.indices.hash?.get() as usize; 431 + let s: &'o str = self.uri.borrow_or_share(); 432 + Some(&s[idx + 1..]) 621 433 } 622 434 } 623 435 624 - impl IntoStatic for AtUri<'_> { 625 - type Output = AtUri<'static>; 436 + // --------------------------------------------------------------------------- 437 + // IntoStatic 438 + // --------------------------------------------------------------------------- 439 + 440 + impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for AtUri<S> 441 + where 442 + S::Output: Bos<str> + AsRef<str>, 443 + { 444 + type Output = AtUri<S::Output>; 626 445 627 - fn into_static(self) -> AtUri<'static> { 446 + fn into_static(self) -> AtUri<S::Output> { 628 447 AtUri { 629 - inner: Inner::new( 630 - self.inner.borrow_uri().clone().into_static(), 631 - |uri| { 632 - let parts = ATURI_REGEX.captures(uri).unwrap(); 633 - unsafe { 634 - AtIdentifier::unchecked_cow( 635 - parts.name("authority").unwrap().as_str().to_cowstr(), 636 - ) 637 - } 638 - }, 639 - |uri| { 640 - if self.inner.borrow_path().is_some() { 641 - let parts = ATURI_REGEX.captures(uri).unwrap(); 642 - if let Some(collection) = parts.name("collection") { 643 - let collection = 644 - unsafe { Nsid::unchecked(CowStr::Borrowed(collection.as_str())) }; 645 - let rkey = if let Some(rkey) = parts.name("rkey") { 646 - let rkey = 647 - unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; 648 - Some(rkey) 649 - } else { 650 - None 651 - }; 652 - Some(RepoPath { collection, rkey }) 653 - } else { 654 - None 655 - } 656 - } else { 657 - None 658 - } 659 - }, 660 - |uri| { 661 - if self.inner.borrow_fragment().is_some() { 662 - let parts = ATURI_REGEX.captures(uri).unwrap(); 663 - parts.name("fragment").map(|fragment| { 664 - let fragment = CowStr::Borrowed(fragment.as_str()); 665 - fragment 666 - }) 667 - } else { 668 - None 669 - } 670 - }, 671 - ), 448 + uri: self.uri.into_static(), 449 + indices: self.indices, 672 450 } 673 451 } 674 452 } 675 453 676 - impl<'de, 'a> Deserialize<'de> for AtUri<'a> 454 + // --------------------------------------------------------------------------- 455 + // Serde 456 + // --------------------------------------------------------------------------- 457 + 458 + impl<'de, S> Deserialize<'de> for AtUri<S> 677 459 where 678 - 'de: 'a, 460 + S: Bos<str> + AsRef<str> + Deserialize<'de>, 679 461 { 680 462 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 681 463 where 682 464 D: Deserializer<'de>, 683 465 { 684 - let value = Deserialize::deserialize(deserializer)?; 685 - Self::new_cow(value).map_err(D::Error::custom) 466 + let s = S::deserialize(deserializer)?; 467 + let indices = validate_and_index(s.as_ref()).map_err(D::Error::custom)?; 468 + Ok(AtUri { uri: s, indices }) 686 469 } 687 470 } 688 471 689 - impl Serialize for AtUri<'_> { 690 - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 472 + impl<S: Bos<str> + AsRef<str>> Serialize for AtUri<S> { 473 + fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> 691 474 where 692 - S: Serializer, 475 + Ser: Serializer, 693 476 { 694 - serializer.serialize_str(&self.inner.borrow_uri()) 477 + serializer.serialize_str(self.uri.as_ref()) 695 478 } 696 479 } 697 480 698 - impl fmt::Display for AtUri<'_> { 699 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 700 - f.write_str(&self.inner.borrow_uri()) 481 + // --------------------------------------------------------------------------- 482 + // FromStr 483 + // --------------------------------------------------------------------------- 484 + 485 + impl FromStr for AtUri<SmolStr> { 486 + type Err = AtStrError; 487 + 488 + fn from_str(uri: &str) -> Result<Self, Self::Err> { 489 + Self::new_owned(uri) 701 490 } 702 491 } 703 492 704 - impl<'d> From<AtUri<'d>> for String { 705 - fn from(value: AtUri<'d>) -> Self { 706 - value.inner.borrow_uri().to_string() 493 + // --------------------------------------------------------------------------- 494 + // Display, conversions 495 + // --------------------------------------------------------------------------- 496 + 497 + impl<S: Bos<str> + AsRef<str>> fmt::Display for AtUri<S> { 498 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 499 + f.write_str(self.uri.as_ref()) 707 500 } 708 501 } 709 502 710 - impl<'d> From<AtUri<'d>> for CowStr<'d> { 711 - fn from(value: AtUri<'d>) -> Self { 712 - value.inner.borrow_uri().clone() 503 + impl<S: Bos<str> + AsRef<str>> From<AtUri<S>> for String { 504 + fn from(value: AtUri<S>) -> Self { 505 + value.uri.as_ref().to_string() 713 506 } 714 507 } 715 508 716 - impl TryFrom<String> for AtUri<'static> { 509 + impl TryFrom<String> for AtUri<SmolStr> { 717 510 type Error = AtStrError; 718 511 719 512 fn try_from(value: String) -> Result<Self, Self::Error> { ··· 721 514 } 722 515 } 723 516 724 - impl<'d> TryFrom<CowStr<'d>> for AtUri<'d> { 517 + impl<'d> TryFrom<CowStr<'d>> for AtUri<CowStr<'d>> { 725 518 type Error = AtStrError; 726 - fn try_from(uri: CowStr<'d>) -> Result<Self, Self::Error> { 727 - if let Some(parts) = ATURI_REGEX.captures(uri.as_ref()) { 728 - if let Some(authority) = parts.name("authority") { 729 - let _authority = AtIdentifier::new(authority.as_str()) 730 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 731 - let _path = if let Some(collection) = parts.name("collection") { 732 - let collection = Nsid::new_cow(CowStr::Borrowed(collection.as_str())) 733 - .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; 734 - let rkey = if let Some(rkey) = parts.name("rkey") { 735 - let rkey = 736 - RecordKey(Rkey::new_cow(CowStr::Borrowed(rkey.as_str())).map_err(|e| { 737 - AtStrError::wrap("at-uri-scheme", uri.to_string(), e) 738 - })?); 739 - Some(rkey) 740 - } else { 741 - None 742 - }; 743 - Some(RepoPath { collection, rkey }) 744 - } else { 745 - None 746 - }; 747 - drop(parts); 748 519 749 - Ok(AtUri { 750 - inner: Inner::new( 751 - uri, 752 - |uri| { 753 - let parts = ATURI_REGEX.captures(uri).unwrap(); 754 - unsafe { 755 - AtIdentifier::unchecked_cow( 756 - parts.name("authority").unwrap().as_str().to_cowstr(), 757 - ) 758 - } 759 - }, 760 - |uri| { 761 - let parts = ATURI_REGEX.captures(uri).unwrap(); 762 - if let Some(collection) = parts.name("collection") { 763 - let collection = unsafe { 764 - Nsid::unchecked(CowStr::Borrowed(collection.as_str())) 765 - }; 766 - let rkey = if let Some(rkey) = parts.name("rkey") { 767 - let rkey = 768 - unsafe { RecordKey(Rkey::unchecked_cow(CowStr::Borrowed(rkey.as_str()))) }; 769 - Some(rkey) 770 - } else { 771 - None 772 - }; 773 - Some(RepoPath { collection, rkey }) 774 - } else { 775 - None 776 - } 777 - }, 778 - |uri| { 779 - let parts = ATURI_REGEX.captures(uri).unwrap(); 780 - parts.name("fragment").map(|fragment| { 781 - let fragment = CowStr::Borrowed(fragment.as_str()); 782 - fragment 783 - }) 784 - }, 785 - ), 786 - }) 787 - } else { 788 - Err(AtStrError::missing( 789 - "at-uri-scheme", 790 - &uri.as_ref(), 791 - "authority", 792 - )) 793 - } 794 - } else { 795 - Err(AtStrError::regex( 796 - "at-uri-scheme", 797 - &uri.as_ref(), 798 - SmolStr::new_static("doesn't match schema"), 799 - )) 800 - } 520 + fn try_from(uri: CowStr<'d>) -> Result<Self, Self::Error> { 521 + Self::new(uri) 801 522 } 802 523 } 803 524 804 - impl AsRef<str> for AtUri<'_> { 525 + impl<S: Bos<str> + AsRef<str>> AsRef<str> for AtUri<S> { 805 526 fn as_ref(&self) -> &str { 806 - &self.inner.borrow_uri().as_ref() 527 + self.uri.as_ref() 807 528 } 808 529 } 809 530 810 - impl Deref for AtUri<'_> { 531 + impl<S: Bos<str> + AsRef<str>> Deref for AtUri<S> { 811 532 type Target = str; 812 533 813 534 fn deref(&self) -> &Self::Target { 814 - self.inner.borrow_uri().as_ref() 535 + self.uri.as_ref() 815 536 } 816 537 } 538 + 539 + // --------------------------------------------------------------------------- 540 + // Tests 541 + // --------------------------------------------------------------------------- 817 542 818 543 #[cfg(test)] 819 544 mod tests { ··· 854 579 #[test] 855 580 fn with_fragment() { 856 581 let uri = AtUri::new("at://alice.test/com.example.foo/123#/path").unwrap(); 857 - assert_eq!(uri.fragment().as_ref().unwrap().as_ref(), "/path"); 582 + assert_eq!(uri.fragment().unwrap(), "/path"); 858 583 859 - // Fragment must start with / 584 + // Fragment must start with /. 860 585 assert!(AtUri::new("at://alice.test#path").is_err()); 861 586 assert!(AtUri::new("at://alice.test#/foo/bar").is_ok()); 862 587 } ··· 881 606 882 607 #[test] 883 608 fn max_length() { 884 - // Spec says 8KB max 609 + // Spec says 8KB max. 885 610 let long_did = format!("did:plc:{}", "a".repeat(8000)); 886 611 let uri = format!("at://{}", long_did); 887 612 assert!(uri.len() < 8192); 888 - // Should work if components are valid 613 + // Should work if components are valid. 889 614 // (our DID will fail at 2048 chars, but this tests the URI doesn't impose extra limits) 615 + } 616 + 617 + #[test] 618 + fn clone_preserves_indices() { 619 + let uri = AtUri::new("at://alice.test/com.example.foo/123").unwrap(); 620 + let owned: AtUri<SmolStr> = 621 + AtUri::new_owned("at://alice.test/com.example.foo/123").unwrap(); 622 + let cloned = owned.clone(); 623 + assert_eq!(owned.as_str(), cloned.as_str()); 624 + assert_eq!(cloned.authority().as_str(), "alice.test"); 625 + assert_eq!(cloned.collection().unwrap().as_str(), "com.example.foo"); 626 + assert_eq!(cloned.rkey().unwrap().as_ref(), "123"); 627 + 628 + // Borrowed clone. 629 + let cloned_borrowed = uri.clone(); 630 + assert_eq!(cloned_borrowed.authority().as_str(), "alice.test"); 631 + } 632 + 633 + #[test] 634 + fn into_static_preserves_components() { 635 + let uri = AtUri::new("at://did:plc:foo/com.example.post/abc").unwrap(); 636 + let owned: AtUri<SmolStr> = uri.into_static(); 637 + assert_eq!(owned.authority().as_str(), "did:plc:foo"); 638 + assert_eq!(owned.collection().unwrap().as_str(), "com.example.post"); 639 + assert_eq!(owned.rkey().unwrap().as_ref(), "abc"); 640 + } 641 + 642 + #[test] 643 + fn path_accessor() { 644 + let uri = AtUri::new("at://alice.test/com.example.foo/123").unwrap(); 645 + let path = uri.path().unwrap(); 646 + assert_eq!(path.collection.as_str(), "com.example.foo"); 647 + assert_eq!(path.rkey.unwrap().as_ref(), "123"); 648 + 649 + let uri2 = AtUri::new("at://alice.test/com.example.foo").unwrap(); 650 + let path2 = uri2.path().unwrap(); 651 + assert_eq!(path2.collection.as_str(), "com.example.foo"); 652 + assert!(path2.rkey.is_none()); 653 + 654 + let uri3 = AtUri::new("at://alice.test").unwrap(); 655 + assert!(uri3.path().is_none()); 656 + } 657 + 658 + #[test] 659 + fn serde_roundtrip() { 660 + let original = "at://did:plc:foo/com.example.post/123"; 661 + let owned: AtUri<SmolStr> = AtUri::new_owned(original).unwrap(); 662 + let json = serde_json::to_string(&owned).unwrap(); 663 + assert_eq!(json, format!("\"{}\"", original)); 664 + let deserialized: AtUri<SmolStr> = serde_json::from_str(&json).unwrap(); 665 + assert_eq!(owned, deserialized); 666 + } 667 + 668 + #[test] 669 + fn fragment_only_uri() { 670 + let uri = AtUri::new("at://alice.test#/foo/bar").unwrap(); 671 + assert_eq!(uri.authority().as_str(), "alice.test"); 672 + assert!(uri.collection().is_none()); 673 + assert!(uri.rkey().is_none()); 674 + assert_eq!(uri.fragment().unwrap(), "/foo/bar"); 675 + } 676 + 677 + #[test] 678 + fn bos_lifetime_semantics() { 679 + // Verify that AtUri<&str> accessors can outlive the borrow. 680 + let s = String::from("at://alice.test/com.example.foo/123"); 681 + let uri = AtUri::new(s).unwrap(); 682 + let authority = uri.authority(); 683 + // authority borrows from s, not from uri — this is the BOS magic. 684 + assert_eq!(authority.as_str(), "alice.test"); 890 685 } 891 686 }
+9 -7
crates/jacquard-common/src/types/collection.rs
··· 1 1 use alloc::string::String; 2 2 use core::fmt; 3 + use core::str::FromStr; 3 4 4 5 use serde::{Deserialize, Serialize}; 5 6 ··· 10 11 recordkey::{RecordKey, RecordKeyType, Rkey}, 11 12 }; 12 13 use crate::xrpc::XrpcResp; 13 - use crate::{CowStr, IntoStatic}; 14 + use crate::{BorrowOrShare, Bos, CowStr, IntoStatic}; 14 15 15 16 /// Trait for a collection of records that can be stored in a repository. 16 17 /// ··· 35 36 /// Panics if [`Self::NSID`] is not a valid NSID. 36 37 /// 37 38 /// [`Nsid`]: crate::types::string::Nsid 38 - fn nsid() -> crate::types::nsid::Nsid<CowStr<'static>> { 39 - Nsid::new_static(Self::NSID).expect("should be valid NSID") 39 + fn nsid() -> crate::types::nsid::Nsid<&'static str> { 40 + unsafe { Nsid::unchecked(Self::NSID) } 40 41 } 41 42 42 43 /// Returns the repo path for a record in this collection with the given record key. ··· 48 49 /// 49 50 /// [Repo Data Structure v3]: https://atproto.com/specs/repository#repo-data-structure-v3 50 51 /// [`Nsid`]: crate::types::string::Nsid 51 - fn repo_path<'u, T: RecordKeyType>( 52 - rkey: &'u crate::types::recordkey::RecordKey<T>, 53 - ) -> RepoPath<'u> { 52 + fn repo_path<T>(rkey: &crate::types::recordkey::RecordKey<T>) -> RepoPath<&str> 53 + where 54 + T: RecordKeyType + Bos<str> + AsRef<str>, 55 + { 54 56 RepoPath { 55 57 collection: Self::nsid(), 56 58 // Borrow the record key string with the caller's lifetime via CowStr. 57 59 rkey: Some( 58 - RecordKey::any_cow(CowStr::Borrowed(rkey.as_ref())) 60 + RecordKey::any(rkey.0.borrow_or_share()) 59 61 .expect("RecordKey implements RecordKeyType, which guarantees a valid rkey"), 60 62 ), 61 63 }
+27 -39
crates/jacquard-common/src/types/did.rs
··· 1 1 use crate::bos::{Bos, DefaultStr}; 2 - use crate::types::string::AtStrError; 2 + use crate::types::string::{AtStrError, StrParseKind}; 3 3 use crate::{CowStr, IntoStatic}; 4 4 use alloc::string::{String, ToString}; 5 5 use core::fmt; ··· 80 80 } 81 81 82 82 // --------------------------------------------------------------------------- 83 - // Borrowed construction 83 + // Generic construction 84 84 // --------------------------------------------------------------------------- 85 85 86 - impl<'d> Did<&'d str> { 87 - /// Fallible constructor, validates, borrows from input. 88 - /// Accepts (and strips) preceding 'at://' if present. 89 - pub fn new(did: &'d str) -> Result<Self, AtStrError> { 90 - let stripped = strip_did_prefix(did); 91 - validate_did(stripped)?; 92 - Ok(Self(stripped)) 86 + impl<S: Bos<str> + AsRef<str>> Did<S> { 87 + /// Fallible constructor, validates, wraps the input directly. 88 + /// 89 + /// Does NOT strip `at://` prefix — use `new_owned()` for that. 90 + pub fn new(s: S) -> Result<Self, AtStrError> { 91 + validate_did(s.as_ref())?; 92 + Ok(Did(s)) 93 93 } 94 94 95 95 /// Infallible constructor. Panics on invalid DIDs. 96 - pub fn raw(did: &'d str) -> Self { 97 - Self::new(did).expect("invalid DID") 96 + pub fn raw(s: S) -> Self { 97 + Self::new(s).expect("invalid DID") 98 98 } 99 99 } 100 100 101 101 // --------------------------------------------------------------------------- 102 - // Owned construction 102 + // Owned construction (with prefix stripping) 103 103 // --------------------------------------------------------------------------- 104 104 105 - impl<S: Bos<str> + From<SmolStr>> Did<S> { 105 + impl<S: Bos<str> + FromStr> Did<S> { 106 106 /// Fallible constructor, validates, takes ownership. 107 + /// 108 + /// Accepts (and strips) preceding `at://` if present. 107 109 pub fn new_owned(did: impl AsRef<str>) -> Result<Self, AtStrError> { 108 110 let did = did.as_ref(); 109 111 let stripped = strip_did_prefix(did); 110 112 validate_did(stripped)?; 111 - Ok(Self(S::from(stripped.to_smolstr()))) 113 + // FromStr for backing types (SmolStr, String, CowStr) is infallible. 114 + let s = S::from_str(stripped) 115 + .map_err(|_| AtStrError::new("did", stripped.to_string(), StrParseKind::Conversion))?; 116 + Ok(Self(s)) 112 117 } 113 118 114 - /// Fallible constructor for static strings. Zero-alloc if possible. 119 + /// Fallible constructor for static strings. 115 120 pub fn new_static(did: &'static str) -> Result<Self, AtStrError> { 116 121 let stripped = strip_did_prefix(did); 117 122 validate_did(stripped)?; 118 - Ok(Self(S::from(SmolStr::new_static(stripped)))) 119 - } 120 - } 121 - 122 - // --------------------------------------------------------------------------- 123 - // CowStr construction 124 - // --------------------------------------------------------------------------- 125 - 126 - impl<'d> Did<CowStr<'d>> { 127 - /// Fallible constructor, borrows if possible. 128 - pub fn new_cow(did: CowStr<'d>) -> Result<Self, AtStrError> { 129 - let did = if let Some(stripped) = did.strip_prefix("at://") { 130 - CowStr::copy_from_str(stripped) 131 - } else { 132 - did 133 - }; 134 - validate_did(&did)?; 135 - Ok(Self(did)) 136 - } 137 - 138 - pub unsafe fn unchecked_cow(did: CowStr<'d>) -> Self { 139 - Self(did) 123 + let s = S::from_str(stripped) 124 + .map_err(|_| AtStrError::new("did", stripped.to_string(), StrParseKind::Conversion))?; 125 + Ok(Self(s)) 140 126 } 141 127 } 142 128 ··· 229 215 230 216 impl<'d> From<CowStr<'d>> for Did<CowStr<'d>> { 231 217 fn from(value: CowStr<'d>) -> Self { 232 - Self::new_cow(value).unwrap() 218 + Self::new(value).unwrap() 233 219 } 234 220 } 235 221 ··· 269 255 270 256 #[test] 271 257 fn prefix_stripping() { 258 + // new() does not strip — use new_owned() for that. 259 + assert!(Did::<&str>::new("at://did:plc:foo").is_err()); 272 260 assert_eq!( 273 - Did::<&str>::new("at://did:plc:foo").unwrap().as_str(), 261 + Did::<SmolStr>::new_owned("at://did:plc:foo").unwrap().as_str(), 274 262 "did:plc:foo" 275 263 ); 276 264 assert_eq!(
+5 -3
crates/jacquard-common/src/types/did_doc.rs
··· 108 108 S: Bos<str> + AsRef<str> + Clone, 109 109 { 110 110 /// Extract validated handles from `alsoKnownAs` entries like `at://\<handle\>`. 111 - pub fn handles(&self) -> Vec<Handle> { 111 + pub fn handles(&self) -> Vec<Handle<&str>> { 112 112 self.also_known_as 113 113 .as_ref() 114 114 .map(|v| { 115 115 v.iter() 116 - .filter_map(|h| Handle::new(h.as_ref()).ok()) 117 - .map(|h| h.into_static()) 116 + .filter_map(|h| { 117 + let s = h.as_ref().strip_prefix("at://").unwrap_or(h.as_ref()); 118 + Handle::new(s).ok() 119 + }) 118 120 .collect() 119 121 }) 120 122 .unwrap_or_default()
+35 -48
crates/jacquard-common/src/types/handle.rs
··· 1 1 use crate::bos::{Bos, DefaultStr}; 2 - use crate::types::string::AtStrError; 2 + use crate::types::string::{AtStrError, StrParseKind}; 3 3 use crate::types::{DISALLOWED_TLDS, ends_with}; 4 4 use crate::{CowStr, IntoStatic}; 5 5 use alloc::string::String; 6 + use alloc::string::ToString; 6 7 use core::fmt; 7 8 use core::hash::{Hash, Hasher}; 8 9 use core::ops::Deref; ··· 100 101 // Borrowed construction: Handle<&'h str> 101 102 // --------------------------------------------------------------------------- 102 103 103 - impl<'h> Handle<&'h str> { 104 - /// Fallible constructor, validates, borrows from input. 104 + impl<S: Bos<str> + AsRef<str>> Handle<S> { 105 + /// Fallible constructor, validates, wraps the input directly. 105 106 /// 106 - /// Rejects uppercase input — use `Handle::<SmolStr>::new_owned()` for 107 - /// case-insensitive construction. 108 - /// Accepts (and strips) preceding '@' or 'at://' if present. 109 - pub fn new(handle: &'h str) -> Result<Self, AtStrError> { 110 - if handle.contains(|c: char| c.is_ascii_uppercase()) { 107 + /// Rejects uppercase input — use `new_owned()` for case-insensitive construction. 108 + /// Does NOT strip `@` or `at://` prefix — use `new_owned()` for that. 109 + pub fn new(s: S) -> Result<Self, AtStrError> { 110 + let r = s.as_ref(); 111 + if r.contains(|c: char| c.is_ascii_uppercase()) { 111 112 return Err(AtStrError::regex( 112 113 "handle", 113 - handle, 114 + r, 114 115 SmolStr::new_static("contains uppercase (use new_owned for normalisation)"), 115 116 )); 116 117 } 117 - let stripped = strip_handle_prefix(handle); 118 - validate_handle(stripped)?; 119 - Ok(Self(stripped)) 118 + validate_handle(r)?; 119 + Ok(Self(s)) 120 120 } 121 121 122 122 /// Infallible constructor. Panics on invalid handles. 123 - pub fn raw(handle: &'h str) -> Self { 124 - Self::new(handle).expect("invalid handle") 123 + pub fn raw(s: S) -> Self { 124 + Self::new(s).expect("invalid handle") 125 125 } 126 126 } 127 127 128 128 // --------------------------------------------------------------------------- 129 - // Owned construction: any S that can be built from SmolStr 129 + // Owned construction (with prefix stripping and normalisation) 130 130 // --------------------------------------------------------------------------- 131 131 132 - impl<S: Bos<str> + From<SmolStr>> Handle<S> { 132 + impl<S: Bos<str> + FromStr> Handle<S> { 133 133 /// Fallible constructor, validates, takes ownership. Normalises to lowercase. 134 + /// 135 + /// Accepts (and strips) preceding `@` or `at://` if present. 134 136 pub fn new_owned(handle: impl AsRef<str>) -> Result<Self, AtStrError> { 135 137 let handle = handle.as_ref(); 136 138 let stripped = strip_handle_prefix(handle); 137 139 let normalized = stripped.to_lowercase_smolstr(); 138 140 validate_handle(&normalized)?; 139 - Ok(Self(S::from(normalized))) 141 + let s = S::from_str(&normalized).map_err(|_| { 142 + AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion) 143 + })?; 144 + Ok(Self(s)) 140 145 } 141 146 142 - /// Fallible constructor for static strings. Zero-alloc if already lowercase. 147 + /// Fallible constructor for static strings. Normalises to lowercase. 143 148 pub fn new_static(handle: &'static str) -> Result<Self, AtStrError> { 144 149 let stripped = strip_handle_prefix(handle); 145 - let smol = if stripped.contains(|c: char| c.is_ascii_uppercase()) { 150 + let normalized = if stripped.contains(|c: char| c.is_ascii_uppercase()) { 146 151 stripped.to_lowercase_smolstr() 147 152 } else { 148 153 SmolStr::new_static(stripped) 149 154 }; 150 - validate_handle(&smol)?; 151 - Ok(Self(S::from(smol))) 152 - } 153 - } 154 - 155 - // --------------------------------------------------------------------------- 156 - // CowStr construction 157 - // --------------------------------------------------------------------------- 158 - 159 - impl<'h> Handle<CowStr<'h>> { 160 - /// Fallible constructor, borrows if possible, allocates for uppercase/prefix. 161 - pub fn new_cow(handle: CowStr<'h>) -> Result<Self, AtStrError> { 162 - if handle.contains(|c: char| c.is_ascii_uppercase()) { 163 - return Handle::<CowStr<'h>>::new_owned(handle); 164 - } 165 - let handle = if handle.starts_with("at://") || handle.starts_with('@') { 166 - CowStr::copy_from_str(strip_handle_prefix(&handle)) 167 - } else { 168 - handle 169 - }; 170 - validate_handle(&handle)?; 171 - Ok(Self(handle)) 172 - } 173 - 174 - pub unsafe fn unchecked_cow(handle: CowStr<'h>) -> Self { 175 - Self(handle) 155 + validate_handle(&normalized)?; 156 + let s = S::from_str(&normalized).map_err(|_| { 157 + AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion) 158 + })?; 159 + Ok(Self(s)) 176 160 } 177 161 } 178 162 ··· 319 303 320 304 impl<'h> From<CowStr<'h>> for Handle<CowStr<'h>> { 321 305 fn from(value: CowStr<'h>) -> Self { 322 - Self::new_cow(value).unwrap() 306 + Self::new(value).unwrap() 323 307 } 324 308 } 325 309 ··· 364 348 365 349 #[test] 366 350 fn prefix_stripping() { 351 + // new() does not strip — use new_owned() for that. 352 + assert!(Handle::<&str>::new("@alice.test").is_err()); 353 + assert!(Handle::<&str>::new("at://alice.test").is_err()); 367 354 assert_eq!( 368 - Handle::<&str>::new("@alice.test").unwrap().as_str(), 355 + Handle::<SmolStr>::new_owned("@alice.test").unwrap().as_str(), 369 356 "alice.test" 370 357 ); 371 358 assert_eq!( 372 - Handle::<&str>::new("at://alice.test").unwrap().as_str(), 359 + Handle::<SmolStr>::new_owned("at://alice.test").unwrap().as_str(), 373 360 "alice.test" 374 361 ); 375 362 assert_eq!(
+24 -39
crates/jacquard-common/src/types/ident.rs
··· 1 1 use crate::bos::{Bos, DefaultStr}; 2 2 use crate::types::handle::Handle; 3 3 use crate::types::string::AtStrError; 4 - use crate::{CowStr, IntoStatic, types::did::Did}; 4 + use crate::{ 5 + CowStr, IntoStatic, 6 + types::did::{Did, validate_did}, 7 + }; 5 8 use alloc::string::String; 6 9 use alloc::string::ToString; 7 10 use core::fmt; ··· 46 49 } 47 50 48 51 // --------------------------------------------------------------------------- 49 - // Borrowed construction 52 + // Generic construction 50 53 // --------------------------------------------------------------------------- 51 54 52 - impl<'i> AtIdentifier<&'i str> { 53 - /// Fallible constructor, validates, borrows from input. 54 - pub fn new(ident: &'i str) -> Result<Self, AtStrError> { 55 - if let Ok(did) = Did::new(ident) { 56 - Ok(AtIdentifier::Did(did)) 55 + impl<S: Bos<str> + AsRef<str>> AtIdentifier<S> { 56 + /// Fallible constructor, validates, wraps the input directly. 57 + /// 58 + /// Tries DID first, then handle. Rejects uppercase handles — use 59 + /// `new_owned()` for case-insensitive construction. 60 + pub fn new(ident: S) -> Result<Self, AtStrError> { 61 + let s = ident.as_ref(); 62 + if validate_did(s).is_ok() { 63 + drop(s); 64 + Ok(AtIdentifier::Did(unsafe { Did::unchecked(ident) })) 57 65 } else { 66 + drop(s); 58 67 Handle::new(ident).map(AtIdentifier::Handle) 59 68 } 60 69 } 61 70 62 71 /// Infallible constructor. Panics on invalid identifiers. 63 - pub fn raw(ident: &'i str) -> Self { 72 + pub fn raw(ident: S) -> Self { 64 73 Self::new(ident).expect("valid identifier") 65 74 } 66 75 ··· 69 78 /// # Safety 70 79 /// 71 80 /// Validates DIDs, treats anything else as a valid handle. 72 - pub unsafe fn unchecked(ident: &'i str) -> Self { 73 - if let Ok(did) = Did::new(ident) { 74 - AtIdentifier::Did(did) 81 + pub unsafe fn unchecked(ident: S) -> Self { 82 + if validate_did(ident.as_ref()).is_ok() { 83 + AtIdentifier::Did(unsafe { Did::unchecked(ident) }) 75 84 } else { 76 85 unsafe { AtIdentifier::Handle(Handle::unchecked(ident)) } 77 86 } ··· 82 91 // Owned construction 83 92 // --------------------------------------------------------------------------- 84 93 85 - impl<S: Bos<str> + AsRef<str> + From<SmolStr>> AtIdentifier<S> { 94 + impl<S: Bos<str> + AsRef<str> + FromStr> AtIdentifier<S> { 86 95 /// Fallible constructor, validates, takes ownership. 96 + /// Strips prefixes and normalises handle case. 87 97 pub fn new_owned(ident: impl AsRef<str>) -> Result<Self, AtStrError> { 88 98 let ident = ident.as_ref(); 89 99 if let Ok(did) = Did::new_owned(ident) { ··· 104 114 } 105 115 106 116 // --------------------------------------------------------------------------- 107 - // CowStr construction 108 - // --------------------------------------------------------------------------- 109 - 110 - impl<'i> AtIdentifier<CowStr<'i>> { 111 - /// Fallible constructor, borrows if possible. 112 - pub fn new_cow(ident: CowStr<'i>) -> Result<Self, AtStrError> { 113 - if let Ok(did) = Did::new_cow(ident.clone()) { 114 - Ok(AtIdentifier::Did(did)) 115 - } else { 116 - Handle::new_cow(ident).map(AtIdentifier::Handle) 117 - } 118 - } 119 - 120 - pub unsafe fn unchecked_cow(ident: CowStr<'i>) -> Self { 121 - unsafe { 122 - if let Ok(did) = Did::new_cow(ident.clone()) { 123 - AtIdentifier::Did(did) 124 - } else { 125 - AtIdentifier::Handle(Handle::unchecked_cow(ident)) 126 - } 127 - } 128 - } 129 - } 130 - 131 - // --------------------------------------------------------------------------- 132 117 // Trait impls 133 118 // --------------------------------------------------------------------------- 134 119 ··· 199 184 200 185 impl<'i> From<CowStr<'i>> for AtIdentifier<CowStr<'i>> { 201 186 fn from(value: CowStr<'i>) -> Self { 202 - Self::new_cow(value).expect("valid identifier") 187 + Self::new(value).expect("valid identifier") 203 188 } 204 189 } 205 190 ··· 246 231 let ident: AtIdentifier<SmolStr> = did.into(); 247 232 assert!(matches!(ident, AtIdentifier::Did(_))); 248 233 249 - let handle = Handle::new_cow("alice.test".to_cowstr()).unwrap(); 234 + let handle = Handle::new("alice.test".to_cowstr()).unwrap(); 250 235 let ident: AtIdentifier<CowStr> = handle.into(); 251 236 assert!(matches!(ident, AtIdentifier::Handle(_))); 252 237 }
+17 -21
crates/jacquard-common/src/types/nsid.rs
··· 1 1 use crate::bos::{Bos, DefaultStr}; 2 2 use crate::types::recordkey::RecordKeyType; 3 - use crate::types::string::AtStrError; 3 + use crate::types::string::{AtStrError, StrParseKind}; 4 4 use crate::{CowStr, IntoStatic}; 5 5 use alloc::string::{String, ToString}; 6 6 use core::fmt; ··· 74 74 } 75 75 } 76 76 77 - impl<'n> Nsid<&'n str> { 78 - /// Fallible constructor, validates, borrows from input. 79 - pub fn new(nsid: &'n str) -> Result<Self, AtStrError> { 80 - validate_nsid(nsid)?; 81 - Ok(Self(nsid)) 77 + impl<S: Bos<str> + AsRef<str>> Nsid<S> { 78 + /// Fallible constructor, validates, wraps the input directly. 79 + pub fn new(s: S) -> Result<Self, AtStrError> { 80 + validate_nsid(s.as_ref())?; 81 + Ok(Self(s)) 82 82 } 83 83 84 84 /// Infallible constructor. Panics on invalid NSIDs. 85 - pub fn raw(nsid: &'n str) -> Self { 86 - Self::new(nsid).expect("invalid NSID") 85 + pub fn raw(s: S) -> Self { 86 + Self::new(s).expect("invalid NSID") 87 87 } 88 88 } 89 89 90 - impl<S: Bos<str> + From<SmolStr>> Nsid<S> { 90 + impl<S: Bos<str> + FromStr> Nsid<S> { 91 91 /// Fallible constructor, validates, takes ownership. 92 92 pub fn new_owned(nsid: impl AsRef<str>) -> Result<Self, AtStrError> { 93 93 let nsid = nsid.as_ref(); 94 94 validate_nsid(nsid)?; 95 - Ok(Self(S::from(nsid.to_smolstr()))) 95 + let s = S::from_str(nsid) 96 + .map_err(|_| AtStrError::new("nsid", nsid.to_string(), StrParseKind::Conversion))?; 97 + Ok(Self(s)) 96 98 } 97 99 98 - /// Fallible constructor for static strings. Zero-alloc if possible. 100 + /// Fallible constructor for static strings. 99 101 pub fn new_static(nsid: &'static str) -> Result<Self, AtStrError> { 100 102 validate_nsid(nsid)?; 101 - Ok(Self(S::from(SmolStr::new_static(nsid)))) 102 - } 103 - } 104 - 105 - impl<'n> Nsid<CowStr<'n>> { 106 - /// Fallible constructor, borrows if possible. 107 - pub fn new_cow(nsid: CowStr<'n>) -> Result<Self, AtStrError> { 108 - validate_nsid(&nsid)?; 109 - Ok(Self(nsid)) 103 + let s = S::from_str(nsid) 104 + .map_err(|_| AtStrError::new("nsid", nsid.to_string(), StrParseKind::Conversion))?; 105 + Ok(Self(s)) 110 106 } 111 107 } 112 108 ··· 191 187 192 188 impl<'n> From<CowStr<'n>> for Nsid<CowStr<'n>> { 193 189 fn from(value: CowStr<'n>) -> Self { 194 - Self::new_cow(value).unwrap() 190 + Self::new(value).unwrap() 195 191 } 196 192 } 197 193
+52 -38
crates/jacquard-common/src/types/recordkey.rs
··· 1 1 use crate::bos::{Bos, DefaultStr}; 2 2 use crate::types::Literal; 3 - use crate::types::string::AtStrError; 3 + use crate::types::string::{AtStrError, StrParseKind}; 4 4 use crate::{CowStr, IntoStatic}; 5 5 use alloc::string::{String, ToString}; 6 6 use core::fmt; ··· 27 27 /// # Safety 28 28 /// Implementations must ensure the string representation matches [`RKEY_REGEX`] and 29 29 /// is not "." or "..". Built-in implementations: `Tid`, `Nsid`, `Literal<T>`, `Rkey<'_>`. 30 - pub unsafe trait RecordKeyType: Clone + Serialize { 31 - /// Get the record key as a string slice 30 + pub unsafe trait RecordKeyType { 31 + /// Get the record key as a string slice. 32 32 fn as_str(&self) -> &str; 33 33 } 34 34 ··· 41 41 #[repr(transparent)] 42 42 pub struct RecordKey<T: RecordKeyType>(pub T); 43 43 44 - impl<'a> RecordKey<Rkey<&'a str>> { 45 - /// Create a new `RecordKey` from a string slice. 46 - pub fn any(str: &'a str) -> Result<Self, AtStrError> { 47 - Ok(RecordKey(Rkey::new(str)?)) 48 - } 49 - } 50 - 51 - impl<S: Bos<str> + AsRef<str> + Clone + Serialize + From<SmolStr>> RecordKey<Rkey<S>> { 44 + impl<S: Bos<str> + AsRef<str> + FromStr> RecordKey<Rkey<S>> { 52 45 /// Create a new `RecordKey` from a static string slice. 53 46 pub fn any_static(str: &'static str) -> Result<Self, AtStrError> { 54 47 Ok(RecordKey(Rkey::new_static(str)?)) ··· 60 53 } 61 54 } 62 55 63 - impl<'a> RecordKey<Rkey<CowStr<'a>>> { 64 - /// Create a new `RecordKey` from a CowStr. 65 - pub fn any_cow(str: CowStr<'a>) -> Result<Self, AtStrError> { 66 - Ok(RecordKey(Rkey::new_cow(str)?)) 56 + impl<S: Bos<str> + AsRef<str>> RecordKey<Rkey<S>> { 57 + /// Create a new `RecordKey` wrapping a pre-validated Rkey. 58 + pub fn any(s: S) -> Result<Self, AtStrError> { 59 + Ok(RecordKey(Rkey::new(s)?)) 67 60 } 68 61 } 69 62 ··· 126 119 #[repr(transparent)] 127 120 pub struct Rkey<S: Bos<str> = DefaultStr>(pub(crate) S); 128 121 129 - unsafe impl<S: Bos<str> + AsRef<str> + Clone + Serialize> RecordKeyType for Rkey<S> { 122 + unsafe impl<S: Bos<str> + AsRef<str>> RecordKeyType for Rkey<S> { 130 123 fn as_str(&self) -> &str { 131 124 self.0.as_ref() 132 125 } ··· 166 159 } 167 160 } 168 161 169 - impl<'r> Rkey<&'r str> { 170 - /// Fallible constructor, validates, borrows from input. 171 - pub fn new(rkey: &'r str) -> Result<Self, AtStrError> { 172 - validate_rkey(rkey)?; 173 - Ok(Self(rkey)) 162 + impl<S: Bos<str> + AsRef<str>> Rkey<S> { 163 + /// Fallible constructor, validates, wraps the input directly. 164 + pub fn new(s: S) -> Result<Self, AtStrError> { 165 + validate_rkey(s.as_ref())?; 166 + Ok(Self(s)) 174 167 } 175 168 176 169 /// Infallible constructor. Panics on invalid rkeys. 177 - pub fn raw(rkey: &'r str) -> Self { 178 - Self::new(rkey).expect("invalid rkey") 170 + pub fn raw(s: S) -> Self { 171 + Self::new(s).expect("invalid rkey") 179 172 } 180 173 } 181 174 182 - impl<S: Bos<str> + From<SmolStr>> Rkey<S> { 175 + impl<S: Bos<str> + FromStr> Rkey<S> { 183 176 /// Fallible constructor, validates, takes ownership. 184 177 pub fn new_owned(rkey: impl AsRef<str>) -> Result<Self, AtStrError> { 185 178 let rkey = rkey.as_ref(); 186 179 validate_rkey(rkey)?; 187 - Ok(Self(S::from(rkey.to_smolstr()))) 180 + let s = S::from_str(rkey).map_err(|_| { 181 + AtStrError::new("record-key", rkey.to_string(), StrParseKind::Conversion) 182 + })?; 183 + Ok(Self(s)) 188 184 } 189 185 190 186 /// Fallible constructor for static strings. 191 187 pub fn new_static(rkey: &'static str) -> Result<Self, AtStrError> { 192 188 validate_rkey(rkey)?; 193 - Ok(Self(S::from(SmolStr::new_static(rkey)))) 189 + let s = S::from_str(rkey).map_err(|_| { 190 + AtStrError::new("record-key", rkey.to_string(), StrParseKind::Conversion) 191 + })?; 192 + Ok(Self(s)) 194 193 } 195 194 } 196 195 197 - impl<'r> Rkey<CowStr<'r>> { 198 - /// Fallible constructor, borrows if possible. 199 - pub fn new_cow(rkey: CowStr<'r>) -> Result<Self, AtStrError> { 200 - validate_rkey(&rkey)?; 201 - Ok(Self(rkey)) 202 - } 196 + impl<T> Bos<str> for RecordKey<T> 197 + where 198 + T: RecordKeyType + Bos<str> + AsRef<str>, 199 + { 200 + type Ref<'this> 201 + = &'this str 202 + where 203 + Self: 'this; 203 204 204 - /// Infallible unchecked constructor for CowStr. 205 - pub unsafe fn unchecked_cow(rkey: CowStr<'r>) -> Self { 206 - Self(rkey) 205 + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { 206 + this.as_ref() 207 207 } 208 208 } 209 209 ··· 288 288 289 289 impl<'r> From<CowStr<'r>> for Rkey<CowStr<'r>> { 290 290 fn from(value: CowStr<'r>) -> Self { 291 - Self::new_cow(value).unwrap() 291 + Self::new(value).unwrap() 292 292 } 293 293 } 294 294 ··· 303 303 304 304 fn deref(&self) -> &Self::Target { 305 305 self.as_str() 306 + } 307 + } 308 + 309 + impl<S> Bos<str> for Rkey<S> 310 + where 311 + S: Bos<str> + AsRef<str>, 312 + { 313 + type Ref<'this> 314 + = &'this str 315 + where 316 + Self: 'this; 317 + 318 + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { 319 + this.as_str() 306 320 } 307 321 } 308 322 ··· 493 507 assert!(Rkey::new("a").is_ok()); // min 1 494 508 let valid_512 = "a".repeat(512); 495 509 assert_eq!(valid_512.len(), 512); 496 - assert!(Rkey::new(&valid_512).is_ok()); 510 + assert!(Rkey::new(valid_512).is_ok()); 497 511 498 512 let too_long_513 = "a".repeat(513); 499 513 assert_eq!(too_long_513.len(), 513); 500 - assert!(Rkey::new(&too_long_513).is_err()); 514 + assert!(Rkey::new(too_long_513).is_err()); 501 515 } 502 516 503 517 #[test]
+24 -12
crates/jacquard-common/src/types/string.rs
··· 73 73 Handle(Handle<S>), 74 74 /// Identifier (DID or handle) 75 75 AtIdentifier(AtIdentifier<S>), 76 - // TODO(bos-migration): parameterise on S once AtUri is migrated. 77 76 /// AT URI 78 - AtUri(AtUri<'static>), 79 - // TODO(bos-migration): parameterise on S once UriValue is migrated. 77 + AtUri(AtUri<S>), 80 78 /// Generic URI 81 - Uri(UriValue<'static>), 79 + Uri(UriValue<S>), 82 80 /// Content identifier 83 81 Cid(Cid<S>), 84 82 /// Record key ··· 123 121 if validate_nsid(s).is_ok() { 124 122 return Self::Nsid(unsafe { Nsid::unchecked(string) }); 125 123 } 126 - // TODO(bos-migration): AtUri and UriValue still use lifetimes. 127 - // For now, construct owned versions for those variants. 128 - if let Ok(aturi) = AtUri::new_owned(s) { 129 - return Self::AtUri(aturi); 124 + if crate::types::aturi::validate_and_index(s).is_ok() { 125 + return Self::AtUri(unsafe { AtUri::unchecked(string) }); 130 126 } 131 - if let Ok(uri) = UriValue::new_owned(s) { 132 - return Self::Uri(uri); 127 + // URI schemes that UriValue handles - check prefix, wrap S directly. 128 + if s.starts_with("https://") || s.starts_with("wss://") || s.starts_with("ipld://") { 129 + if let Ok(uri) = UriValue::new(s) { 130 + // we don't want to always Any here, it's better to fall back to the String variant. 131 + match uri { 132 + UriValue::Any(_) => {} 133 + _ => { 134 + drop(s); 135 + return Self::Uri(UriValue::new(string).expect("already checked")); 136 + } 137 + } 138 + } 133 139 } 140 + let s: &str = string.as_ref(); 134 141 // CID: try to parse as IPLD first, otherwise wrap as string CID. 135 142 if IpldCid::try_from(s).is_ok() || s.starts_with("bafy") { 136 143 return Self::Cid(unsafe { Cid::unchecked_str(string) }); 137 144 } 145 + drop(s); 138 146 // Fallback: plain string. 139 147 Self::String(string) 140 148 } ··· 227 235 AtprotoStr::Handle(handle) => AtprotoStr::Handle(handle.into_static()), 228 236 AtprotoStr::AtIdentifier(ident) => AtprotoStr::AtIdentifier(ident.into_static()), 229 237 // AtUri and UriValue are already 'static in this enum. 230 - AtprotoStr::AtUri(at_uri) => AtprotoStr::AtUri(at_uri), 231 - AtprotoStr::Uri(uri) => AtprotoStr::Uri(uri), 238 + AtprotoStr::AtUri(at_uri) => AtprotoStr::AtUri(at_uri.into_static()), 239 + AtprotoStr::Uri(uri) => AtprotoStr::Uri(uri.into_static()), 232 240 AtprotoStr::Cid(cid) => AtprotoStr::Cid(cid.into_static()), 233 241 AtprotoStr::RecordKey(record_key) => AtprotoStr::RecordKey(record_key.into_static()), 234 242 AtprotoStr::String(s) => AtprotoStr::String(s.into_static()), ··· 480 488 #[source] 481 489 err: Arc<AtStrError>, 482 490 }, 491 + /// Wraps another error with additional context 492 + #[error("converting from a string slice")] 493 + #[cfg_attr(feature = "std", diagnostic(code(jacquard::atstr::conversion)))] 494 + Conversion, 483 495 }
+168 -143
crates/jacquard-common/src/types/uri.rs
··· 1 - use crate::cowstr::ToCowStr; 1 + use crate::bos::Bos; 2 2 use crate::deps::fluent_uri::Uri; 3 3 use crate::{ 4 - CowStr, IntoStatic, 4 + CowStr, DefaultStr, IntoStatic, 5 5 types::{ 6 - aturi::AtUri, cid::Cid, collection::Collection, did::Did, nsid::Nsid, string::AtStrError, 6 + aturi::{AtUri, validate_and_index}, 7 + cid::Cid, 8 + collection::Collection, 9 + did::{Did, validate_did}, 10 + nsid::Nsid, 11 + string::{AtStrError, StrParseKind}, 7 12 }, 8 13 }; 9 - use alloc::string::String; 14 + use alloc::string::{String, ToString}; 10 15 use core::{fmt::Display, marker::PhantomData, ops::Deref, str::FromStr}; 11 16 use serde::{Deserialize, Deserializer, Serialize, Serializer}; 12 - use smol_str::ToSmolStr; 17 + use smol_str::{SmolStr, ToSmolStr}; 13 18 14 - /// Generic URI with type-specific parsing 19 + /// Generic URI with type-specific parsing. 15 20 /// 16 21 /// Automatically detects and parses URIs into the appropriate variant based on 17 22 /// the scheme prefix. Used in lexicon where URIs can be of various types. 18 23 /// 19 24 /// Variants are checked by prefix: `did:`, `at://`, `https://`, `wss://`, `ipld://` 20 25 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 21 - pub enum UriValue<'u> { 22 - /// DID URI (did:) 23 - Did(Did<CowStr<'u>>), 24 - /// AT Protocol URI (at://) 25 - At(AtUri<'u>), 26 - /// HTTPS URL 26 + pub enum UriValue<S: Bos<str> + AsRef<str> = DefaultStr> { 27 + /// DID URI (did:). 28 + Did(Did<S>), 29 + /// AT Protocol URI (at://). 30 + At(AtUri<S>), 31 + /// HTTPS URL. 27 32 Https(Uri<String>), 28 - /// WebSocket Secure URL 33 + /// WebSocket Secure URL. 29 34 Wss(Uri<String>), 30 - /// IPLD CID URI 31 - Cid(Cid<CowStr<'u>>), 32 - /// Unrecognized URI scheme (catch-all) 33 - Any(CowStr<'u>), 35 + /// IPLD CID URI. 36 + Cid(Cid<S>), 37 + /// Unrecognized URI scheme (catch-all). 38 + Any(S), 34 39 } 35 40 36 - /// Errors that can occur when parsing URIs 41 + /// Errors that can occur when parsing URIs. 37 42 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 38 43 #[non_exhaustive] 39 44 pub enum UriParseError { 40 - /// AT Protocol string parsing error 45 + /// AT Protocol string parsing error. 41 46 #[error("Invalid atproto string: {0}")] 42 47 At(#[from] AtStrError), 43 - /// URI parsing error 48 + /// URI parsing error. 44 49 #[error(transparent)] 45 50 Uri(#[from] crate::deps::fluent_uri::ParseError), 46 - /// CID parsing error 51 + /// CID parsing error. 47 52 #[error(transparent)] 48 53 Cid(#[from] crate::types::cid::Error), 49 54 } 50 55 51 - impl<'u> UriValue<'u> { 52 - /// Parse a URI from a string slice, borrowing 53 - pub fn new(uri: &'u str) -> Result<Self, UriParseError> { 54 - if uri.starts_with("did:") { 55 - Ok(UriValue::Did(Did::new_cow(uri.to_cowstr())?)) 56 - } else if uri.starts_with("at://") { 57 - Ok(UriValue::At(AtUri::new(uri)?)) 58 - } else if uri.starts_with("https://") { 59 - Ok(UriValue::Https(Uri::parse(uri)?.to_owned())) 60 - } else if uri.starts_with("wss://") { 61 - Ok(UriValue::Wss(Uri::parse(uri)?.to_owned())) 62 - } else if uri.starts_with("ipld://") { 63 - // Borrow the slice after "ipld://" prefix (7 bytes) from the input &'u str. 64 - let cid_part = &uri[7..]; 65 - if cid_part.is_empty() { 66 - Ok(UriValue::Any(CowStr::Borrowed(uri))) 67 - } else { 68 - Ok(UriValue::Cid(Cid::cow_str(CowStr::Borrowed(cid_part)))) 56 + // --------------------------------------------------------------------------- 57 + // Generic construction 58 + // --------------------------------------------------------------------------- 59 + 60 + impl<S: Bos<str> + AsRef<str>> UriValue<S> { 61 + /// Parse a URI, validate by prefix, wrap `S` into the matching variant. 62 + /// 63 + /// `Https` and `Wss` variants always allocate a `Uri<String>` regardless of `S`. 64 + pub fn new(uri: S) -> Result<Self, UriParseError> { 65 + let s = uri.as_ref(); 66 + if s.starts_with("did:") { 67 + if validate_did(s).is_ok() { 68 + return Ok(UriValue::Did(unsafe { Did::unchecked(uri) })); 69 69 } 70 - } else { 71 - Ok(UriValue::Any(CowStr::Borrowed(uri))) 70 + } else if s.starts_with("at://") { 71 + if let Ok(indices) = validate_and_index(s) { 72 + return Ok(UriValue::At(unsafe { AtUri::from_parts(uri, indices) })); 73 + } 74 + } else if s.starts_with("https://") { 75 + if let Ok(parsed) = Uri::parse(s) { 76 + return Ok(UriValue::Https(parsed.to_owned())); 77 + } 78 + } else if s.starts_with("wss://") { 79 + if let Ok(parsed) = Uri::parse(s) { 80 + return Ok(UriValue::Wss(parsed.to_owned())); 81 + } 82 + } else if s.starts_with("ipld://") { 83 + return Ok(UriValue::Cid(unsafe { Cid::unchecked_str(uri) })); 72 84 } 85 + Ok(UriValue::Any(uri)) 73 86 } 87 + } 74 88 75 - /// Parse a URI from a string, taking ownership 76 - pub fn new_owned(uri: impl AsRef<str>) -> Result<UriValue<'static>, UriParseError> { 77 - let uri = uri.as_ref(); 78 - if uri.starts_with("did:") { 79 - Ok(UriValue::Did(Did::new_owned(uri)?)) 80 - } else if uri.starts_with("at://") { 81 - Ok(UriValue::At(AtUri::new_owned(uri)?)) 82 - } else if uri.starts_with("https://") { 83 - Ok(UriValue::Https(Uri::parse(uri)?.to_owned())) 84 - } else if uri.starts_with("wss://") { 85 - Ok(UriValue::Wss(Uri::parse(uri)?.to_owned())) 86 - } else if uri.starts_with("ipld://") { 87 - // Owned context: use SmolStr via CowStr::Owned. 88 - let cid_part = &uri[7..]; 89 + // --------------------------------------------------------------------------- 90 + // Owned construction 91 + // --------------------------------------------------------------------------- 92 + 93 + impl<S: Bos<str> + AsRef<str> + FromStr> UriValue<S> { 94 + /// Parse a URI from a string, taking ownership. 95 + pub fn new_owned(uri: impl AsRef<str>) -> Result<Self, UriParseError> { 96 + let uri_str = uri.as_ref(); 97 + if uri_str.starts_with("did:") { 98 + Ok(UriValue::Did(Did::new_owned(uri_str)?)) 99 + } else if uri_str.starts_with("at://") { 100 + Ok(UriValue::At(AtUri::new_owned(uri_str)?)) 101 + } else if uri_str.starts_with("https://") { 102 + Ok(UriValue::Https(Uri::parse(uri_str)?.to_owned())) 103 + } else if uri_str.starts_with("wss://") { 104 + Ok(UriValue::Wss(Uri::parse(uri_str)?.to_owned())) 105 + } else if uri_str.starts_with("ipld://") { 106 + let cid_part = &uri_str[7..]; 89 107 if cid_part.is_empty() { 90 - Ok(UriValue::Any(CowStr::Owned(uri.to_smolstr()))) 108 + let s = S::from_str(uri_str).map_err(|_| { 109 + UriParseError::At(AtStrError::new( 110 + "uri", 111 + uri_str.to_string(), 112 + StrParseKind::Conversion, 113 + )) 114 + })?; 115 + Ok(UriValue::Any(s)) 91 116 } else { 92 - Ok(UriValue::Cid(Cid::cow_str(CowStr::Owned(cid_part.to_smolstr())))) 117 + let s = S::from_str(cid_part).map_err(|_| { 118 + UriParseError::At(AtStrError::new( 119 + "uri", 120 + cid_part.to_string(), 121 + StrParseKind::Conversion, 122 + )) 123 + })?; 124 + Ok(UriValue::Cid(unsafe { Cid::unchecked_str(s) })) 93 125 } 94 126 } else { 95 - Ok(UriValue::Any(CowStr::Owned(uri.to_smolstr()))) 127 + let s = S::from_str(uri_str).map_err(|_| { 128 + UriParseError::At(AtStrError::new( 129 + "uri", 130 + uri_str.to_string(), 131 + StrParseKind::Conversion, 132 + )) 133 + })?; 134 + Ok(UriValue::Any(s)) 96 135 } 97 136 } 137 + } 98 138 99 - /// Parse a URI from a CowStr, borrowing where possible 100 - pub fn new_cow(uri: CowStr<'u>) -> Result<Self, UriParseError> { 101 - if uri.starts_with("did:") { 102 - Ok(UriValue::Did(Did::new_cow(uri)?)) 103 - } else if uri.starts_with("at://") { 104 - Ok(UriValue::At(AtUri::new_cow(uri)?)) 105 - } else if uri.starts_with("https://") { 106 - Ok(UriValue::Https(Uri::parse(uri.as_ref())?.to_owned())) 107 - } else if uri.starts_with("wss://") { 108 - Ok(UriValue::Wss(Uri::parse(uri.as_ref())?.to_owned())) 109 - } else if uri.starts_with("ipld://") { 110 - // Determine whether the CID part (after "ipld://") is non-empty before consuming uri. 111 - if uri.as_ref()[7..].is_empty() { 112 - Ok(UriValue::Any(uri)) 113 - } else { 114 - // Build a CowStr for the CID part, preserving the ownership variant. 115 - let cid_cow: CowStr<'u> = match uri { 116 - CowStr::Borrowed(s) => CowStr::Borrowed(&s[7..]), 117 - CowStr::Owned(ref s) => CowStr::Owned(s[7..].to_smolstr()), 118 - }; 119 - Ok(UriValue::Cid(Cid::cow_str(cid_cow))) 120 - } 121 - } else { 122 - Ok(UriValue::Any(uri)) 123 - } 124 - } 139 + // --------------------------------------------------------------------------- 140 + // Accessors 141 + // --------------------------------------------------------------------------- 125 142 126 - /// Get the URI as a string slice 143 + impl<S: Bos<str> + AsRef<str>> UriValue<S> { 144 + /// Get the URI as a string slice. 127 145 pub fn as_str(&self) -> &str { 128 146 match self { 129 147 UriValue::Did(did) => did.as_str(), ··· 136 154 } 137 155 } 138 156 139 - impl Serialize for UriValue<'_> { 140 - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 157 + // --------------------------------------------------------------------------- 158 + // Serde 159 + // --------------------------------------------------------------------------- 160 + 161 + impl<S: Bos<str> + AsRef<str>> Serialize for UriValue<S> { 162 + fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> 141 163 where 142 - S: Serializer, 164 + Ser: Serializer, 143 165 { 144 166 serializer.serialize_str(self.as_str()) 145 167 } 146 168 } 147 169 148 - impl<'de, 'a> Deserialize<'de> for UriValue<'a> 149 - where 150 - 'de: 'a, 151 - { 170 + impl<'de> Deserialize<'de> for UriValue<CowStr<'de>> { 152 171 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 153 172 where 154 173 D: Deserializer<'de>, 155 174 { 156 - use serde::de::Error; 157 - let value = Deserialize::deserialize(deserializer)?; 158 - Self::new_cow(value).map_err(D::Error::custom) 175 + let value: CowStr<'de> = Deserialize::deserialize(deserializer)?; 176 + Self::new(value).map_err(serde::de::Error::custom) 177 + } 178 + } 179 + 180 + impl<'de> Deserialize<'de> for UriValue<SmolStr> { 181 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 182 + where 183 + D: Deserializer<'de>, 184 + { 185 + let s: SmolStr = Deserialize::deserialize(deserializer)?; 186 + Self::new(s).map_err(serde::de::Error::custom) 159 187 } 160 188 } 161 189 162 - impl<'s> AsRef<str> for UriValue<'s> { 190 + // --------------------------------------------------------------------------- 191 + // AsRef, IntoStatic 192 + // --------------------------------------------------------------------------- 193 + 194 + impl<S: Bos<str> + AsRef<str>> AsRef<str> for UriValue<S> { 163 195 fn as_ref(&self) -> &str { 164 - match self { 165 - UriValue::Did(did) => did.as_str(), 166 - UriValue::At(at_uri) => at_uri.as_str(), 167 - UriValue::Https(url) => url.as_str(), 168 - UriValue::Wss(url) => url.as_str(), 169 - UriValue::Cid(cid) => cid.as_str(), 170 - UriValue::Any(s) => s.as_ref(), 171 - } 196 + self.as_str() 172 197 } 173 198 } 174 199 175 - impl IntoStatic for UriValue<'_> { 176 - type Output = UriValue<'static>; 200 + impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for UriValue<S> 201 + where 202 + S::Output: Bos<str> + AsRef<str>, 203 + { 204 + type Output = UriValue<S::Output>; 177 205 178 206 fn into_static(self) -> Self::Output { 179 207 match self { ··· 186 214 } 187 215 } 188 216 } 217 + 218 + // --------------------------------------------------------------------------- 219 + // RecordUri 220 + // --------------------------------------------------------------------------- 189 221 190 222 #[repr(transparent)] 191 - /// Collection type-annotated at:// URI 223 + /// Collection type-annotated at:// URI. 192 224 /// 193 - /// Carries the corresponding collection type for fetching records easily 194 - pub struct RecordUri<'a, R: Collection>(AtUri<'a>, PhantomData<R>); 225 + /// Carries the corresponding collection type for fetching records easily. 226 + pub struct RecordUri<S: Bos<str> + AsRef<str>, R: Collection>(AtUri<S>, PhantomData<R>); 195 227 196 - impl<'a, R: Collection> RecordUri<'a, R> { 197 - /// attepts to parse an at-uri as the corresponding collection 198 - pub fn try_from_uri(uri: AtUri<'a>) -> Result<Self, UriError> { 228 + impl<S: Bos<str> + AsRef<str>, R: Collection> RecordUri<S, R> { 229 + /// Attempts to parse an at-uri as the corresponding collection. 230 + pub fn try_from_uri(uri: AtUri<S>) -> Result<Self, UriError> { 199 231 if let Some(collection) = uri.collection() { 200 232 if collection.as_str() == R::NSID { 201 233 return Ok(Self(uri, PhantomData)); ··· 209 241 }) 210 242 } 211 243 212 - /// Spits out the internal un-typed AtUri 213 - pub fn into_inner(self) -> AtUri<'a> { 244 + /// Returns the internal un-typed AtUri. 245 + pub fn into_inner(self) -> AtUri<S> { 214 246 self.0 215 247 } 216 248 217 - /// Accesses the internal AtUri for use 218 - pub fn as_uri(&self) -> &AtUri<'a> { 249 + /// Accesses the internal AtUri for use. 250 + pub fn as_uri(&self) -> &AtUri<S> { 219 251 &self.0 220 252 } 221 253 } 222 254 223 - impl<R: Collection> Display for RecordUri<'_, R> { 255 + impl<S: Bos<str> + AsRef<str>, R: Collection> Display for RecordUri<S, R> { 224 256 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 225 257 self.0.fmt(f) 226 258 } 227 259 } 228 260 229 - impl<'a, R: Collection> AsRef<AtUri<'a>> for RecordUri<'a, R> { 230 - fn as_ref(&self) -> &AtUri<'a> { 261 + impl<S: Bos<str> + AsRef<str>, R: Collection> AsRef<AtUri<S>> for RecordUri<S, R> { 262 + fn as_ref(&self) -> &AtUri<S> { 231 263 &self.0 232 264 } 233 265 } 234 266 235 - impl<'a, R: Collection> Deref for RecordUri<'a, R> { 236 - type Target = AtUri<'a>; 267 + impl<S: Bos<str> + AsRef<str>, R: Collection> Deref for RecordUri<S, R> { 268 + type Target = AtUri<S>; 237 269 238 270 fn deref(&self) -> &Self::Target { 239 271 &self.0 ··· 242 274 243 275 #[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)] 244 276 #[non_exhaustive] 245 - /// Errors that can occur when parsing or validating collection type-annotated URIs 277 + /// Errors that can occur when parsing or validating collection type-annotated URIs. 246 278 pub enum UriError { 247 - /// Given at-uri didn't have the matching collection for the record 279 + /// Given at-uri didn't have the matching collection for the record. 248 280 #[error("Collection mismatch: expected {expected}, found {found:?}")] 249 281 CollectionMismatch { 250 - /// The collection of the record 282 + /// The collection of the record. 251 283 expected: &'static str, 252 - /// What the at-uri had 284 + /// What the at-uri had. 253 285 found: Option<Nsid>, 254 286 }, 255 - /// Couldn't parse the string as an AtUri 287 + /// Couldn't parse the string as an AtUri. 256 288 #[error("Invalid URI: {0}")] 257 289 InvalidUri(#[from] AtStrError), 258 290 } ··· 263 295 264 296 #[test] 265 297 fn test_wss_variant_parsing() { 266 - // Test that wss:// URIs are parsed as UriValue::Wss, not UriValue::Https 267 298 let uri = UriValue::new("wss://example.com/path").expect("valid wss uri"); 268 299 assert!( 269 300 matches!(uri, UriValue::Wss(_)), ··· 274 305 275 306 #[test] 276 307 fn test_https_variant_parsing() { 277 - // Test that https:// URIs are parsed as UriValue::Https 278 308 let uri = UriValue::new("https://example.com/path").expect("valid https uri"); 279 309 assert!( 280 310 matches!(uri, UriValue::Https(_)), ··· 285 315 286 316 #[test] 287 317 fn test_wss_owned_variant_parsing() { 288 - // Test that owned wss:// parsing works correctly 289 - let uri = UriValue::new_owned("wss://example.com").expect("valid wss uri"); 318 + let uri: UriValue<SmolStr> = 319 + UriValue::new_owned("wss://example.com").expect("valid wss uri"); 290 320 assert!( 291 321 matches!(uri, UriValue::Wss(_)), 292 322 "owned wss:// should parse to UriValue::Wss" ··· 296 326 297 327 #[test] 298 328 fn test_https_owned_variant_parsing() { 299 - // Test that owned https:// parsing works correctly 300 - let uri = UriValue::new_owned("https://example.com").expect("valid https uri"); 329 + let uri: UriValue<SmolStr> = 330 + UriValue::new_owned("https://example.com").expect("valid https uri"); 301 331 assert!( 302 332 matches!(uri, UriValue::Https(_)), 303 333 "owned https:// should parse to UriValue::Https" ··· 307 337 308 338 #[test] 309 339 fn test_wss_cow_variant_parsing() { 310 - // Test that cow variant parsing works correctly for wss:// 311 - let uri = UriValue::new_cow(CowStr::Borrowed("wss://example.com")).expect("valid wss uri"); 340 + let uri = UriValue::new(CowStr::Borrowed("wss://example.com")).expect("valid wss uri"); 312 341 assert!( 313 342 matches!(uri, UriValue::Wss(_)), 314 343 "cow wss:// should parse to UriValue::Wss" ··· 318 347 319 348 #[test] 320 349 fn test_https_cow_variant_parsing() { 321 - // Test that cow variant parsing works correctly for https:// 322 - let uri = 323 - UriValue::new_cow(CowStr::Borrowed("https://example.com")).expect("valid https uri"); 350 + let uri = UriValue::new(CowStr::Borrowed("https://example.com")).expect("valid https uri"); 324 351 assert!( 325 352 matches!(uri, UriValue::Https(_)), 326 353 "cow https:// should parse to UriValue::Https" ··· 330 357 331 358 #[test] 332 359 fn test_uri_display() { 333 - // Test that Display output preserves the original scheme 334 - let wss = UriValue::new_owned("wss://example.com").unwrap(); 360 + let wss: UriValue<SmolStr> = UriValue::new_owned("wss://example.com").unwrap(); 335 361 assert_eq!(wss.as_str(), "wss://example.com"); 336 362 337 - let https = UriValue::new_owned("https://example.com").unwrap(); 363 + let https: UriValue<SmolStr> = UriValue::new_owned("https://example.com").unwrap(); 338 364 assert_eq!(https.as_str(), "https://example.com"); 339 365 } 340 366 341 367 #[test] 342 368 fn test_into_static_preserves_variant() { 343 - // Test that IntoStatic conversion preserves the variant type 344 - let wss = UriValue::new_owned("wss://example.com").unwrap(); 369 + let wss: UriValue<SmolStr> = UriValue::new_owned("wss://example.com").unwrap(); 345 370 let static_wss = wss.into_static(); 346 371 assert!(matches!(static_wss, UriValue::Wss(_))); 347 372 348 - let https = UriValue::new_owned("https://example.com").unwrap(); 373 + let https: UriValue<SmolStr> = UriValue::new_owned("https://example.com").unwrap(); 349 374 let static_https = https.into_static(); 350 375 assert!(matches!(static_https, UriValue::Https(_))); 351 376 }
+14 -15
crates/jacquard-common/src/types/value/parsing.rs
··· 42 42 } 43 43 } 44 44 LexiconStringType::AtUri => { 45 - if let Ok(value) = AtUri::new(value) { 45 + if let Ok(value) = AtUri::new(value.to_cowstr()) { 46 46 // AtprotoStr::AtUri stores AtUri<'static>; convert to owned. 47 47 map.insert( 48 48 key.to_smolstr(), ··· 56 56 } 57 57 } 58 58 LexiconStringType::Did => { 59 - if let Ok(value) = Did::new_cow(value.to_cowstr()) { 59 + if let Ok(value) = Did::new(value.to_cowstr()) { 60 60 map.insert(key.to_smolstr(), Data::String(AtprotoStr::Did(value))); 61 61 } else { 62 62 map.insert( ··· 66 66 } 67 67 } 68 68 LexiconStringType::Handle => { 69 - if let Ok(value) = Handle::new_cow(value.into()) { 69 + if let Ok(value) = Handle::new(value.to_cowstr()) { 70 70 map.insert(key.to_smolstr(), Data::String(AtprotoStr::Handle(value))); 71 71 } else { 72 72 map.insert( ··· 76 76 } 77 77 } 78 78 LexiconStringType::AtIdentifier => { 79 - if let Ok(value) = AtIdentifier::new_cow(value.to_cowstr()) { 79 + if let Ok(value) = AtIdentifier::new(value.to_cowstr()) { 80 80 map.insert( 81 81 key.to_smolstr(), 82 82 Data::String(AtprotoStr::AtIdentifier(value)), ··· 89 89 } 90 90 } 91 91 LexiconStringType::Nsid => { 92 - if let Ok(value) = Nsid::new_cow(value.to_cowstr()) { 92 + if let Ok(value) = Nsid::new(value.to_cowstr()) { 93 93 map.insert(key.to_smolstr(), Data::String(AtprotoStr::Nsid(value))); 94 94 } else { 95 95 map.insert( ··· 135 135 key.to_smolstr(), 136 136 // Rkey already validated above; borrow the original &'s str directly. 137 137 Data::String(AtprotoStr::RecordKey( 138 - RecordKey::any_cow(CowStr::Borrowed(value)) 139 - .expect("Rkey validation passed"), 138 + RecordKey::any(CowStr::Borrowed(value)).expect("Rkey validation passed"), 140 139 )), 141 140 ); 142 141 } else { ··· 167 166 /// smarter parsing to avoid trying as many posibilities. 168 167 pub fn parse_string<'s>(string: &'s str) -> AtprotoStr<CowStr<'s>> { 169 168 if string.len() < 2048 && string.starts_with("did:") { 170 - if let Ok(did) = Did::new_cow(string.to_cowstr()) { 169 + if let Ok(did) = Did::new(string.to_cowstr()) { 171 170 return AtprotoStr::Did(did); 172 171 } 173 172 } else if string.starts_with("20") && string.ends_with("Z") { ··· 177 176 } 178 177 } else if string.starts_with("at://") { 179 178 // AtprotoStr::AtUri stores AtUri<'static>; convert to owned. 180 - if let Ok(uri) = AtUri::new(string) { 181 - return AtprotoStr::AtUri(uri.into_static()); 179 + if let Ok(uri) = AtUri::new(string.to_cowstr()) { 180 + return AtprotoStr::AtUri(uri); 182 181 } 183 182 } else if string.starts_with("https://") { 184 183 if let Ok(uri) = Uri::parse(string) { ··· 209 208 210 209 // First segment is a known TLD → reverse domain order → try NSID first. 211 210 if first_is_tld { 212 - if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { 211 + if let Ok(nsid) = Nsid::new(string.to_cowstr()) { 213 212 return AtprotoStr::Nsid(nsid); 214 213 } 215 214 } 216 215 217 216 // Last segment is a known TLD and first is not → normal domain order → handle. 218 217 if last_is_tld && !first_is_tld { 219 - if let Ok(handle) = AtIdentifier::new_cow(string.to_cowstr()) { 218 + if let Ok(handle) = AtIdentifier::new(string.to_cowstr()) { 220 219 return AtprotoStr::AtIdentifier(handle); 221 220 } 222 221 } 223 222 224 223 // camelCase in last segment → NSID (e.g., "com.atproto.repo.getRecord"). 225 224 if has_upper_last_segment { 226 - if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { 225 + if let Ok(nsid) = Nsid::new(string.to_cowstr()) { 227 226 return AtprotoStr::Nsid(nsid); 228 227 } 229 228 } 230 229 231 230 // Fallback: try both, preferring handle. 232 - if let Ok(handle) = AtIdentifier::new_cow(string.to_cowstr()) { 231 + if let Ok(handle) = AtIdentifier::new(string.to_cowstr()) { 233 232 return AtprotoStr::AtIdentifier(handle); 234 - } else if let Ok(nsid) = Nsid::new_cow(string.to_cowstr()) { 233 + } else if let Ok(nsid) = Nsid::new(string.to_cowstr()) { 235 234 return AtprotoStr::Nsid(nsid); 236 235 } else if string.contains("://") && Uri::<&str>::parse(string).is_ok() { 237 236 // AtprotoStr::Uri stores UriValue<'static>; convert to owned.
+1 -1
crates/jacquard-common/src/types/value/serde_impl.rs
··· 384 384 LexiconStringType::Tid => Tid::new(s.clone()) 385 385 .map(|tid| Data::String(AtprotoStr::Tid(tid))) 386 386 .unwrap_or_else(|_| Data::String(AtprotoStr::String(s.clone()))), 387 - LexiconStringType::RecordKey => Rkey::new_cow(s.clone()) 387 + LexiconStringType::RecordKey => Rkey::new(s.clone()) 388 388 .map(|rkey| Data::String(AtprotoStr::RecordKey(RecordKey(rkey)))) 389 389 .unwrap_or_else(|_| Data::String(AtprotoStr::String(s.clone()))), 390 390 LexiconStringType::Uri(_) => UriValue::new_owned(s.clone())
+22 -21
crates/jacquard-common/src/types/value/tests.rs
··· 1 + use crate::cowstr::ToCowStr; 2 + 1 3 use super::*; 2 4 use core::str::FromStr; 3 5 ··· 560 562 #[derive(Debug, Deserialize)] 561 563 struct WithAtUri<'a> { 562 564 #[serde(borrow)] 563 - uri: AtUri<'a>, 565 + uri: AtUri<CowStr<'a>>, 564 566 did: Did<CowStr<'a>>, 565 567 } 566 568 ··· 568 570 map.insert( 569 571 SmolStr::new_static("uri"), 570 572 Data::String(AtprotoStr::AtUri( 571 - AtUri::new("at://alice.bsky.social/app.bsky.feed.post/3jk5").unwrap(), 573 + AtUri::new("at://alice.bsky.social/app.bsky.feed.post/3jk5".to_cowstr()).unwrap(), 572 574 )), 573 575 ); 574 576 map.insert( ··· 586 588 } 587 589 588 590 #[test] 589 - fn test_aturi_zero_copy() { 590 - use serde::Deserialize; 591 + fn test_aturi_zero_copy_borrowed() { 592 + // AtUri<&str> should point at the original buffer. 593 + let uri_str = "at://alice.bsky.social/app.bsky.feed.post/3jk5"; 594 + let uri = AtUri::new(uri_str).unwrap(); 595 + assert_eq!(uri.as_str().as_ptr(), uri_str.as_ptr()); 596 + } 591 597 592 - #[derive(Debug, Deserialize)] 593 - struct WithAtUri<'a> { 594 - #[serde(borrow)] 595 - uri: AtUri<'a>, 596 - } 598 + #[test] 599 + fn test_aturi_zero_copy_cowstr() { 600 + // AtUri<CowStr<'a>> with a borrowed CowStr should point at the original buffer. 601 + let uri_str = "at://alice.bsky.social/app.bsky.feed.post/3jk5"; 602 + let uri = AtUri::new(CowStr::Borrowed(uri_str)).unwrap(); 603 + assert_eq!(uri.as_str().as_ptr(), uri_str.as_ptr()); 604 + } 597 605 598 - // Use borrowed CowStr to create the AtUri 606 + #[test] 607 + fn test_aturi_owned_allocates() { 608 + // AtUri<SmolStr> allocates — pointers should NOT match. 599 609 let uri_str = "at://alice.bsky.social/app.bsky.feed.post/3jk5"; 600 - let mut map = BTreeMap::new(); 601 - map.insert( 602 - SmolStr::new_static("uri"), 603 - Data::String(AtprotoStr::AtUri(AtUri::new(uri_str).unwrap())), 604 - ); 605 - let data = Data::Object(Object(map)); 606 - 607 - let result: WithAtUri = from_data(&data).unwrap(); 608 - 609 - // Check if the AtUri borrowed from the original string 610 - assert_eq!(result.uri.as_str().as_ptr(), uri_str.as_ptr()); 610 + let uri: AtUri<SmolStr> = AtUri::new_owned(uri_str).unwrap(); 611 + assert_ne!(uri.as_str().as_ptr(), uri_str.as_ptr()); 611 612 } 612 613 613 614 #[test]
+9 -9
crates/jacquard-common/src/xrpc/atproto.rs
··· 89 89 #[serde(borrow)] 90 90 pub cid: Option<Cid<CowStr<'a>>>, 91 91 #[serde(borrow)] 92 - pub uri: AtUri<'a>, 92 + pub uri: AtUri<CowStr<'a>>, 93 93 #[serde(borrow)] 94 94 pub value: Data<'a>, 95 95 } ··· 164 164 #[serde(borrow)] 165 165 pub cid: Option<Cid<CowStr<'a>>>, 166 166 #[serde(borrow)] 167 - pub uri: AtUri<'a>, 167 + pub uri: AtUri<CowStr<'a>>, 168 168 #[serde(borrow)] 169 169 pub value: Data<'a>, 170 170 } ··· 453 453 #[cfg(test)] 454 454 mod tests { 455 455 use super::*; 456 - use crate::IntoStatic; 456 + use crate::{IntoStatic, cowstr::ToCowStr}; 457 457 458 458 #[test] 459 459 fn test_list_records_serializes() { 460 460 let req = ListRecords { 461 - repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), 462 - collection: Nsid::new_cow("app.bsky.feed.post".into()) 461 + repo: AtIdentifier::new("test.bsky.social".to_cowstr()).unwrap(), 462 + collection: Nsid::new("app.bsky.feed.post".to_cowstr()) 463 463 .unwrap() 464 464 .into_static(), 465 465 cursor: None, ··· 552 552 #[test] 553 553 fn test_types_implement_into_static() { 554 554 let list_records = ListRecords { 555 - repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), 556 - collection: Nsid::new_cow("app.bsky.feed.post".into()) 555 + repo: AtIdentifier::new("test.bsky.social".to_cowstr()).unwrap(), 556 + collection: Nsid::new("app.bsky.feed.post".to_cowstr()) 557 557 .unwrap() 558 558 .into_static(), 559 559 cursor: None, ··· 563 563 let _static = list_records.into_static(); 564 564 565 565 let get_record = GetRecord { 566 - repo: AtIdentifier::new_cow("test.bsky.social".into()).unwrap(), 567 - collection: Nsid::new_cow("app.bsky.feed.post".into()) 566 + repo: AtIdentifier::new("test.bsky.social".to_cowstr()).unwrap(), 567 + collection: Nsid::new("app.bsky.feed.post".to_cowstr()) 568 568 .unwrap() 569 569 .into_static(), 570 570 rkey: CowStr::from("abc123").into_static(),