1use crate::bos::{Bos, DefaultStr};
2use crate::types::string::{AtStrError, StrParseKind};
3use crate::types::{DISALLOWED_TLDS, ends_with};
4use crate::{CowStr, IntoStatic};
5use alloc::string::String;
6use alloc::string::ToString;
7use core::fmt;
8use core::hash::{Hash, Hasher};
9use core::ops::Deref;
10use core::str::FromStr;
11#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
12use regex::Regex;
13#[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))]
14use regex_automata::meta::Regex;
15#[cfg(target_arch = "wasm32")]
16use regex_lite::Regex;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use smol_str::{SmolStr, StrExt};
19
20use super::Lazy;
21
22#[derive(Clone)]
32#[repr(transparent)]
33pub struct Handle<S: Bos<str> = DefaultStr>(pub(crate) S);
34
35pub static HANDLE_REGEX: Lazy<Regex> = Lazy::new(|| {
37 Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap()
38});
39
40fn strip_handle_prefix(handle: &str) -> &str {
45 handle
46 .strip_prefix("at://")
47 .or_else(|| handle.strip_prefix('@'))
48 .unwrap_or(handle)
49}
50
51pub(crate) fn validate_handle(handle: &str) -> Result<(), AtStrError> {
52 if handle.len() > 253 {
53 Err(AtStrError::too_long("handle", handle, 253, handle.len()))
54 } else if !HANDLE_REGEX.is_match(handle) {
55 Err(AtStrError::regex(
56 "handle",
57 handle,
58 SmolStr::new_static("invalid"),
59 ))
60 } else if ends_with(handle, DISALLOWED_TLDS) && handle != "handle.invalid" {
61 Err(AtStrError::disallowed("handle", handle, DISALLOWED_TLDS))
62 } else {
63 Ok(())
64 }
65}
66
67impl<S: Bos<str> + AsRef<str>> Handle<S> {
72 pub fn as_str(&self) -> &str {
78 self.0.as_ref()
79 }
80
81 pub fn is_valid(&self) -> bool {
84 let s = self.as_str();
85 s.len() <= 253 && HANDLE_REGEX.is_match(s) && !ends_with(s, DISALLOWED_TLDS)
86 }
87}
88
89impl<S: Bos<str>> Handle<S> {
90 pub unsafe fn unchecked(handle: S) -> Self {
96 Handle(handle)
97 }
98
99 pub fn borrow(&self) -> Handle<&str>
101 where
102 S: AsRef<str>,
103 {
104 unsafe { Handle::unchecked(self.0.as_ref()) }
106 }
107}
108
109impl<S: Bos<str> + AsRef<str>> Handle<S> {
114 pub fn new(s: S) -> Result<Self, AtStrError> {
119 let r = s.as_ref();
120 if r.contains(|c: char| c.is_ascii_uppercase()) {
121 return Err(AtStrError::regex(
122 "handle",
123 r,
124 SmolStr::new_static("contains uppercase (use new_owned for normalisation)"),
125 ));
126 }
127 validate_handle(r)?;
128 Ok(Self(s))
129 }
130
131 pub fn raw(s: S) -> Self {
133 Self::new(s).expect("invalid handle")
134 }
135}
136
137impl<S: Bos<str> + FromStr> Handle<S> {
142 pub fn new_owned(handle: impl AsRef<str>) -> Result<Self, AtStrError> {
146 let handle = handle.as_ref();
147 let stripped = strip_handle_prefix(handle);
148 let normalized = stripped.to_lowercase_smolstr();
149 validate_handle(&normalized)?;
150 let s = S::from_str(&normalized).map_err(|_| {
151 AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion)
152 })?;
153 Ok(Self(s))
154 }
155
156 pub fn new_static(handle: &'static str) -> Result<Self, AtStrError> {
158 let stripped = strip_handle_prefix(handle);
159 let normalized = if stripped.contains(|c: char| c.is_ascii_uppercase()) {
160 stripped.to_lowercase_smolstr()
161 } else {
162 SmolStr::new_static(stripped)
163 };
164 validate_handle(&normalized)?;
165 let s = S::from_str(&normalized).map_err(|_| {
166 AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion)
167 })?;
168 Ok(Self(s))
169 }
170}
171
172impl<'de, S> Deserialize<'de> for Handle<S>
177where
178 S: Bos<str> + AsRef<str> + Deserialize<'de>,
179{
180 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
181 where
182 D: Deserializer<'de>,
183 {
184 let s = S::deserialize(deserializer)?;
185 validate_handle(s.as_ref()).map_err(serde::de::Error::custom)?;
186 Ok(Handle(s))
187 }
188}
189
190impl<S: Bos<str> + AsRef<str>> Serialize for Handle<S> {
195 fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
196 where
197 Ser: Serializer,
198 {
199 let raw = self.as_str();
200 if raw.bytes().all(|b| !b.is_ascii_uppercase()) {
201 serializer.serialize_str(raw)
202 } else {
203 let lowered = raw.to_lowercase_smolstr();
204 serializer.serialize_str(&lowered)
205 }
206 }
207}
208
209impl<S: Bos<str> + AsRef<str>, T: Bos<str> + AsRef<str>> PartialEq<Handle<T>> for Handle<S> {
214 fn eq(&self, other: &Handle<T>) -> bool {
215 self.as_str().eq_ignore_ascii_case(other.as_str())
216 }
217}
218
219impl<S: Bos<str> + AsRef<str>> Eq for Handle<S> {}
220
221impl<S: Bos<str> + AsRef<str>> Hash for Handle<S> {
222 fn hash<H: Hasher>(&self, state: &mut H) {
223 for byte in self.as_str().bytes() {
224 state.write_u8(byte.to_ascii_lowercase());
225 }
226 }
227}
228
229impl<S: Bos<str> + IntoStatic> IntoStatic for Handle<S>
234where
235 S::Output: Bos<str>,
236{
237 type Output = Handle<S::Output>;
238
239 fn into_static(self) -> Self::Output {
240 Handle(self.0.into_static())
241 }
242}
243
244impl<S: Bos<str>> Handle<S> {
245 pub fn convert<B: Bos<str> + From<S>>(self) -> Handle<B> {
247 Handle(B::from(self.0))
248 }
249}
250
251impl FromStr for Handle {
252 type Err = AtStrError;
253
254 fn from_str(s: &str) -> Result<Self, Self::Err> {
255 Self::new_owned(s)
256 }
257}
258
259impl FromStr for Handle<CowStr<'static>> {
260 type Err = AtStrError;
261
262 fn from_str(s: &str) -> Result<Self, Self::Err> {
263 Self::new_owned(s)
264 }
265}
266
267impl FromStr for Handle<String> {
268 type Err = AtStrError;
269
270 fn from_str(s: &str) -> Result<Self, Self::Err> {
271 Self::new_owned(s)
272 }
273}
274
275impl<S: Bos<str> + AsRef<str>> fmt::Display for Handle<S> {
276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277 if self.0.as_ref().contains(|c: char| c.is_ascii_uppercase()) {
278 for c in self.as_str().chars() {
279 fmt::Write::write_char(f, c.to_ascii_lowercase())?;
280 }
281 } else {
282 f.write_str(self.as_str())?;
283 }
284 Ok(())
285 }
286}
287
288impl<S: Bos<str> + AsRef<str>> fmt::Debug for Handle<S> {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 write!(f, "at://")?;
291 if self.0.as_ref().contains(|c: char| c.is_ascii_uppercase()) {
292 for c in self.as_str().chars() {
293 fmt::Write::write_char(f, c.to_ascii_lowercase())?;
294 }
295 } else {
296 f.write_str(self.as_str())?;
297 }
298 Ok(())
299 }
300}
301
302impl<S: Bos<str> + AsRef<str>> From<Handle<S>> for String {
303 fn from(value: Handle<S>) -> Self {
304 value.as_str().to_ascii_lowercase()
305 }
306}
307
308impl<S: Bos<str> + AsRef<str>> From<Handle<S>> for SmolStr {
309 fn from(value: Handle<S>) -> Self {
310 value.as_str().to_ascii_lowercase_smolstr()
311 }
312}
313
314impl From<String> for Handle {
315 fn from(value: String) -> Self {
316 Self::new_owned(value).unwrap()
317 }
318}
319
320impl<'h> From<CowStr<'h>> for Handle<CowStr<'h>> {
321 fn from(value: CowStr<'h>) -> Self {
322 Self::new(value).unwrap()
323 }
324}
325
326impl<S: Bos<str> + AsRef<str>> AsRef<str> for Handle<S> {
327 fn as_ref(&self) -> &str {
328 self.as_str()
329 }
330}
331
332impl<S: Bos<str> + AsRef<str>> Deref for Handle<S> {
333 type Target = str;
334
335 fn deref(&self) -> &Self::Target {
336 self.as_str()
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn valid_handles() {
346 assert!(Handle::<&str>::new("alice.test").is_ok());
347 assert!(Handle::<&str>::new("foo.bsky.social").is_ok());
348 assert!(Handle::<&str>::new("a.b.c.d.e").is_ok());
349 assert!(Handle::<&str>::new("a1.b2.c3").is_ok());
350 assert!(Handle::<&str>::new("name-with-dash.com").is_ok());
351 }
352
353 #[test]
354 fn valid_handles_owned() {
355 assert!(Handle::<SmolStr>::new_owned("alice.test").is_ok());
356 assert!(Handle::<SmolStr>::new_owned("Alice.Test").is_ok());
357 assert!(Handle::<String>::new_owned("foo.bsky.social").is_ok());
358 }
359
360 #[test]
361 fn borrowed_rejects_uppercase() {
362 assert!(Handle::<&str>::new("Alice.Test").is_err());
363 }
364
365 #[test]
366 fn prefix_stripping() {
367 assert!(Handle::<&str>::new("@alice.test").is_err());
369 assert!(Handle::<&str>::new("at://alice.test").is_err());
370 assert_eq!(
371 Handle::<SmolStr>::new_owned("@alice.test")
372 .unwrap()
373 .as_str(),
374 "alice.test"
375 );
376 assert_eq!(
377 Handle::<SmolStr>::new_owned("at://alice.test")
378 .unwrap()
379 .as_str(),
380 "alice.test"
381 );
382 assert_eq!(
383 Handle::<&str>::new("alice.test").unwrap().as_str(),
384 "alice.test"
385 );
386 }
387
388 #[test]
389 fn prefix_stripping_owned() {
390 assert_eq!(
391 Handle::<SmolStr>::new_owned("@Alice.Test")
392 .unwrap()
393 .as_str(),
394 "alice.test"
395 );
396 assert_eq!(
397 Handle::<SmolStr>::new_owned("at://alice.test")
398 .unwrap()
399 .as_str(),
400 "alice.test"
401 );
402 }
403
404 #[test]
405 fn max_length() {
406 let s1 = format!("a{}a", "b".repeat(61));
407 let s2 = format!("c{}c", "d".repeat(61));
408 let s3 = format!("e{}e", "f".repeat(61));
409 let s4 = format!("g{}g", "h".repeat(59));
410 let valid_253 = format!("{s1}.{s2}.{s3}.{s4}");
411 assert_eq!(valid_253.len(), 253);
412 assert!(Handle::<&str>::new(&valid_253).is_ok());
413
414 let s4_long = format!("g{}g", "h".repeat(60));
415 let too_long_254 = format!("{s1}.{s2}.{s3}.{s4_long}");
416 assert_eq!(too_long_254.len(), 254);
417 assert!(Handle::<&str>::new(&too_long_254).is_err());
418 }
419
420 #[test]
421 fn segment_length_constraints() {
422 let valid = format!("{}.com", "a".repeat(63));
423 assert!(Handle::<&str>::new(&valid).is_ok());
424 let too_long = format!("{}.com", "a".repeat(64));
425 assert!(Handle::<&str>::new(&too_long).is_err());
426 }
427
428 #[test]
429 fn hyphen_placement() {
430 assert!(Handle::<&str>::new("valid-label.com").is_ok());
431 assert!(Handle::<&str>::new("-nope.com").is_err());
432 assert!(Handle::<&str>::new("nope-.com").is_err());
433 }
434
435 #[test]
436 fn tld_must_start_with_letter() {
437 assert!(Handle::<&str>::new("foo.bar").is_ok());
438 assert!(Handle::<&str>::new("foo.9bar").is_err());
439 }
440
441 #[test]
442 fn disallowed_tlds() {
443 for tld in [
444 "local",
445 "localhost",
446 "arpa",
447 "invalid",
448 "internal",
449 "example",
450 "alt",
451 "onion",
452 ] {
453 assert!(
454 Handle::<&str>::new(&format!("foo.{tld}")).is_err(),
455 "should reject .{tld}"
456 );
457 }
458 }
459
460 #[test]
461 fn minimum_segments() {
462 assert!(Handle::<&str>::new("a.b").is_ok());
463 assert!(Handle::<&str>::new("a").is_err());
464 assert!(Handle::<&str>::new("com").is_err());
465 }
466
467 #[test]
468 fn invalid_characters() {
469 assert!(Handle::<&str>::new("foo!bar.com").is_err());
470 assert!(Handle::<&str>::new("foo_bar.com").is_err());
471 assert!(Handle::<&str>::new("foo bar.com").is_err());
472 assert!(Handle::<&str>::new("foo@bar.com").is_err());
473 }
474
475 #[test]
476 fn empty_segments() {
477 assert!(Handle::<&str>::new("foo..com").is_err());
478 assert!(Handle::<&str>::new(".foo.com").is_err());
479 assert!(Handle::<&str>::new("foo.com.").is_err());
480 }
481
482 #[test]
483 fn handle_invalid_passthrough() {
484 assert!(Handle::<&str>::new("handle.invalid").is_ok());
485 assert!(Handle::<SmolStr>::new_owned("handle.invalid").is_ok());
486 }
487
488 #[test]
489 fn into_static_borrowed() {
490 let h = Handle::<&str>::new("alice.test").unwrap();
491 let owned: Handle<SmolStr> = h.into_static();
492 assert_eq!(owned.as_str(), "alice.test");
493 }
494
495 #[test]
496 fn into_static_already_owned() {
497 let h = Handle::<SmolStr>::new_owned("alice.test").unwrap();
498 let owned: Handle<SmolStr> = h.into_static();
499 assert_eq!(owned.as_str(), "alice.test");
500 }
501
502 #[test]
503 fn case_insensitive_equality() {
504 let lower = Handle::<SmolStr>::new_owned("alice.test").unwrap();
505 let upper = Handle(SmolStr::new("Alice.Test"));
506 assert_eq!(lower, upper);
507 }
508
509 #[test]
510 fn case_insensitive_hash() {
511 let a = Handle::<SmolStr>::new_owned("alice.test").unwrap();
512 let b = Handle(SmolStr::new("Alice.Test"));
513 assert_eq!(a, b);
514 #[allow(deprecated)]
515 let (mut ha, mut hb) = (core::hash::SipHasher::new(), core::hash::SipHasher::new());
516 a.hash(&mut ha);
517 b.hash(&mut hb);
518 assert_eq!(ha.finish(), hb.finish());
519 }
520
521 #[test]
522 fn display_lowercases() {
523 let h = Handle(SmolStr::new("Alice.Test"));
524 assert_eq!(format!("{h}"), "alice.test");
525 }
526
527 #[test]
528 fn serialize_lowercases() {
529 let h = Handle(SmolStr::new("Alice.Test"));
530 let json = serde_json::to_string(&h).unwrap();
531 assert_eq!(json, "\"alice.test\"");
532 }
533
534 #[test]
535 fn cross_type_equality() {
536 let borrowed = Handle::<&str>::new("alice.test").unwrap();
537 let owned = Handle::<SmolStr>::new_owned("alice.test").unwrap();
538 assert_eq!(borrowed, owned);
539 }
540}