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
125// ── Email ─────────────────────────────────────────────────────────────────────
126
127/// Email address with basic structural validation.
128///
129/// Checks: exactly one `@`, non-empty local part and domain, domain contains
130/// at least one `.`, domain labels are non-empty, no whitespace. Not a full
131/// RFC 5321 validator.
132#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub struct Email(String);
134
135impl Email {
136    /// Creates a new `Email`. Returns `Invalid` if the value fails structural checks.
137    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    /// Returns the underlying email string slice.
151    pub fn as_str(&self) -> &str {
152        &self.0
153    }
154
155    /// Consumes the wrapper and returns the inner string.
156    pub fn into_inner(self) -> String {
157        self.0
158    }
159
160    /// Returns the local part (before `@`).
161    pub fn local(&self) -> &str {
162        self.0.split('@').next().unwrap_or("")
163    }
164
165    /// Returns the domain part (after `@`).
166    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// ── HttpUrl ───────────────────────────────────────────────────────────────────
244
245/// HTTP or HTTPS URL with scheme validation.
246///
247/// Must start with `http://` or `https://` and have a non-empty host.
248#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
249pub struct HttpUrl(String);
250
251impl HttpUrl {
252    /// Creates a new `HttpUrl`. Returns `Invalid` if the scheme is missing or
253    /// the host is empty.
254    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    /// Returns the underlying URL string slice.
284    pub fn as_str(&self) -> &str {
285        &self.0
286    }
287
288    /// Consumes the wrapper and returns the inner string.
289    pub fn into_inner(self) -> String {
290        self.0
291    }
292
293    /// Returns `true` if the URL uses `https`.
294    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// ── HexString ─────────────────────────────────────────────────────────────────
352
353/// String of valid hexadecimal characters, with optional `0x`/`0X` prefix.
354#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
355pub struct HexString(String);
356
357impl HexString {
358    /// Creates a new `HexString`. Returns `Invalid` if any character is not a
359    /// valid hex digit (after stripping an optional `0x`/`0X` prefix).
360    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    /// Returns the underlying hex string slice.
383    pub fn as_str(&self) -> &str {
384        &self.0
385    }
386
387    /// Consumes the wrapper and returns the inner string.
388    pub fn into_inner(self) -> String {
389        self.0
390    }
391
392    /// Returns `true` if the value was stored with a `0x`/`0X` prefix.
393    pub fn has_prefix(&self) -> bool {
394        self.0.starts_with("0x") || self.0.starts_with("0X")
395    }
396
397    /// Returns only the hex digit characters, without any `0x`/`0X` prefix.
398    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// ── Tests ─────────────────────────────────────────────────────────────────────
459
460#[cfg(test)]
461mod tests {
462    use super::{Email, HexString, HttpUrl, Slug};
463    use crate::PrimitiveError;
464
465    // Slug
466    #[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    // Email
525    #[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    // HttpUrl
600    #[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    // HexString
661    #[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}