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