Skip to main content

reliakit_primitives/
text.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::String;
3use core::{fmt, ops::Deref};
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    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32
33    pub fn into_inner(self) -> String {
34        self.0
35    }
36}
37
38fn is_valid_slug(s: &str) -> bool {
39    if s.starts_with('-') || s.ends_with('-') {
40        return false;
41    }
42    let mut prev = ' ';
43    for c in s.chars() {
44        if !matches!(c, 'a'..='z' | '0'..='9' | '-') {
45            return false;
46        }
47        if c == '-' && prev == '-' {
48            return false;
49        }
50        prev = c;
51    }
52    true
53}
54
55impl fmt::Display for Slug {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.write_str(&self.0)
58    }
59}
60
61impl AsRef<str> for Slug {
62    fn as_ref(&self) -> &str {
63        self.as_str()
64    }
65}
66
67impl Deref for Slug {
68    type Target = str;
69
70    fn deref(&self) -> &Self::Target {
71        self.as_str()
72    }
73}
74
75impl TryFrom<&str> for Slug {
76    type Error = PrimitiveError;
77
78    fn try_from(value: &str) -> Result<Self, Self::Error> {
79        Self::new(value)
80    }
81}
82
83impl TryFrom<String> for Slug {
84    type Error = PrimitiveError;
85
86    fn try_from(value: String) -> Result<Self, Self::Error> {
87        Self::new(value)
88    }
89}
90
91// ── Email ─────────────────────────────────────────────────────────────────────
92
93/// Email address with basic structural validation.
94///
95/// Checks: exactly one `@`, non-empty local part and domain, domain contains
96/// at least one `.`, no whitespace. Not a full RFC 5321 validator.
97#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
98pub struct Email(String);
99
100impl Email {
101    /// Creates a new `Email`. Returns `Invalid` if the value fails structural checks.
102    pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
103        let value = value.into();
104        if value.is_empty() {
105            return Err(PrimitiveError::Empty);
106        }
107        if !is_valid_email(&value) {
108            return Err(PrimitiveError::Invalid {
109                message: "invalid email address",
110            });
111        }
112        Ok(Self(value))
113    }
114
115    pub fn as_str(&self) -> &str {
116        &self.0
117    }
118
119    pub fn into_inner(self) -> String {
120        self.0
121    }
122
123    /// Returns the local part (before `@`).
124    pub fn local(&self) -> &str {
125        self.0.split('@').next().unwrap_or("")
126    }
127
128    /// Returns the domain part (after `@`).
129    pub fn domain(&self) -> &str {
130        self.0.split('@').nth(1).unwrap_or("")
131    }
132}
133
134fn is_valid_email(s: &str) -> bool {
135    if s.contains(' ') {
136        return false;
137    }
138    let at_count = s.chars().filter(|&c| c == '@').count();
139    if at_count != 1 {
140        return false;
141    }
142    let mut parts = s.splitn(2, '@');
143    let local = parts.next().unwrap_or("");
144    let domain = parts.next().unwrap_or("");
145    if local.is_empty() || domain.is_empty() {
146        return false;
147    }
148    if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
149        return false;
150    }
151    true
152}
153
154impl fmt::Display for Email {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.write_str(&self.0)
157    }
158}
159
160impl AsRef<str> for Email {
161    fn as_ref(&self) -> &str {
162        self.as_str()
163    }
164}
165
166impl TryFrom<&str> for Email {
167    type Error = PrimitiveError;
168
169    fn try_from(value: &str) -> Result<Self, Self::Error> {
170        Self::new(value)
171    }
172}
173
174// ── HttpUrl ───────────────────────────────────────────────────────────────────
175
176/// HTTP or HTTPS URL with scheme validation.
177///
178/// Must start with `http://` or `https://` and have a non-empty host.
179#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
180pub struct HttpUrl(String);
181
182impl HttpUrl {
183    /// Creates a new `HttpUrl`. Returns `Invalid` if the scheme is missing or
184    /// the host is empty.
185    pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
186        let value = value.into();
187        if value.is_empty() {
188            return Err(PrimitiveError::Empty);
189        }
190        let lower = value.to_lowercase();
191        let after_scheme = if let Some(rest) = lower.strip_prefix("https://") {
192            rest
193        } else if let Some(rest) = lower.strip_prefix("http://") {
194            rest
195        } else {
196            return Err(PrimitiveError::Invalid {
197                message: "URL must start with http:// or https://",
198            });
199        };
200        if after_scheme.is_empty() {
201            return Err(PrimitiveError::Invalid {
202                message: "URL must have a non-empty host",
203            });
204        }
205        Ok(Self(value))
206    }
207
208    pub fn as_str(&self) -> &str {
209        &self.0
210    }
211
212    pub fn into_inner(self) -> String {
213        self.0
214    }
215
216    /// Returns `true` if the URL uses `https`.
217    pub fn is_https(&self) -> bool {
218        self.0.len() >= 8 && self.0[..8].eq_ignore_ascii_case("https://")
219    }
220}
221
222impl fmt::Display for HttpUrl {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.write_str(&self.0)
225    }
226}
227
228impl AsRef<str> for HttpUrl {
229    fn as_ref(&self) -> &str {
230        self.as_str()
231    }
232}
233
234impl TryFrom<&str> for HttpUrl {
235    type Error = PrimitiveError;
236
237    fn try_from(value: &str) -> Result<Self, Self::Error> {
238        Self::new(value)
239    }
240}
241
242// ── HexString ─────────────────────────────────────────────────────────────────
243
244/// String of valid hexadecimal characters, with optional `0x`/`0X` prefix.
245#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
246pub struct HexString(String);
247
248impl HexString {
249    /// Creates a new `HexString`. Returns `Invalid` if any character is not a
250    /// valid hex digit (after stripping an optional `0x`/`0X` prefix).
251    pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
252        let value = value.into();
253        if value.is_empty() {
254            return Err(PrimitiveError::Empty);
255        }
256        let hex_part = value
257            .strip_prefix("0x")
258            .or_else(|| value.strip_prefix("0X"))
259            .unwrap_or(&value);
260        if hex_part.is_empty() {
261            return Err(PrimitiveError::Invalid {
262                message: "hex string must not be empty after prefix",
263            });
264        }
265        if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
266            return Err(PrimitiveError::Invalid {
267                message: "hex string must contain only hexadecimal characters (0-9, a-f, A-F)",
268            });
269        }
270        Ok(Self(value))
271    }
272
273    pub fn as_str(&self) -> &str {
274        &self.0
275    }
276
277    pub fn into_inner(self) -> String {
278        self.0
279    }
280
281    /// Returns `true` if the value was stored with a `0x`/`0X` prefix.
282    pub fn has_prefix(&self) -> bool {
283        self.0.starts_with("0x") || self.0.starts_with("0X")
284    }
285
286    /// Returns only the hex digit characters, without any `0x`/`0X` prefix.
287    pub fn hex_digits(&self) -> &str {
288        self.0
289            .strip_prefix("0x")
290            .or_else(|| self.0.strip_prefix("0X"))
291            .unwrap_or(&self.0)
292    }
293}
294
295impl fmt::Display for HexString {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        f.write_str(&self.0)
298    }
299}
300
301impl AsRef<str> for HexString {
302    fn as_ref(&self) -> &str {
303        self.as_str()
304    }
305}
306
307impl TryFrom<&str> for HexString {
308    type Error = PrimitiveError;
309
310    fn try_from(value: &str) -> Result<Self, Self::Error> {
311        Self::new(value)
312    }
313}
314
315// ── Tests ─────────────────────────────────────────────────────────────────────
316
317#[cfg(test)]
318mod tests {
319    use super::{Email, HexString, HttpUrl, Slug};
320    use crate::PrimitiveError;
321
322    // Slug
323    #[test]
324    fn slug_accepts_valid() {
325        assert_eq!(Slug::new("my-service").unwrap().as_str(), "my-service");
326        assert_eq!(Slug::new("api-v2").unwrap().as_str(), "api-v2");
327        assert_eq!(Slug::new("user123").unwrap().as_str(), "user123");
328    }
329
330    #[test]
331    fn slug_rejects_empty() {
332        assert_eq!(Slug::new("").unwrap_err(), PrimitiveError::Empty);
333    }
334
335    #[test]
336    fn slug_rejects_uppercase() {
337        assert!(Slug::new("MySlug").is_err());
338    }
339
340    #[test]
341    fn slug_rejects_leading_hyphen() {
342        assert!(Slug::new("-bad").is_err());
343    }
344
345    #[test]
346    fn slug_rejects_trailing_hyphen() {
347        assert!(Slug::new("bad-").is_err());
348    }
349
350    #[test]
351    fn slug_rejects_consecutive_hyphens() {
352        assert!(Slug::new("bad--slug").is_err());
353    }
354
355    #[test]
356    fn slug_rejects_spaces() {
357        assert!(Slug::new("has space").is_err());
358    }
359
360    #[test]
361    fn slug_display() {
362        use alloc::string::ToString;
363        assert_eq!(Slug::new("hello").unwrap().to_string(), "hello");
364    }
365
366    #[test]
367    fn slug_deref() {
368        let s = Slug::new("hello").unwrap();
369        assert_eq!(&*s, "hello");
370    }
371
372    // Email
373    #[test]
374    fn email_accepts_valid() {
375        let e = Email::new("user@example.com").unwrap();
376        assert_eq!(e.local(), "user");
377        assert_eq!(e.domain(), "example.com");
378    }
379
380    #[test]
381    fn email_rejects_empty() {
382        assert_eq!(Email::new("").unwrap_err(), PrimitiveError::Empty);
383    }
384
385    #[test]
386    fn email_rejects_missing_at() {
387        assert!(Email::new("nodomain").is_err());
388    }
389
390    #[test]
391    fn email_rejects_multiple_at() {
392        assert!(Email::new("a@b@c.com").is_err());
393    }
394
395    #[test]
396    fn email_rejects_no_dot_in_domain() {
397        assert!(Email::new("user@nodot").is_err());
398    }
399
400    #[test]
401    fn email_rejects_spaces() {
402        assert!(Email::new("us er@example.com").is_err());
403    }
404
405    #[test]
406    fn email_display() {
407        use alloc::string::ToString;
408        assert_eq!(Email::new("a@b.com").unwrap().to_string(), "a@b.com");
409    }
410
411    // HttpUrl
412    #[test]
413    fn url_accepts_http() {
414        let u = HttpUrl::new("http://example.com").unwrap();
415        assert!(!u.is_https());
416    }
417
418    #[test]
419    fn url_accepts_https() {
420        let u = HttpUrl::new("https://example.com/path").unwrap();
421        assert!(u.is_https());
422    }
423
424    #[test]
425    fn url_rejects_empty() {
426        assert_eq!(HttpUrl::new("").unwrap_err(), PrimitiveError::Empty);
427    }
428
429    #[test]
430    fn url_rejects_missing_scheme() {
431        assert!(HttpUrl::new("ftp://example.com").is_err());
432    }
433
434    #[test]
435    fn url_rejects_empty_host() {
436        assert!(HttpUrl::new("https://").is_err());
437    }
438
439    #[test]
440    fn url_display() {
441        use alloc::string::ToString;
442        let u = HttpUrl::new("https://example.com").unwrap();
443        assert_eq!(u.to_string(), "https://example.com");
444    }
445
446    #[test]
447    fn url_is_https_uppercase_scheme() {
448        let u = HttpUrl::new("HTTPS://example.com").unwrap();
449        assert!(u.is_https());
450    }
451
452    #[test]
453    fn url_is_http_not_https() {
454        let u = HttpUrl::new("http://example.com").unwrap();
455        assert!(!u.is_https());
456    }
457
458    // HexString
459    #[test]
460    fn hex_accepts_plain() {
461        let h = HexString::new("deadbeef").unwrap();
462        assert_eq!(h.hex_digits(), "deadbeef");
463        assert!(!h.has_prefix());
464    }
465
466    #[test]
467    fn hex_accepts_prefixed() {
468        let h = HexString::new("0xdeadbeef").unwrap();
469        assert_eq!(h.hex_digits(), "deadbeef");
470        assert!(h.has_prefix());
471    }
472
473    #[test]
474    fn hex_accepts_uppercase() {
475        assert!(HexString::new("DEADBEEF").is_ok());
476    }
477
478    #[test]
479    fn hex_rejects_empty() {
480        assert_eq!(HexString::new("").unwrap_err(), PrimitiveError::Empty);
481    }
482
483    #[test]
484    fn hex_rejects_prefix_only() {
485        assert!(HexString::new("0x").is_err());
486    }
487
488    #[test]
489    fn hex_rejects_invalid_chars() {
490        assert!(HexString::new("xyz").is_err());
491    }
492
493    #[test]
494    fn hex_display() {
495        use alloc::string::ToString;
496        assert_eq!(HexString::new("ff00").unwrap().to_string(), "ff00");
497    }
498}