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)]
99pub struct Email(String);
100
101impl Email {
102 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 pub fn local(&self) -> &str {
126 self.0.split('@').next().unwrap_or("")
127 }
128
129 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
181pub struct HttpUrl(String);
182
183impl HttpUrl {
184 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 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
253pub struct HexString(String);
254
255impl HexString {
256 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 pub fn has_prefix(&self) -> bool {
290 self.0.starts_with("0x") || self.0.starts_with("0X")
291 }
292
293 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#[cfg(test)]
325mod tests {
326 use super::{Email, HexString, HttpUrl, Slug};
327 use crate::PrimitiveError;
328
329 #[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 #[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 #[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 #[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}