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// ── Base64 ────────────────────────────────────────────────────────────────────
542
543/// Standard (RFC 4648) base64 string with required, correct padding.
544///
545/// Rules: non-empty, length is a multiple of `4`, every non-padding character is
546/// in the standard alphabet (`A-Z`, `a-z`, `0-9`, `+`, `/`), and `=` padding (at
547/// most two) appears only at the end. This is a *format* check; it does not
548/// decode the data. The URL-safe alphabet (`-`/`_`) is not accepted.
549#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
550pub struct Base64(String);
551
552impl Base64 {
553    /// Creates a new `Base64`. Returns an error if the value is empty or is not
554    /// well-formed standard base64 (see the type docs for the exact rules).
555    pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
556        let value = value.into();
557        if value.is_empty() {
558            return Err(PrimitiveError::Empty);
559        }
560        let bytes = value.as_bytes();
561        if bytes.len() % 4 != 0 {
562            return Err(PrimitiveError::Invalid {
563                message: "base64 length must be a multiple of 4",
564            });
565        }
566        let pad = bytes.iter().rev().take_while(|&&b| b == b'=').count();
567        if pad > 2 {
568            return Err(PrimitiveError::Invalid {
569                message: "base64 has at most two padding characters",
570            });
571        }
572        // Every character before the padding must be in the standard alphabet;
573        // because `=` is not in the alphabet, this also rejects interior padding.
574        if !bytes[..bytes.len() - pad]
575            .iter()
576            .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
577        {
578            return Err(PrimitiveError::Invalid {
579                message: "base64 contains a character outside the standard alphabet",
580            });
581        }
582        Ok(Self(value))
583    }
584
585    /// Returns the underlying base64 string slice.
586    pub fn as_str(&self) -> &str {
587        &self.0
588    }
589
590    /// Consumes the wrapper and returns the inner string.
591    pub fn into_inner(self) -> String {
592        self.0
593    }
594
595    /// Returns `true` if the value carries `=` padding.
596    pub fn is_padded(&self) -> bool {
597        self.0.ends_with('=')
598    }
599
600    /// Returns the number of bytes this base64 string decodes to.
601    pub fn decoded_len(&self) -> usize {
602        let pad = self.0.bytes().rev().take_while(|&b| b == b'=').count();
603        self.0.len() / 4 * 3 - pad
604    }
605}
606
607impl fmt::Display for Base64 {
608    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
609        f.write_str(&self.0)
610    }
611}
612
613impl AsRef<str> for Base64 {
614    fn as_ref(&self) -> &str {
615        self.as_str()
616    }
617}
618
619impl Deref for Base64 {
620    type Target = str;
621
622    fn deref(&self) -> &Self::Target {
623        self.as_str()
624    }
625}
626
627impl TryFrom<&str> for Base64 {
628    type Error = PrimitiveError;
629
630    fn try_from(value: &str) -> Result<Self, Self::Error> {
631        Self::new(value)
632    }
633}
634
635impl TryFrom<String> for Base64 {
636    type Error = PrimitiveError;
637
638    fn try_from(value: String) -> Result<Self, Self::Error> {
639        Self::new(value)
640    }
641}
642
643impl FromStr for Base64 {
644    type Err = PrimitiveError;
645
646    fn from_str(s: &str) -> Result<Self, Self::Err> {
647        Self::new(s)
648    }
649}
650
651impl PartialEq<str> for Base64 {
652    fn eq(&self, other: &str) -> bool {
653        self.as_str() == other
654    }
655}
656
657impl PartialEq<&str> for Base64 {
658    fn eq(&self, other: &&str) -> bool {
659        self.as_str() == *other
660    }
661}
662
663impl PartialEq<String> for Base64 {
664    fn eq(&self, other: &String) -> bool {
665        self.as_str() == other.as_str()
666    }
667}
668
669impl PartialEq<&String> for Base64 {
670    fn eq(&self, other: &&String) -> bool {
671        self.as_str() == other.as_str()
672    }
673}
674
675impl From<Base64> for String {
676    fn from(value: Base64) -> Self {
677        value.into_inner()
678    }
679}
680
681// ── Identifier ────────────────────────────────────────────────────────────────
682
683/// A conservative ASCII identifier: a letter or `_`, then letters, digits, or
684/// `_`.
685///
686/// Rules: non-empty, the first character is `[A-Za-z_]`, and every remaining
687/// character is `[A-Za-z0-9_]`. Useful for handles, keys, and machine-generated
688/// names that must be safe across many systems.
689#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
690pub struct Identifier(String);
691
692impl Identifier {
693    /// Creates a new `Identifier`. Returns an error if the value is empty or
694    /// contains a character not allowed at its position (see the type docs).
695    pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
696        let value = value.into();
697        let mut chars = value.chars();
698        match chars.next() {
699            None => return Err(PrimitiveError::Empty),
700            Some(first) if !(first.is_ascii_alphabetic() || first == '_') => {
701                return Err(PrimitiveError::Invalid {
702                    message: "identifier must start with an ASCII letter or underscore",
703                });
704            }
705            Some(_) => {}
706        }
707        if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
708            return Err(PrimitiveError::Invalid {
709                message: "identifier may contain only ASCII letters, digits, and underscores",
710            });
711        }
712        Ok(Self(value))
713    }
714
715    /// Returns the underlying identifier string slice.
716    pub fn as_str(&self) -> &str {
717        &self.0
718    }
719
720    /// Consumes the wrapper and returns the inner string.
721    pub fn into_inner(self) -> String {
722        self.0
723    }
724}
725
726impl fmt::Display for Identifier {
727    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
728        f.write_str(&self.0)
729    }
730}
731
732impl AsRef<str> for Identifier {
733    fn as_ref(&self) -> &str {
734        self.as_str()
735    }
736}
737
738impl Deref for Identifier {
739    type Target = str;
740
741    fn deref(&self) -> &Self::Target {
742        self.as_str()
743    }
744}
745
746impl TryFrom<&str> for Identifier {
747    type Error = PrimitiveError;
748
749    fn try_from(value: &str) -> Result<Self, Self::Error> {
750        Self::new(value)
751    }
752}
753
754impl TryFrom<String> for Identifier {
755    type Error = PrimitiveError;
756
757    fn try_from(value: String) -> Result<Self, Self::Error> {
758        Self::new(value)
759    }
760}
761
762impl FromStr for Identifier {
763    type Err = PrimitiveError;
764
765    fn from_str(s: &str) -> Result<Self, Self::Err> {
766        Self::new(s)
767    }
768}
769
770impl PartialEq<str> for Identifier {
771    fn eq(&self, other: &str) -> bool {
772        self.as_str() == other
773    }
774}
775
776impl PartialEq<&str> for Identifier {
777    fn eq(&self, other: &&str) -> bool {
778        self.as_str() == *other
779    }
780}
781
782impl PartialEq<String> for Identifier {
783    fn eq(&self, other: &String) -> bool {
784        self.as_str() == other.as_str()
785    }
786}
787
788impl PartialEq<&String> for Identifier {
789    fn eq(&self, other: &&String) -> bool {
790        self.as_str() == other.as_str()
791    }
792}
793
794impl From<Identifier> for String {
795    fn from(value: Identifier) -> Self {
796        value.into_inner()
797    }
798}
799
800// ── Hostname ──────────────────────────────────────────────────────────────────
801
802/// A DNS hostname following the RFC 1123 rules.
803///
804/// Rules: non-empty, at most 253 characters total, split into dot-separated
805/// labels where each label is 1–63 characters of `[A-Za-z0-9-]` and does not
806/// start or end with a hyphen. Empty labels (a leading, trailing, or doubled
807/// dot) are rejected. The check is case-preserving and does not resolve the name.
808#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
809pub struct Hostname(String);
810
811impl Hostname {
812    /// Creates a new `Hostname`. Returns an error if the value is empty, too
813    /// long, or has a label that violates the RFC 1123 rules (see the type docs).
814    pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
815        let value = value.into();
816        if value.is_empty() {
817            return Err(PrimitiveError::Empty);
818        }
819        if value.len() > 253 {
820            return Err(PrimitiveError::TooLong {
821                max: 253,
822                actual: value.len(),
823            });
824        }
825        for label in value.split('.') {
826            if label.is_empty() {
827                return Err(PrimitiveError::Invalid {
828                    message: "hostname label must not be empty",
829                });
830            }
831            if label.len() > 63 {
832                return Err(PrimitiveError::Invalid {
833                    message: "hostname label must not exceed 63 characters",
834                });
835            }
836            if label.starts_with('-') || label.ends_with('-') {
837                return Err(PrimitiveError::Invalid {
838                    message: "hostname label must not start or end with a hyphen",
839                });
840            }
841            if !label
842                .bytes()
843                .all(|b| b.is_ascii_alphanumeric() || b == b'-')
844            {
845                return Err(PrimitiveError::Invalid {
846                    message: "hostname label may contain only letters, digits, and hyphens",
847                });
848            }
849        }
850        Ok(Self(value))
851    }
852
853    /// Returns the underlying hostname string slice.
854    pub fn as_str(&self) -> &str {
855        &self.0
856    }
857
858    /// Consumes the wrapper and returns the inner string.
859    pub fn into_inner(self) -> String {
860        self.0
861    }
862
863    /// Iterates over the dot-separated labels, from left to right.
864    pub fn labels(&self) -> impl Iterator<Item = &str> + '_ {
865        self.0.split('.')
866    }
867}
868
869impl fmt::Display for Hostname {
870    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
871        f.write_str(&self.0)
872    }
873}
874
875impl AsRef<str> for Hostname {
876    fn as_ref(&self) -> &str {
877        self.as_str()
878    }
879}
880
881impl Deref for Hostname {
882    type Target = str;
883
884    fn deref(&self) -> &Self::Target {
885        self.as_str()
886    }
887}
888
889impl TryFrom<&str> for Hostname {
890    type Error = PrimitiveError;
891
892    fn try_from(value: &str) -> Result<Self, Self::Error> {
893        Self::new(value)
894    }
895}
896
897impl TryFrom<String> for Hostname {
898    type Error = PrimitiveError;
899
900    fn try_from(value: String) -> Result<Self, Self::Error> {
901        Self::new(value)
902    }
903}
904
905impl FromStr for Hostname {
906    type Err = PrimitiveError;
907
908    fn from_str(s: &str) -> Result<Self, Self::Err> {
909        Self::new(s)
910    }
911}
912
913impl PartialEq<str> for Hostname {
914    fn eq(&self, other: &str) -> bool {
915        self.as_str() == other
916    }
917}
918
919impl PartialEq<&str> for Hostname {
920    fn eq(&self, other: &&str) -> bool {
921        self.as_str() == *other
922    }
923}
924
925impl PartialEq<String> for Hostname {
926    fn eq(&self, other: &String) -> bool {
927        self.as_str() == other.as_str()
928    }
929}
930
931impl PartialEq<&String> for Hostname {
932    fn eq(&self, other: &&String) -> bool {
933        self.as_str() == other.as_str()
934    }
935}
936
937impl From<Hostname> for String {
938    fn from(value: Hostname) -> Self {
939        value.into_inner()
940    }
941}
942
943// ── Tests ─────────────────────────────────────────────────────────────────────
944
945#[cfg(test)]
946mod tests {
947    use super::{Base64, Email, HexString, Hostname, HttpUrl, Identifier, Slug};
948    use crate::{PrimitiveError, PrimitiveErrorKind};
949
950    // Slug
951    #[test]
952    fn slug_accepts_valid() {
953        assert_eq!(Slug::new("my-service").unwrap().as_str(), "my-service");
954        assert_eq!(Slug::new("api-v2").unwrap().as_str(), "api-v2");
955        assert_eq!(Slug::new("user123").unwrap().as_str(), "user123");
956    }
957
958    #[test]
959    fn slug_rejects_empty() {
960        assert_eq!(Slug::new("").unwrap_err(), PrimitiveError::Empty);
961    }
962
963    #[test]
964    fn slug_rejects_uppercase() {
965        assert!(Slug::new("MySlug").is_err());
966    }
967
968    #[test]
969    fn slug_rejects_leading_hyphen() {
970        assert!(Slug::new("-bad").is_err());
971    }
972
973    #[test]
974    fn slug_rejects_trailing_hyphen() {
975        assert!(Slug::new("bad-").is_err());
976    }
977
978    #[test]
979    fn slug_rejects_consecutive_hyphens() {
980        assert!(Slug::new("bad--slug").is_err());
981    }
982
983    #[test]
984    fn slug_rejects_spaces() {
985        assert!(Slug::new("has space").is_err());
986    }
987
988    #[test]
989    fn slug_display() {
990        use alloc::string::ToString;
991        assert_eq!(Slug::new("hello").unwrap().to_string(), "hello");
992    }
993
994    #[test]
995    fn slug_deref() {
996        let s = Slug::new("hello").unwrap();
997        assert_eq!(&*s, "hello");
998    }
999
1000    #[test]
1001    fn slug_from_str_and_string_comparisons() {
1002        let slug = "hello".parse::<Slug>().unwrap();
1003        let owned = String::from("hello");
1004        assert_eq!(slug, "hello");
1005        assert_eq!(slug, owned);
1006        assert!("Hello".parse::<Slug>().is_err());
1007    }
1008
1009    #[test]
1010    fn slug_converts_into_string() {
1011        let slug = Slug::new("hello").unwrap();
1012        let inner = String::from(slug);
1013        assert_eq!(inner, "hello");
1014    }
1015
1016    // Email
1017    #[test]
1018    fn email_accepts_valid() {
1019        let e = Email::new("user@example.com").unwrap();
1020        assert_eq!(e.local(), "user");
1021        assert_eq!(e.domain(), "example.com");
1022    }
1023
1024    #[test]
1025    fn email_rejects_empty() {
1026        assert_eq!(Email::new("").unwrap_err(), PrimitiveError::Empty);
1027    }
1028
1029    #[test]
1030    fn email_rejects_missing_at() {
1031        assert!(Email::new("nodomain").is_err());
1032    }
1033
1034    #[test]
1035    fn email_rejects_multiple_at() {
1036        assert!(Email::new("a@b@c.com").is_err());
1037    }
1038
1039    #[test]
1040    fn email_rejects_no_dot_in_domain() {
1041        assert!(Email::new("user@nodot").is_err());
1042    }
1043
1044    #[test]
1045    fn email_rejects_empty_domain_labels() {
1046        assert!(Email::new("user@example..com").is_err());
1047        assert!(Email::new("user@.example.com").is_err());
1048        assert!(Email::new("user@example.com.").is_err());
1049    }
1050
1051    #[test]
1052    fn email_rejects_spaces() {
1053        assert!(Email::new("us er@example.com").is_err());
1054    }
1055
1056    #[test]
1057    fn email_rejects_tab() {
1058        assert!(Email::new("user\t@example.com").is_err());
1059    }
1060
1061    #[test]
1062    fn email_rejects_newline() {
1063        assert!(Email::new("user\n@example.com").is_err());
1064    }
1065
1066    #[test]
1067    fn url_rejects_whitespace_host() {
1068        assert!(HttpUrl::new("http://   ").is_err());
1069    }
1070
1071    #[test]
1072    fn url_rejects_whitespace_in_path() {
1073        assert!(HttpUrl::new("https://ex ample.com").is_err());
1074    }
1075
1076    #[test]
1077    fn email_display() {
1078        use alloc::string::ToString;
1079        assert_eq!(Email::new("a@b.com").unwrap().to_string(), "a@b.com");
1080    }
1081
1082    #[test]
1083    fn email_from_str_and_string_comparisons() {
1084        let email = "a@b.com".parse::<Email>().unwrap();
1085        let owned = String::from("a@b.com");
1086        assert_eq!(email, "a@b.com");
1087        assert_eq!(email, owned);
1088        assert!("bad".parse::<Email>().is_err());
1089    }
1090
1091    #[test]
1092    fn email_string_ergonomics() {
1093        let email = Email::try_from(String::from("a@b.com")).unwrap();
1094        let borrowed: &str = email.as_ref();
1095        assert_eq!(borrowed, "a@b.com");
1096        assert_eq!(&*email, "a@b.com");
1097
1098        let inner = String::from(email);
1099        assert_eq!(inner, "a@b.com");
1100    }
1101
1102    // HttpUrl
1103    #[test]
1104    fn url_accepts_http() {
1105        let u = HttpUrl::new("http://example.com").unwrap();
1106        assert!(!u.is_https());
1107    }
1108
1109    #[test]
1110    fn url_accepts_https() {
1111        let u = HttpUrl::new("https://example.com/path").unwrap();
1112        assert!(u.is_https());
1113    }
1114
1115    #[test]
1116    fn url_rejects_empty() {
1117        assert_eq!(HttpUrl::new("").unwrap_err(), PrimitiveError::Empty);
1118    }
1119
1120    #[test]
1121    fn url_rejects_missing_scheme() {
1122        assert!(HttpUrl::new("ftp://example.com").is_err());
1123    }
1124
1125    #[test]
1126    fn url_rejects_empty_host() {
1127        assert!(HttpUrl::new("https://").is_err());
1128    }
1129
1130    #[test]
1131    fn url_rejects_missing_host_before_path() {
1132        assert!(HttpUrl::new("https:///path").is_err());
1133    }
1134
1135    #[test]
1136    fn url_display() {
1137        use alloc::string::ToString;
1138        let u = HttpUrl::new("https://example.com").unwrap();
1139        assert_eq!(u.to_string(), "https://example.com");
1140    }
1141
1142    #[test]
1143    fn url_is_https_uppercase_scheme() {
1144        let u = HttpUrl::new("HTTPS://example.com").unwrap();
1145        assert!(u.is_https());
1146    }
1147
1148    #[test]
1149    fn url_accepts_uppercase_http_scheme() {
1150        let u = HttpUrl::new("HTTP://example.com").unwrap();
1151        assert!(!u.is_https());
1152    }
1153
1154    #[test]
1155    fn url_is_http_not_https() {
1156        let u = HttpUrl::new("http://example.com").unwrap();
1157        assert!(!u.is_https());
1158    }
1159
1160    #[test]
1161    fn url_from_str_and_string_comparisons() {
1162        let url = "https://example.com".parse::<HttpUrl>().unwrap();
1163        let owned = String::from("https://example.com");
1164        assert_eq!(url, "https://example.com");
1165        assert_eq!(url, owned);
1166        assert!("ftp://example.com".parse::<HttpUrl>().is_err());
1167    }
1168
1169    #[test]
1170    fn url_string_ergonomics() {
1171        let url = HttpUrl::try_from(String::from("https://example.com")).unwrap();
1172        let borrowed: &str = url.as_ref();
1173        assert_eq!(borrowed, "https://example.com");
1174        assert_eq!(&*url, "https://example.com");
1175
1176        let inner = String::from(url);
1177        assert_eq!(inner, "https://example.com");
1178    }
1179
1180    // HexString
1181    #[test]
1182    fn hex_accepts_plain() {
1183        let h = HexString::new("deadbeef").unwrap();
1184        assert_eq!(h.hex_digits(), "deadbeef");
1185        assert!(!h.has_prefix());
1186    }
1187
1188    #[test]
1189    fn hex_accepts_prefixed() {
1190        let h = HexString::new("0xdeadbeef").unwrap();
1191        assert_eq!(h.hex_digits(), "deadbeef");
1192        assert!(h.has_prefix());
1193    }
1194
1195    #[test]
1196    fn hex_accepts_uppercase() {
1197        assert!(HexString::new("DEADBEEF").is_ok());
1198    }
1199
1200    #[test]
1201    fn hex_rejects_empty() {
1202        assert_eq!(HexString::new("").unwrap_err(), PrimitiveError::Empty);
1203    }
1204
1205    #[test]
1206    fn hex_rejects_prefix_only() {
1207        assert!(HexString::new("0x").is_err());
1208    }
1209
1210    #[test]
1211    fn hex_rejects_invalid_chars() {
1212        assert!(HexString::new("xyz").is_err());
1213    }
1214
1215    #[test]
1216    fn hex_display() {
1217        use alloc::string::ToString;
1218        assert_eq!(HexString::new("ff00").unwrap().to_string(), "ff00");
1219    }
1220
1221    #[test]
1222    fn hex_from_str_and_string_comparisons() {
1223        let hex = "ff00".parse::<HexString>().unwrap();
1224        let owned = String::from("ff00");
1225        assert_eq!(hex, "ff00");
1226        assert_eq!(hex, owned);
1227        assert!("xyz".parse::<HexString>().is_err());
1228    }
1229
1230    #[test]
1231    fn hex_string_ergonomics() {
1232        let hex = HexString::try_from(String::from("ff00")).unwrap();
1233        let borrowed: &str = hex.as_ref();
1234        assert_eq!(borrowed, "ff00");
1235        assert_eq!(&*hex, "ff00");
1236
1237        let inner = String::from(hex);
1238        assert_eq!(inner, "ff00");
1239    }
1240
1241    #[test]
1242    fn base64_accepts_valid() {
1243        assert_eq!(Base64::new("aGVsbG8=").unwrap().as_str(), "aGVsbG8=");
1244        assert!(Base64::new("YWJjZA==").is_ok()); // two pads
1245        assert!(Base64::new("YWJjZGU+").is_ok()); // '+' and no pad
1246        assert!(Base64::new("ab/+ZZ90").is_ok());
1247    }
1248
1249    #[test]
1250    fn base64_rejects_bad() {
1251        assert_eq!(
1252            Base64::new("").unwrap_err().kind(),
1253            PrimitiveErrorKind::Empty
1254        );
1255        assert_eq!(
1256            Base64::new("aGVsbG8").unwrap_err().kind(), // not a multiple of 4
1257            PrimitiveErrorKind::InvalidFormat
1258        );
1259        assert!(Base64::new("ab-_ZZ90").is_err()); // url-safe alphabet
1260        assert!(Base64::new("ab=cZZ90").is_err()); // interior padding
1261        assert!(Base64::new("ab======").is_err()); // too much padding
1262    }
1263
1264    #[test]
1265    fn base64_padding_and_decoded_len() {
1266        let b = Base64::new("aGVsbG8=").unwrap(); // "hello" -> 5 bytes
1267        assert!(b.is_padded());
1268        assert_eq!(b.decoded_len(), 5);
1269        let b = Base64::new("YWJjZA==").unwrap(); // "abcd" -> 4 bytes
1270        assert_eq!(b.decoded_len(), 4);
1271        let b = Base64::new("YWJjZGZn").unwrap(); // 6 bytes, no pad
1272        assert!(!b.is_padded());
1273        assert_eq!(b.decoded_len(), 6);
1274    }
1275
1276    #[test]
1277    fn identifier_accepts_valid() {
1278        assert_eq!(Identifier::new("user_id").unwrap().as_str(), "user_id");
1279        assert!(Identifier::new("_private").is_ok());
1280        assert!(Identifier::new("A1").is_ok());
1281        assert!(Identifier::new("x").is_ok());
1282    }
1283
1284    #[test]
1285    fn identifier_rejects_bad() {
1286        assert_eq!(
1287            Identifier::new("").unwrap_err().kind(),
1288            PrimitiveErrorKind::Empty
1289        );
1290        assert!(Identifier::new("3bad").is_err()); // starts with digit
1291        assert!(Identifier::new("has space").is_err());
1292        assert!(Identifier::new("dash-no").is_err());
1293        assert!(Identifier::new("café").is_err()); // non-ascii
1294    }
1295
1296    #[test]
1297    fn hostname_accepts_valid() {
1298        assert_eq!(
1299            Hostname::new("api.example.com").unwrap().as_str(),
1300            "api.example.com"
1301        );
1302        assert!(Hostname::new("localhost").is_ok());
1303        assert!(Hostname::new("a-b.c-d.example").is_ok());
1304        let h = Hostname::new("api.example.com").unwrap();
1305        let labels: alloc::vec::Vec<&str> = h.labels().collect();
1306        assert_eq!(labels, ["api", "example", "com"]);
1307    }
1308
1309    #[test]
1310    fn hostname_rejects_bad() {
1311        assert_eq!(
1312            Hostname::new("").unwrap_err().kind(),
1313            PrimitiveErrorKind::Empty
1314        );
1315        assert!(Hostname::new("-bad.com").is_err()); // leading hyphen
1316        assert!(Hostname::new("bad-.com").is_err()); // trailing hyphen
1317        assert!(Hostname::new("a..b").is_err()); // empty label
1318        assert!(Hostname::new(".leading").is_err());
1319        assert!(Hostname::new("trailing.").is_err());
1320        assert!(Hostname::new("under_score.com").is_err()); // underscore not allowed
1321        assert!(Hostname::new(String::from("a").repeat(64)).is_err());
1322        let too_long = alloc::format!("{}.com", String::from("a").repeat(252));
1323        assert!(Hostname::new(too_long).is_err());
1324    }
1325
1326    // The owned/reference comparisons below intentionally exercise the
1327    // `PartialEq<String>` and `PartialEq<&String>` impls, which `cmp_owned` and
1328    // `op_ref` would otherwise rewrite away.
1329    #[test]
1330    #[allow(clippy::cmp_owned, clippy::op_ref)]
1331    fn base64_conversions_and_traits() {
1332        let from_str: Base64 = "YWJj".parse().unwrap();
1333        let try_ref = Base64::try_from("YWJj").unwrap();
1334        let try_owned = Base64::try_from(String::from("YWJj")).unwrap();
1335        assert_eq!(from_str, try_ref);
1336        assert_eq!(try_ref, try_owned);
1337
1338        assert_eq!(try_ref.to_string(), "YWJj"); // Display
1339        let as_ref: &str = try_ref.as_ref(); // AsRef
1340        assert_eq!(as_ref, "YWJj");
1341        assert_eq!(&*try_ref, "YWJj"); // Deref
1342        assert!(try_ref == "YWJj"); // PartialEq<&str>
1343        assert!(try_ref == *"YWJj"); // PartialEq<str>
1344        assert!(try_ref == String::from("YWJj")); // PartialEq<String>
1345        assert!(try_ref == &String::from("YWJj")); // PartialEq<&String>
1346        assert_eq!(String::from(try_owned), "YWJj"); // From<Base64> for String
1347    }
1348
1349    #[test]
1350    #[allow(clippy::cmp_owned, clippy::op_ref)]
1351    fn identifier_conversions_and_traits() {
1352        let from_str: Identifier = "user_id".parse().unwrap();
1353        let try_ref = Identifier::try_from("user_id").unwrap();
1354        let try_owned = Identifier::try_from(String::from("user_id")).unwrap();
1355        assert_eq!(from_str, try_ref);
1356        assert_eq!(try_ref, try_owned);
1357
1358        assert_eq!(try_ref.to_string(), "user_id");
1359        let as_ref: &str = try_ref.as_ref();
1360        assert_eq!(as_ref, "user_id");
1361        assert_eq!(&*try_ref, "user_id");
1362        assert!(try_ref == "user_id");
1363        assert!(try_ref == *"user_id");
1364        assert!(try_ref == String::from("user_id"));
1365        assert!(try_ref == &String::from("user_id"));
1366        assert_eq!(String::from(try_owned), "user_id");
1367    }
1368
1369    #[test]
1370    #[allow(clippy::cmp_owned, clippy::op_ref)]
1371    fn hostname_conversions_and_traits() {
1372        let from_str: Hostname = "example.com".parse().unwrap();
1373        let try_ref = Hostname::try_from("example.com").unwrap();
1374        let try_owned = Hostname::try_from(String::from("example.com")).unwrap();
1375        assert_eq!(from_str, try_ref);
1376        assert_eq!(try_ref, try_owned);
1377
1378        assert_eq!(try_ref.to_string(), "example.com");
1379        let as_ref: &str = try_ref.as_ref();
1380        assert_eq!(as_ref, "example.com");
1381        assert_eq!(&*try_ref, "example.com");
1382        assert!(try_ref == "example.com");
1383        assert!(try_ref == *"example.com");
1384        assert!(try_ref == String::from("example.com"));
1385        assert!(try_ref == &String::from("example.com"));
1386        assert_eq!(String::from(try_owned), "example.com");
1387    }
1388}