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
125#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub struct Email(String);
134
135impl Email {
136 pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
138 let value = value.into();
139 if value.is_empty() {
140 return Err(PrimitiveError::Empty);
141 }
142 if !is_valid_email(&value) {
143 return Err(PrimitiveError::Invalid {
144 message: "invalid email address",
145 });
146 }
147 Ok(Self(value))
148 }
149
150 pub fn as_str(&self) -> &str {
152 &self.0
153 }
154
155 pub fn into_inner(self) -> String {
157 self.0
158 }
159
160 pub fn local(&self) -> &str {
162 self.0.split('@').next().unwrap_or("")
163 }
164
165 pub fn domain(&self) -> &str {
167 self.0.split('@').nth(1).unwrap_or("")
168 }
169}
170
171fn is_valid_email(s: &str) -> bool {
172 if s.chars().any(|c| c.is_whitespace()) {
173 return false;
174 }
175 let at_count = s.chars().filter(|&c| c == '@').count();
176 if at_count != 1 {
177 return false;
178 }
179 let mut parts = s.splitn(2, '@');
180 let local = parts.next().unwrap_or("");
181 let domain = parts.next().unwrap_or("");
182 if local.is_empty() || domain.is_empty() {
183 return false;
184 }
185 if !domain.contains('.') || domain.split('.').any(str::is_empty) {
186 return false;
187 }
188 true
189}
190
191impl fmt::Display for Email {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 f.write_str(&self.0)
194 }
195}
196
197impl AsRef<str> for Email {
198 fn as_ref(&self) -> &str {
199 self.as_str()
200 }
201}
202
203impl TryFrom<&str> for Email {
204 type Error = PrimitiveError;
205
206 fn try_from(value: &str) -> Result<Self, Self::Error> {
207 Self::new(value)
208 }
209}
210
211impl FromStr for Email {
212 type Err = PrimitiveError;
213
214 fn from_str(s: &str) -> Result<Self, Self::Err> {
215 Self::new(s)
216 }
217}
218
219impl PartialEq<str> for Email {
220 fn eq(&self, other: &str) -> bool {
221 self.as_str() == other
222 }
223}
224
225impl PartialEq<&str> for Email {
226 fn eq(&self, other: &&str) -> bool {
227 self.as_str() == *other
228 }
229}
230
231impl PartialEq<String> for Email {
232 fn eq(&self, other: &String) -> bool {
233 self.as_str() == other.as_str()
234 }
235}
236
237impl PartialEq<&String> for Email {
238 fn eq(&self, other: &&String) -> bool {
239 self.as_str() == other.as_str()
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
249pub struct HttpUrl(String);
250
251impl HttpUrl {
252 pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
255 let value = value.into();
256 if value.is_empty() {
257 return Err(PrimitiveError::Empty);
258 }
259 let lower = value.to_lowercase();
260 let after_scheme = if let Some(rest) = lower.strip_prefix("https://") {
261 rest
262 } else if let Some(rest) = lower.strip_prefix("http://") {
263 rest
264 } else {
265 return Err(PrimitiveError::Invalid {
266 message: "URL must start with http:// or https://",
267 });
268 };
269 let host = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
270 if host.is_empty() || host.chars().all(|c| c.is_whitespace()) {
271 return Err(PrimitiveError::Invalid {
272 message: "URL must have a non-empty host",
273 });
274 }
275 if after_scheme.chars().any(|c| c.is_whitespace()) {
276 return Err(PrimitiveError::Invalid {
277 message: "URL must not contain whitespace",
278 });
279 }
280 Ok(Self(value))
281 }
282
283 pub fn as_str(&self) -> &str {
285 &self.0
286 }
287
288 pub fn into_inner(self) -> String {
290 self.0
291 }
292
293 pub fn is_https(&self) -> bool {
295 self.0.len() >= 8 && self.0[..8].eq_ignore_ascii_case("https://")
296 }
297}
298
299impl fmt::Display for HttpUrl {
300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 f.write_str(&self.0)
302 }
303}
304
305impl AsRef<str> for HttpUrl {
306 fn as_ref(&self) -> &str {
307 self.as_str()
308 }
309}
310
311impl TryFrom<&str> for HttpUrl {
312 type Error = PrimitiveError;
313
314 fn try_from(value: &str) -> Result<Self, Self::Error> {
315 Self::new(value)
316 }
317}
318
319impl FromStr for HttpUrl {
320 type Err = PrimitiveError;
321
322 fn from_str(s: &str) -> Result<Self, Self::Err> {
323 Self::new(s)
324 }
325}
326
327impl PartialEq<str> for HttpUrl {
328 fn eq(&self, other: &str) -> bool {
329 self.as_str() == other
330 }
331}
332
333impl PartialEq<&str> for HttpUrl {
334 fn eq(&self, other: &&str) -> bool {
335 self.as_str() == *other
336 }
337}
338
339impl PartialEq<String> for HttpUrl {
340 fn eq(&self, other: &String) -> bool {
341 self.as_str() == other.as_str()
342 }
343}
344
345impl PartialEq<&String> for HttpUrl {
346 fn eq(&self, other: &&String) -> bool {
347 self.as_str() == other.as_str()
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
355pub struct HexString(String);
356
357impl HexString {
358 pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
361 let value = value.into();
362 if value.is_empty() {
363 return Err(PrimitiveError::Empty);
364 }
365 let hex_part = value
366 .strip_prefix("0x")
367 .or_else(|| value.strip_prefix("0X"))
368 .unwrap_or(&value);
369 if hex_part.is_empty() {
370 return Err(PrimitiveError::Invalid {
371 message: "hex string must not be empty after prefix",
372 });
373 }
374 if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
375 return Err(PrimitiveError::Invalid {
376 message: "hex string must contain only hexadecimal characters (0-9, a-f, A-F)",
377 });
378 }
379 Ok(Self(value))
380 }
381
382 pub fn as_str(&self) -> &str {
384 &self.0
385 }
386
387 pub fn into_inner(self) -> String {
389 self.0
390 }
391
392 pub fn has_prefix(&self) -> bool {
394 self.0.starts_with("0x") || self.0.starts_with("0X")
395 }
396
397 pub fn hex_digits(&self) -> &str {
399 self.0
400 .strip_prefix("0x")
401 .or_else(|| self.0.strip_prefix("0X"))
402 .unwrap_or(&self.0)
403 }
404}
405
406impl fmt::Display for HexString {
407 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408 f.write_str(&self.0)
409 }
410}
411
412impl AsRef<str> for HexString {
413 fn as_ref(&self) -> &str {
414 self.as_str()
415 }
416}
417
418impl TryFrom<&str> for HexString {
419 type Error = PrimitiveError;
420
421 fn try_from(value: &str) -> Result<Self, Self::Error> {
422 Self::new(value)
423 }
424}
425
426impl FromStr for HexString {
427 type Err = PrimitiveError;
428
429 fn from_str(s: &str) -> Result<Self, Self::Err> {
430 Self::new(s)
431 }
432}
433
434impl PartialEq<str> for HexString {
435 fn eq(&self, other: &str) -> bool {
436 self.as_str() == other
437 }
438}
439
440impl PartialEq<&str> for HexString {
441 fn eq(&self, other: &&str) -> bool {
442 self.as_str() == *other
443 }
444}
445
446impl PartialEq<String> for HexString {
447 fn eq(&self, other: &String) -> bool {
448 self.as_str() == other.as_str()
449 }
450}
451
452impl PartialEq<&String> for HexString {
453 fn eq(&self, other: &&String) -> bool {
454 self.as_str() == other.as_str()
455 }
456}
457
458#[cfg(test)]
461mod tests {
462 use super::{Email, HexString, HttpUrl, Slug};
463 use crate::PrimitiveError;
464
465 #[test]
467 fn slug_accepts_valid() {
468 assert_eq!(Slug::new("my-service").unwrap().as_str(), "my-service");
469 assert_eq!(Slug::new("api-v2").unwrap().as_str(), "api-v2");
470 assert_eq!(Slug::new("user123").unwrap().as_str(), "user123");
471 }
472
473 #[test]
474 fn slug_rejects_empty() {
475 assert_eq!(Slug::new("").unwrap_err(), PrimitiveError::Empty);
476 }
477
478 #[test]
479 fn slug_rejects_uppercase() {
480 assert!(Slug::new("MySlug").is_err());
481 }
482
483 #[test]
484 fn slug_rejects_leading_hyphen() {
485 assert!(Slug::new("-bad").is_err());
486 }
487
488 #[test]
489 fn slug_rejects_trailing_hyphen() {
490 assert!(Slug::new("bad-").is_err());
491 }
492
493 #[test]
494 fn slug_rejects_consecutive_hyphens() {
495 assert!(Slug::new("bad--slug").is_err());
496 }
497
498 #[test]
499 fn slug_rejects_spaces() {
500 assert!(Slug::new("has space").is_err());
501 }
502
503 #[test]
504 fn slug_display() {
505 use alloc::string::ToString;
506 assert_eq!(Slug::new("hello").unwrap().to_string(), "hello");
507 }
508
509 #[test]
510 fn slug_deref() {
511 let s = Slug::new("hello").unwrap();
512 assert_eq!(&*s, "hello");
513 }
514
515 #[test]
516 fn slug_from_str_and_string_comparisons() {
517 let slug = "hello".parse::<Slug>().unwrap();
518 let owned = String::from("hello");
519 assert_eq!(slug, "hello");
520 assert_eq!(slug, owned);
521 assert!("Hello".parse::<Slug>().is_err());
522 }
523
524 #[test]
526 fn email_accepts_valid() {
527 let e = Email::new("user@example.com").unwrap();
528 assert_eq!(e.local(), "user");
529 assert_eq!(e.domain(), "example.com");
530 }
531
532 #[test]
533 fn email_rejects_empty() {
534 assert_eq!(Email::new("").unwrap_err(), PrimitiveError::Empty);
535 }
536
537 #[test]
538 fn email_rejects_missing_at() {
539 assert!(Email::new("nodomain").is_err());
540 }
541
542 #[test]
543 fn email_rejects_multiple_at() {
544 assert!(Email::new("a@b@c.com").is_err());
545 }
546
547 #[test]
548 fn email_rejects_no_dot_in_domain() {
549 assert!(Email::new("user@nodot").is_err());
550 }
551
552 #[test]
553 fn email_rejects_empty_domain_labels() {
554 assert!(Email::new("user@example..com").is_err());
555 assert!(Email::new("user@.example.com").is_err());
556 assert!(Email::new("user@example.com.").is_err());
557 }
558
559 #[test]
560 fn email_rejects_spaces() {
561 assert!(Email::new("us er@example.com").is_err());
562 }
563
564 #[test]
565 fn email_rejects_tab() {
566 assert!(Email::new("user\t@example.com").is_err());
567 }
568
569 #[test]
570 fn email_rejects_newline() {
571 assert!(Email::new("user\n@example.com").is_err());
572 }
573
574 #[test]
575 fn url_rejects_whitespace_host() {
576 assert!(HttpUrl::new("http:// ").is_err());
577 }
578
579 #[test]
580 fn url_rejects_whitespace_in_path() {
581 assert!(HttpUrl::new("https://ex ample.com").is_err());
582 }
583
584 #[test]
585 fn email_display() {
586 use alloc::string::ToString;
587 assert_eq!(Email::new("a@b.com").unwrap().to_string(), "a@b.com");
588 }
589
590 #[test]
591 fn email_from_str_and_string_comparisons() {
592 let email = "a@b.com".parse::<Email>().unwrap();
593 let owned = String::from("a@b.com");
594 assert_eq!(email, "a@b.com");
595 assert_eq!(email, owned);
596 assert!("bad".parse::<Email>().is_err());
597 }
598
599 #[test]
601 fn url_accepts_http() {
602 let u = HttpUrl::new("http://example.com").unwrap();
603 assert!(!u.is_https());
604 }
605
606 #[test]
607 fn url_accepts_https() {
608 let u = HttpUrl::new("https://example.com/path").unwrap();
609 assert!(u.is_https());
610 }
611
612 #[test]
613 fn url_rejects_empty() {
614 assert_eq!(HttpUrl::new("").unwrap_err(), PrimitiveError::Empty);
615 }
616
617 #[test]
618 fn url_rejects_missing_scheme() {
619 assert!(HttpUrl::new("ftp://example.com").is_err());
620 }
621
622 #[test]
623 fn url_rejects_empty_host() {
624 assert!(HttpUrl::new("https://").is_err());
625 }
626
627 #[test]
628 fn url_rejects_missing_host_before_path() {
629 assert!(HttpUrl::new("https:///path").is_err());
630 }
631
632 #[test]
633 fn url_display() {
634 use alloc::string::ToString;
635 let u = HttpUrl::new("https://example.com").unwrap();
636 assert_eq!(u.to_string(), "https://example.com");
637 }
638
639 #[test]
640 fn url_is_https_uppercase_scheme() {
641 let u = HttpUrl::new("HTTPS://example.com").unwrap();
642 assert!(u.is_https());
643 }
644
645 #[test]
646 fn url_is_http_not_https() {
647 let u = HttpUrl::new("http://example.com").unwrap();
648 assert!(!u.is_https());
649 }
650
651 #[test]
652 fn url_from_str_and_string_comparisons() {
653 let url = "https://example.com".parse::<HttpUrl>().unwrap();
654 let owned = String::from("https://example.com");
655 assert_eq!(url, "https://example.com");
656 assert_eq!(url, owned);
657 assert!("ftp://example.com".parse::<HttpUrl>().is_err());
658 }
659
660 #[test]
662 fn hex_accepts_plain() {
663 let h = HexString::new("deadbeef").unwrap();
664 assert_eq!(h.hex_digits(), "deadbeef");
665 assert!(!h.has_prefix());
666 }
667
668 #[test]
669 fn hex_accepts_prefixed() {
670 let h = HexString::new("0xdeadbeef").unwrap();
671 assert_eq!(h.hex_digits(), "deadbeef");
672 assert!(h.has_prefix());
673 }
674
675 #[test]
676 fn hex_accepts_uppercase() {
677 assert!(HexString::new("DEADBEEF").is_ok());
678 }
679
680 #[test]
681 fn hex_rejects_empty() {
682 assert_eq!(HexString::new("").unwrap_err(), PrimitiveError::Empty);
683 }
684
685 #[test]
686 fn hex_rejects_prefix_only() {
687 assert!(HexString::new("0x").is_err());
688 }
689
690 #[test]
691 fn hex_rejects_invalid_chars() {
692 assert!(HexString::new("xyz").is_err());
693 }
694
695 #[test]
696 fn hex_display() {
697 use alloc::string::ToString;
698 assert_eq!(HexString::new("ff00").unwrap().to_string(), "ff00");
699 }
700
701 #[test]
702 fn hex_from_str_and_string_comparisons() {
703 let hex = "ff00".parse::<HexString>().unwrap();
704 let owned = String::from("ff00");
705 assert_eq!(hex, "ff00");
706 assert_eq!(hex, owned);
707 assert!("xyz".parse::<HexString>().is_err());
708 }
709}