reliakit_primitives/
text.rs1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::String;
3use core::{fmt, ops::Deref};
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct Slug(String);
13
14impl Slug {
15 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
98pub struct Email(String);
99
100impl Email {
101 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 pub fn local(&self) -> &str {
125 self.0.split('@').next().unwrap_or("")
126 }
127
128 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
180pub struct HttpUrl(String);
181
182impl HttpUrl {
183 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 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
246pub struct HexString(String);
247
248impl HexString {
249 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 pub fn has_prefix(&self) -> bool {
283 self.0.starts_with("0x") || self.0.starts_with("0X")
284 }
285
286 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#[cfg(test)]
318mod tests {
319 use super::{Email, HexString, HttpUrl, Slug};
320 use crate::PrimitiveError;
321
322 #[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 #[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 #[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 #[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}