Skip to main content

reliakit_primitives/
text.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::String;
3use core::{fmt, ops::Deref, str::FromStr};
4
5// ── Slug ─────────────────────────────────────────────────────────────────────
6
7/// URL-safe slug: lowercase ASCII alphanumeric characters and hyphens.
8///
9/// Rules: non-empty, only `[a-z0-9-]`, does not start or end with `-`,
10/// no consecutive `--`.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct Slug(String);
13
14impl Slug {
15    /// Creates a new `Slug`. Returns `Invalid` if the value violates slug rules.
16    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    /// Returns the underlying slug string slice.
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33
34    /// Consumes the wrapper and returns the inner string.
35    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// ── Email ─────────────────────────────────────────────────────────────────────
132
133/// Email address with basic structural validation.
134///
135/// Checks: exactly one `@`, non-empty local part and domain, domain contains
136/// at least one `.`, domain labels are non-empty, no whitespace. Not a full
137/// RFC 5321 validator.
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
139pub struct Email(String);
140
141impl Email {
142    /// Creates a new `Email`. Returns `Invalid` if the value fails structural checks.
143    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    /// Returns the underlying email string slice.
157    pub fn as_str(&self) -> &str {
158        &self.0
159    }
160
161    /// Consumes the wrapper and returns the inner string.
162    pub fn into_inner(self) -> String {
163        self.0
164    }
165
166    /// Returns the local part (before `@`).
167    pub fn local(&self) -> &str {
168        self.0.split('@').next().unwrap_or("")
169    }
170
171    /// Returns the domain part (after `@`).
172    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// ── HttpUrl ───────────────────────────────────────────────────────────────────
272
273/// HTTP or HTTPS URL with scheme validation.
274///
275/// Must start with `http://` or `https://` and have a non-empty host.
276#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
277pub struct HttpUrl(String);
278
279impl HttpUrl {
280    /// Creates a new `HttpUrl`. Returns `Invalid` if the scheme is missing or
281    /// the host is empty.
282    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    /// Returns the underlying URL string slice.
305    pub fn as_str(&self) -> &str {
306        &self.0
307    }
308
309    /// Consumes the wrapper and returns the inner string.
310    pub fn into_inner(self) -> String {
311        self.0
312    }
313
314    /// Returns `true` if the URL uses `https`.
315    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// ── HexString ─────────────────────────────────────────────────────────────────
413
414/// String of valid hexadecimal characters, with optional `0x`/`0X` prefix.
415#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
416pub struct HexString(String);
417
418impl HexString {
419    /// Creates a new `HexString`. Returns `Invalid` if any character is not a
420    /// valid hex digit (after stripping an optional `0x`/`0X` prefix).
421    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    /// Returns the underlying hex string slice.
444    pub fn as_str(&self) -> &str {
445        &self.0
446    }
447
448    /// Consumes the wrapper and returns the inner string.
449    pub fn into_inner(self) -> String {
450        self.0
451    }
452
453    /// Returns `true` if the value was stored with a `0x`/`0X` prefix.
454    pub fn has_prefix(&self) -> bool {
455        self.0.starts_with("0x") || self.0.starts_with("0X")
456    }
457
458    /// Returns only the hex digit characters, without any `0x`/`0X` prefix.
459    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// ── Tests ─────────────────────────────────────────────────────────────────────
542
543#[cfg(test)]
544mod tests {
545    use super::{Email, HexString, HttpUrl, Slug};
546    use crate::PrimitiveError;
547
548    // Slug
549    #[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    // Email
615    #[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    // HttpUrl
701    #[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    // HexString
779    #[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}