1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::String;
3use core::{fmt, ops::Deref, str::FromStr};
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct Slug(String);
13
14impl Slug {
15 pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
17 let value = value.into();
18 if value.is_empty() {
19 return Err(PrimitiveError::Empty);
20 }
21 if !is_valid_slug(&value) {
22 return Err(PrimitiveError::Invalid {
23 message: "slug must be lowercase alphanumeric with hyphens, must not start or end with a hyphen, and must not contain consecutive hyphens",
24 });
25 }
26 Ok(Self(value))
27 }
28
29 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33
34 pub fn into_inner(self) -> String {
36 self.0
37 }
38}
39
40fn is_valid_slug(s: &str) -> bool {
41 if s.starts_with('-') || s.ends_with('-') {
42 return false;
43 }
44 let mut prev = ' ';
45 for c in s.chars() {
46 if !matches!(c, 'a'..='z' | '0'..='9' | '-') {
47 return false;
48 }
49 if c == '-' && prev == '-' {
50 return false;
51 }
52 prev = c;
53 }
54 true
55}
56
57impl fmt::Display for Slug {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63impl AsRef<str> for Slug {
64 fn as_ref(&self) -> &str {
65 self.as_str()
66 }
67}
68
69impl Deref for Slug {
70 type Target = str;
71
72 fn deref(&self) -> &Self::Target {
73 self.as_str()
74 }
75}
76
77impl TryFrom<&str> for Slug {
78 type Error = PrimitiveError;
79
80 fn try_from(value: &str) -> Result<Self, Self::Error> {
81 Self::new(value)
82 }
83}
84
85impl TryFrom<String> for Slug {
86 type Error = PrimitiveError;
87
88 fn try_from(value: String) -> Result<Self, Self::Error> {
89 Self::new(value)
90 }
91}
92
93impl FromStr for Slug {
94 type Err = PrimitiveError;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 Self::new(s)
98 }
99}
100
101impl PartialEq<str> for Slug {
102 fn eq(&self, other: &str) -> bool {
103 self.as_str() == other
104 }
105}
106
107impl PartialEq<&str> for Slug {
108 fn eq(&self, other: &&str) -> bool {
109 self.as_str() == *other
110 }
111}
112
113impl PartialEq<String> for Slug {
114 fn eq(&self, other: &String) -> bool {
115 self.as_str() == other.as_str()
116 }
117}
118
119impl PartialEq<&String> for Slug {
120 fn eq(&self, other: &&String) -> bool {
121 self.as_str() == other.as_str()
122 }
123}
124
125impl From<Slug> for String {
126 fn from(value: Slug) -> Self {
127 value.into_inner()
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
139pub struct Email(String);
140
141impl Email {
142 pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
144 let value = value.into();
145 if value.is_empty() {
146 return Err(PrimitiveError::Empty);
147 }
148 if !is_valid_email(&value) {
149 return Err(PrimitiveError::Invalid {
150 message: "invalid email address",
151 });
152 }
153 Ok(Self(value))
154 }
155
156 pub fn as_str(&self) -> &str {
158 &self.0
159 }
160
161 pub fn into_inner(self) -> String {
163 self.0
164 }
165
166 pub fn local(&self) -> &str {
168 self.0.split('@').next().unwrap_or("")
169 }
170
171 pub fn domain(&self) -> &str {
173 self.0.split('@').nth(1).unwrap_or("")
174 }
175}
176
177fn is_valid_email(s: &str) -> bool {
178 if s.chars().any(|c| c.is_whitespace()) {
179 return false;
180 }
181 let at_count = s.chars().filter(|&c| c == '@').count();
182 if at_count != 1 {
183 return false;
184 }
185 let mut parts = s.splitn(2, '@');
186 let local = parts.next().unwrap_or("");
187 let domain = parts.next().unwrap_or("");
188 if local.is_empty() || domain.is_empty() {
189 return false;
190 }
191 if !domain.contains('.') || domain.split('.').any(str::is_empty) {
192 return false;
193 }
194 true
195}
196
197impl fmt::Display for Email {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 f.write_str(&self.0)
200 }
201}
202
203impl AsRef<str> for Email {
204 fn as_ref(&self) -> &str {
205 self.as_str()
206 }
207}
208
209impl Deref for Email {
210 type Target = str;
211
212 fn deref(&self) -> &Self::Target {
213 self.as_str()
214 }
215}
216
217impl TryFrom<&str> for Email {
218 type Error = PrimitiveError;
219
220 fn try_from(value: &str) -> Result<Self, Self::Error> {
221 Self::new(value)
222 }
223}
224
225impl TryFrom<String> for Email {
226 type Error = PrimitiveError;
227
228 fn try_from(value: String) -> Result<Self, Self::Error> {
229 Self::new(value)
230 }
231}
232
233impl FromStr for Email {
234 type Err = PrimitiveError;
235
236 fn from_str(s: &str) -> Result<Self, Self::Err> {
237 Self::new(s)
238 }
239}
240
241impl PartialEq<str> for Email {
242 fn eq(&self, other: &str) -> bool {
243 self.as_str() == other
244 }
245}
246
247impl PartialEq<&str> for Email {
248 fn eq(&self, other: &&str) -> bool {
249 self.as_str() == *other
250 }
251}
252
253impl PartialEq<String> for Email {
254 fn eq(&self, other: &String) -> bool {
255 self.as_str() == other.as_str()
256 }
257}
258
259impl PartialEq<&String> for Email {
260 fn eq(&self, other: &&String) -> bool {
261 self.as_str() == other.as_str()
262 }
263}
264
265impl From<Email> for String {
266 fn from(value: Email) -> Self {
267 value.into_inner()
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
277pub struct HttpUrl(String);
278
279impl HttpUrl {
280 pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
283 let value = value.into();
284 if value.is_empty() {
285 return Err(PrimitiveError::Empty);
286 }
287 let after_scheme = strip_http_scheme(&value).ok_or(PrimitiveError::Invalid {
288 message: "URL must start with http:// or https://",
289 })?;
290 let host = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
291 if host.is_empty() || host.chars().all(|c| c.is_whitespace()) {
292 return Err(PrimitiveError::Invalid {
293 message: "URL must have a non-empty host",
294 });
295 }
296 if after_scheme.chars().any(|c| c.is_whitespace()) {
297 return Err(PrimitiveError::Invalid {
298 message: "URL must not contain whitespace",
299 });
300 }
301 Ok(Self(value))
302 }
303
304 pub fn as_str(&self) -> &str {
306 &self.0
307 }
308
309 pub fn into_inner(self) -> String {
311 self.0
312 }
313
314 pub fn is_https(&self) -> bool {
316 self.0.len() >= 8 && self.0[..8].eq_ignore_ascii_case("https://")
317 }
318}
319
320fn strip_http_scheme(value: &str) -> Option<&str> {
321 if value
322 .as_bytes()
323 .get(..8)
324 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"https://"))
325 {
326 Some(&value[8..])
327 } else if value
328 .as_bytes()
329 .get(..7)
330 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"http://"))
331 {
332 Some(&value[7..])
333 } else {
334 None
335 }
336}
337
338impl fmt::Display for HttpUrl {
339 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340 f.write_str(&self.0)
341 }
342}
343
344impl AsRef<str> for HttpUrl {
345 fn as_ref(&self) -> &str {
346 self.as_str()
347 }
348}
349
350impl Deref for HttpUrl {
351 type Target = str;
352
353 fn deref(&self) -> &Self::Target {
354 self.as_str()
355 }
356}
357
358impl TryFrom<&str> for HttpUrl {
359 type Error = PrimitiveError;
360
361 fn try_from(value: &str) -> Result<Self, Self::Error> {
362 Self::new(value)
363 }
364}
365
366impl TryFrom<String> for HttpUrl {
367 type Error = PrimitiveError;
368
369 fn try_from(value: String) -> Result<Self, Self::Error> {
370 Self::new(value)
371 }
372}
373
374impl FromStr for HttpUrl {
375 type Err = PrimitiveError;
376
377 fn from_str(s: &str) -> Result<Self, Self::Err> {
378 Self::new(s)
379 }
380}
381
382impl PartialEq<str> for HttpUrl {
383 fn eq(&self, other: &str) -> bool {
384 self.as_str() == other
385 }
386}
387
388impl PartialEq<&str> for HttpUrl {
389 fn eq(&self, other: &&str) -> bool {
390 self.as_str() == *other
391 }
392}
393
394impl PartialEq<String> for HttpUrl {
395 fn eq(&self, other: &String) -> bool {
396 self.as_str() == other.as_str()
397 }
398}
399
400impl PartialEq<&String> for HttpUrl {
401 fn eq(&self, other: &&String) -> bool {
402 self.as_str() == other.as_str()
403 }
404}
405
406impl From<HttpUrl> for String {
407 fn from(value: HttpUrl) -> Self {
408 value.into_inner()
409 }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
416pub struct HexString(String);
417
418impl HexString {
419 pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
422 let value = value.into();
423 if value.is_empty() {
424 return Err(PrimitiveError::Empty);
425 }
426 let hex_part = value
427 .strip_prefix("0x")
428 .or_else(|| value.strip_prefix("0X"))
429 .unwrap_or(&value);
430 if hex_part.is_empty() {
431 return Err(PrimitiveError::Invalid {
432 message: "hex string must not be empty after prefix",
433 });
434 }
435 if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
436 return Err(PrimitiveError::Invalid {
437 message: "hex string must contain only hexadecimal characters (0-9, a-f, A-F)",
438 });
439 }
440 Ok(Self(value))
441 }
442
443 pub fn as_str(&self) -> &str {
445 &self.0
446 }
447
448 pub fn into_inner(self) -> String {
450 self.0
451 }
452
453 pub fn has_prefix(&self) -> bool {
455 self.0.starts_with("0x") || self.0.starts_with("0X")
456 }
457
458 pub fn hex_digits(&self) -> &str {
460 self.0
461 .strip_prefix("0x")
462 .or_else(|| self.0.strip_prefix("0X"))
463 .unwrap_or(&self.0)
464 }
465}
466
467impl fmt::Display for HexString {
468 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469 f.write_str(&self.0)
470 }
471}
472
473impl AsRef<str> for HexString {
474 fn as_ref(&self) -> &str {
475 self.as_str()
476 }
477}
478
479impl Deref for HexString {
480 type Target = str;
481
482 fn deref(&self) -> &Self::Target {
483 self.as_str()
484 }
485}
486
487impl TryFrom<&str> for HexString {
488 type Error = PrimitiveError;
489
490 fn try_from(value: &str) -> Result<Self, Self::Error> {
491 Self::new(value)
492 }
493}
494
495impl TryFrom<String> for HexString {
496 type Error = PrimitiveError;
497
498 fn try_from(value: String) -> Result<Self, Self::Error> {
499 Self::new(value)
500 }
501}
502
503impl FromStr for HexString {
504 type Err = PrimitiveError;
505
506 fn from_str(s: &str) -> Result<Self, Self::Err> {
507 Self::new(s)
508 }
509}
510
511impl PartialEq<str> for HexString {
512 fn eq(&self, other: &str) -> bool {
513 self.as_str() == other
514 }
515}
516
517impl PartialEq<&str> for HexString {
518 fn eq(&self, other: &&str) -> bool {
519 self.as_str() == *other
520 }
521}
522
523impl PartialEq<String> for HexString {
524 fn eq(&self, other: &String) -> bool {
525 self.as_str() == other.as_str()
526 }
527}
528
529impl PartialEq<&String> for HexString {
530 fn eq(&self, other: &&String) -> bool {
531 self.as_str() == other.as_str()
532 }
533}
534
535impl From<HexString> for String {
536 fn from(value: HexString) -> Self {
537 value.into_inner()
538 }
539}
540
541#[cfg(test)]
544mod tests {
545 use super::{Email, HexString, HttpUrl, Slug};
546 use crate::PrimitiveError;
547
548 #[test]
550 fn slug_accepts_valid() {
551 assert_eq!(Slug::new("my-service").unwrap().as_str(), "my-service");
552 assert_eq!(Slug::new("api-v2").unwrap().as_str(), "api-v2");
553 assert_eq!(Slug::new("user123").unwrap().as_str(), "user123");
554 }
555
556 #[test]
557 fn slug_rejects_empty() {
558 assert_eq!(Slug::new("").unwrap_err(), PrimitiveError::Empty);
559 }
560
561 #[test]
562 fn slug_rejects_uppercase() {
563 assert!(Slug::new("MySlug").is_err());
564 }
565
566 #[test]
567 fn slug_rejects_leading_hyphen() {
568 assert!(Slug::new("-bad").is_err());
569 }
570
571 #[test]
572 fn slug_rejects_trailing_hyphen() {
573 assert!(Slug::new("bad-").is_err());
574 }
575
576 #[test]
577 fn slug_rejects_consecutive_hyphens() {
578 assert!(Slug::new("bad--slug").is_err());
579 }
580
581 #[test]
582 fn slug_rejects_spaces() {
583 assert!(Slug::new("has space").is_err());
584 }
585
586 #[test]
587 fn slug_display() {
588 use alloc::string::ToString;
589 assert_eq!(Slug::new("hello").unwrap().to_string(), "hello");
590 }
591
592 #[test]
593 fn slug_deref() {
594 let s = Slug::new("hello").unwrap();
595 assert_eq!(&*s, "hello");
596 }
597
598 #[test]
599 fn slug_from_str_and_string_comparisons() {
600 let slug = "hello".parse::<Slug>().unwrap();
601 let owned = String::from("hello");
602 assert_eq!(slug, "hello");
603 assert_eq!(slug, owned);
604 assert!("Hello".parse::<Slug>().is_err());
605 }
606
607 #[test]
608 fn slug_converts_into_string() {
609 let slug = Slug::new("hello").unwrap();
610 let inner = String::from(slug);
611 assert_eq!(inner, "hello");
612 }
613
614 #[test]
616 fn email_accepts_valid() {
617 let e = Email::new("user@example.com").unwrap();
618 assert_eq!(e.local(), "user");
619 assert_eq!(e.domain(), "example.com");
620 }
621
622 #[test]
623 fn email_rejects_empty() {
624 assert_eq!(Email::new("").unwrap_err(), PrimitiveError::Empty);
625 }
626
627 #[test]
628 fn email_rejects_missing_at() {
629 assert!(Email::new("nodomain").is_err());
630 }
631
632 #[test]
633 fn email_rejects_multiple_at() {
634 assert!(Email::new("a@b@c.com").is_err());
635 }
636
637 #[test]
638 fn email_rejects_no_dot_in_domain() {
639 assert!(Email::new("user@nodot").is_err());
640 }
641
642 #[test]
643 fn email_rejects_empty_domain_labels() {
644 assert!(Email::new("user@example..com").is_err());
645 assert!(Email::new("user@.example.com").is_err());
646 assert!(Email::new("user@example.com.").is_err());
647 }
648
649 #[test]
650 fn email_rejects_spaces() {
651 assert!(Email::new("us er@example.com").is_err());
652 }
653
654 #[test]
655 fn email_rejects_tab() {
656 assert!(Email::new("user\t@example.com").is_err());
657 }
658
659 #[test]
660 fn email_rejects_newline() {
661 assert!(Email::new("user\n@example.com").is_err());
662 }
663
664 #[test]
665 fn url_rejects_whitespace_host() {
666 assert!(HttpUrl::new("http:// ").is_err());
667 }
668
669 #[test]
670 fn url_rejects_whitespace_in_path() {
671 assert!(HttpUrl::new("https://ex ample.com").is_err());
672 }
673
674 #[test]
675 fn email_display() {
676 use alloc::string::ToString;
677 assert_eq!(Email::new("a@b.com").unwrap().to_string(), "a@b.com");
678 }
679
680 #[test]
681 fn email_from_str_and_string_comparisons() {
682 let email = "a@b.com".parse::<Email>().unwrap();
683 let owned = String::from("a@b.com");
684 assert_eq!(email, "a@b.com");
685 assert_eq!(email, owned);
686 assert!("bad".parse::<Email>().is_err());
687 }
688
689 #[test]
690 fn email_string_ergonomics() {
691 let email = Email::try_from(String::from("a@b.com")).unwrap();
692 let borrowed: &str = email.as_ref();
693 assert_eq!(borrowed, "a@b.com");
694 assert_eq!(&*email, "a@b.com");
695
696 let inner = String::from(email);
697 assert_eq!(inner, "a@b.com");
698 }
699
700 #[test]
702 fn url_accepts_http() {
703 let u = HttpUrl::new("http://example.com").unwrap();
704 assert!(!u.is_https());
705 }
706
707 #[test]
708 fn url_accepts_https() {
709 let u = HttpUrl::new("https://example.com/path").unwrap();
710 assert!(u.is_https());
711 }
712
713 #[test]
714 fn url_rejects_empty() {
715 assert_eq!(HttpUrl::new("").unwrap_err(), PrimitiveError::Empty);
716 }
717
718 #[test]
719 fn url_rejects_missing_scheme() {
720 assert!(HttpUrl::new("ftp://example.com").is_err());
721 }
722
723 #[test]
724 fn url_rejects_empty_host() {
725 assert!(HttpUrl::new("https://").is_err());
726 }
727
728 #[test]
729 fn url_rejects_missing_host_before_path() {
730 assert!(HttpUrl::new("https:///path").is_err());
731 }
732
733 #[test]
734 fn url_display() {
735 use alloc::string::ToString;
736 let u = HttpUrl::new("https://example.com").unwrap();
737 assert_eq!(u.to_string(), "https://example.com");
738 }
739
740 #[test]
741 fn url_is_https_uppercase_scheme() {
742 let u = HttpUrl::new("HTTPS://example.com").unwrap();
743 assert!(u.is_https());
744 }
745
746 #[test]
747 fn url_accepts_uppercase_http_scheme() {
748 let u = HttpUrl::new("HTTP://example.com").unwrap();
749 assert!(!u.is_https());
750 }
751
752 #[test]
753 fn url_is_http_not_https() {
754 let u = HttpUrl::new("http://example.com").unwrap();
755 assert!(!u.is_https());
756 }
757
758 #[test]
759 fn url_from_str_and_string_comparisons() {
760 let url = "https://example.com".parse::<HttpUrl>().unwrap();
761 let owned = String::from("https://example.com");
762 assert_eq!(url, "https://example.com");
763 assert_eq!(url, owned);
764 assert!("ftp://example.com".parse::<HttpUrl>().is_err());
765 }
766
767 #[test]
768 fn url_string_ergonomics() {
769 let url = HttpUrl::try_from(String::from("https://example.com")).unwrap();
770 let borrowed: &str = url.as_ref();
771 assert_eq!(borrowed, "https://example.com");
772 assert_eq!(&*url, "https://example.com");
773
774 let inner = String::from(url);
775 assert_eq!(inner, "https://example.com");
776 }
777
778 #[test]
780 fn hex_accepts_plain() {
781 let h = HexString::new("deadbeef").unwrap();
782 assert_eq!(h.hex_digits(), "deadbeef");
783 assert!(!h.has_prefix());
784 }
785
786 #[test]
787 fn hex_accepts_prefixed() {
788 let h = HexString::new("0xdeadbeef").unwrap();
789 assert_eq!(h.hex_digits(), "deadbeef");
790 assert!(h.has_prefix());
791 }
792
793 #[test]
794 fn hex_accepts_uppercase() {
795 assert!(HexString::new("DEADBEEF").is_ok());
796 }
797
798 #[test]
799 fn hex_rejects_empty() {
800 assert_eq!(HexString::new("").unwrap_err(), PrimitiveError::Empty);
801 }
802
803 #[test]
804 fn hex_rejects_prefix_only() {
805 assert!(HexString::new("0x").is_err());
806 }
807
808 #[test]
809 fn hex_rejects_invalid_chars() {
810 assert!(HexString::new("xyz").is_err());
811 }
812
813 #[test]
814 fn hex_display() {
815 use alloc::string::ToString;
816 assert_eq!(HexString::new("ff00").unwrap().to_string(), "ff00");
817 }
818
819 #[test]
820 fn hex_from_str_and_string_comparisons() {
821 let hex = "ff00".parse::<HexString>().unwrap();
822 let owned = String::from("ff00");
823 assert_eq!(hex, "ff00");
824 assert_eq!(hex, owned);
825 assert!("xyz".parse::<HexString>().is_err());
826 }
827
828 #[test]
829 fn hex_string_ergonomics() {
830 let hex = HexString::try_from(String::from("ff00")).unwrap();
831 let borrowed: &str = hex.as_ref();
832 assert_eq!(borrowed, "ff00");
833 assert_eq!(&*hex, "ff00");
834
835 let inner = String::from(hex);
836 assert_eq!(inner, "ff00");
837 }
838}