1use std::fmt;
21use std::str::FromStr;
22
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ValidatorError {
31 InvalidSlug(String),
33 InvalidEmail(String),
36 InvalidUrl(String),
39}
40
41impl fmt::Display for ValidatorError {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 ValidatorError::InvalidSlug(s) => write!(f, "invalid slug: `{s}`"),
45 ValidatorError::InvalidEmail(s) => write!(f, "invalid email: `{s}`"),
46 ValidatorError::InvalidUrl(s) => write!(f, "invalid url: `{s}`"),
47 }
48 }
49}
50
51impl std::error::Error for ValidatorError {}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(transparent)]
59pub struct Slug(String);
60
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(transparent)]
69pub struct Email(String);
70
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct Url(String);
80
81impl Slug {
82 pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
85 let s = s.into();
86 if validate_slug_str(&s) {
87 Ok(Self(s))
88 } else {
89 Err(ValidatorError::InvalidSlug(s))
90 }
91 }
92 pub fn unchecked(s: String) -> Self {
95 Self(s)
96 }
97 pub fn as_str(&self) -> &str {
98 &self.0
99 }
100 pub fn into_inner(self) -> String {
101 self.0
102 }
103}
104
105impl Email {
106 pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
107 let s = s.into();
108 if validate_email_str(&s) {
109 Ok(Self(s))
110 } else {
111 Err(ValidatorError::InvalidEmail(s))
112 }
113 }
114 pub fn unchecked(s: String) -> Self {
115 Self(s)
116 }
117 pub fn as_str(&self) -> &str {
118 &self.0
119 }
120 pub fn into_inner(self) -> String {
121 self.0
122 }
123}
124
125impl Url {
126 pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
127 let s = s.into();
128 if validate_url_str(&s) {
129 Ok(Self(s))
130 } else {
131 Err(ValidatorError::InvalidUrl(s))
132 }
133 }
134 pub fn unchecked(s: String) -> Self {
135 Self(s)
136 }
137 pub fn as_str(&self) -> &str {
138 &self.0
139 }
140 pub fn into_inner(self) -> String {
141 self.0
142 }
143}
144
145impl AsRef<str> for Slug {
150 fn as_ref(&self) -> &str {
151 &self.0
152 }
153}
154impl AsRef<str> for Email {
155 fn as_ref(&self) -> &str {
156 &self.0
157 }
158}
159impl AsRef<str> for Url {
160 fn as_ref(&self) -> &str {
161 &self.0
162 }
163}
164
165impl fmt::Display for Slug {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 f.write_str(&self.0)
168 }
169}
170impl fmt::Display for Email {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.write_str(&self.0)
173 }
174}
175impl fmt::Display for Url {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 f.write_str(&self.0)
178 }
179}
180
181impl FromStr for Slug {
182 type Err = ValidatorError;
183 fn from_str(s: &str) -> Result<Self, Self::Err> {
184 Self::new(s.to_string())
185 }
186}
187impl FromStr for Email {
188 type Err = ValidatorError;
189 fn from_str(s: &str) -> Result<Self, Self::Err> {
190 Self::new(s.to_string())
191 }
192}
193impl FromStr for Url {
194 type Err = ValidatorError;
195 fn from_str(s: &str) -> Result<Self, Self::Err> {
196 Self::new(s.to_string())
197 }
198}
199
200impl<DB: sqlx::Database> sqlx::Type<DB> for Slug
206where
207 String: sqlx::Type<DB>,
208{
209 fn type_info() -> DB::TypeInfo {
210 <String as sqlx::Type<DB>>::type_info()
211 }
212 fn compatible(ty: &DB::TypeInfo) -> bool {
213 <String as sqlx::Type<DB>>::compatible(ty)
214 }
215}
216impl<DB: sqlx::Database> sqlx::Type<DB> for Email
217where
218 String: sqlx::Type<DB>,
219{
220 fn type_info() -> DB::TypeInfo {
221 <String as sqlx::Type<DB>>::type_info()
222 }
223 fn compatible(ty: &DB::TypeInfo) -> bool {
224 <String as sqlx::Type<DB>>::compatible(ty)
225 }
226}
227impl<DB: sqlx::Database> sqlx::Type<DB> for Url
228where
229 String: sqlx::Type<DB>,
230{
231 fn type_info() -> DB::TypeInfo {
232 <String as sqlx::Type<DB>>::type_info()
233 }
234 fn compatible(ty: &DB::TypeInfo) -> bool {
235 <String as sqlx::Type<DB>>::compatible(ty)
236 }
237}
238
239impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Slug
240where
241 String: sqlx::Decode<'r, DB>,
242{
243 fn decode(
244 value: <DB as sqlx::Database>::ValueRef<'r>,
245 ) -> Result<Self, sqlx::error::BoxDynError> {
246 let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
247 Ok(Slug::unchecked(s))
248 }
249}
250impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Email
251where
252 String: sqlx::Decode<'r, DB>,
253{
254 fn decode(
255 value: <DB as sqlx::Database>::ValueRef<'r>,
256 ) -> Result<Self, sqlx::error::BoxDynError> {
257 let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
258 Ok(Email::unchecked(s))
259 }
260}
261impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Url
262where
263 String: sqlx::Decode<'r, DB>,
264{
265 fn decode(
266 value: <DB as sqlx::Database>::ValueRef<'r>,
267 ) -> Result<Self, sqlx::error::BoxDynError> {
268 let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
269 Ok(Url::unchecked(s))
270 }
271}
272
273impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Slug
274where
275 String: sqlx::Encode<'q, DB>,
276{
277 fn encode_by_ref(
278 &self,
279 buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
280 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
281 <String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
282 }
283}
284impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Email
285where
286 String: sqlx::Encode<'q, DB>,
287{
288 fn encode_by_ref(
289 &self,
290 buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
291 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
292 <String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
293 }
294}
295impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Url
296where
297 String: sqlx::Encode<'q, DB>,
298{
299 fn encode_by_ref(
300 &self,
301 buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
302 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
303 <String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
304 }
305}
306
307pub fn validate_text_format(format: &str, value: &str) -> Result<(), ValidatorError> {
313 match format {
314 "slug" => {
315 if validate_slug_str(value) {
316 Ok(())
317 } else {
318 Err(ValidatorError::InvalidSlug(value.to_string()))
319 }
320 }
321 "email" => {
322 if validate_email_str(value) {
323 Ok(())
324 } else {
325 Err(ValidatorError::InvalidEmail(value.to_string()))
326 }
327 }
328 "url" => {
329 if validate_url_str(value) {
330 Ok(())
331 } else {
332 Err(ValidatorError::InvalidUrl(value.to_string()))
333 }
334 }
335 _ => Ok(()),
338 }
339}
340
341fn validate_slug_str(s: &str) -> bool {
342 !s.is_empty()
343 && s.chars()
344 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
345}
346
347fn validate_email_str(s: &str) -> bool {
348 let Some((local, domain)) = s.split_once('@') else {
349 return false;
350 };
351 if local.is_empty() || domain.is_empty() {
352 return false;
353 }
354 if domain.contains('@') || s.contains(char::is_whitespace) {
358 return false;
359 }
360 domain
363 .rsplit_once('.')
364 .map(|(left, right)| !left.is_empty() && !right.is_empty())
365 .unwrap_or(false)
366}
367
368fn validate_url_str(s: &str) -> bool {
369 let lower = s.to_ascii_lowercase();
370 if !(lower.starts_with("http://") || lower.starts_with("https://")) {
371 return false;
372 }
373 let after = &s[s.find("://").map(|i| i + 3).unwrap_or(s.len())..];
375 let host = after.split('/').next().unwrap_or("");
376 !host.is_empty() && !host.contains(char::is_whitespace) && host.contains('.')
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn slug_accepts_url_safe() {
385 assert!(Slug::new("hello-world_2").is_ok());
386 assert!(Slug::new("A").is_ok());
387 }
388 #[test]
389 fn slug_rejects_empty_and_special_chars() {
390 assert!(matches!(Slug::new(""), Err(ValidatorError::InvalidSlug(_))));
391 assert!(matches!(
392 Slug::new("hello world"),
393 Err(ValidatorError::InvalidSlug(_))
394 ));
395 assert!(matches!(
396 Slug::new("hi/there"),
397 Err(ValidatorError::InvalidSlug(_))
398 ));
399 }
400 #[test]
401 fn email_accepts_structural_shape() {
402 assert!(Email::new("a@b.c").is_ok());
403 assert!(Email::new("user+tag@example.com").is_ok());
404 }
405 #[test]
406 fn email_rejects_obvious_breaks() {
407 assert!(matches!(
408 Email::new("plain"),
409 Err(ValidatorError::InvalidEmail(_))
410 ));
411 assert!(matches!(
412 Email::new("@no-local.com"),
413 Err(ValidatorError::InvalidEmail(_))
414 ));
415 assert!(matches!(
416 Email::new("no-at"),
417 Err(ValidatorError::InvalidEmail(_))
418 ));
419 assert!(matches!(
420 Email::new("two@@sign.com"),
421 Err(ValidatorError::InvalidEmail(_))
422 ));
423 assert!(matches!(
424 Email::new("a@localhost"),
425 Err(ValidatorError::InvalidEmail(_))
426 ));
427 }
428 #[test]
429 fn url_accepts_http_and_https() {
430 assert!(Url::new("http://example.com/path?x=1").is_ok());
431 assert!(Url::new("https://example.com/").is_ok());
432 }
433 #[test]
434 fn url_rejects_non_http_or_missing_host() {
435 assert!(matches!(
436 Url::new("ftp://example.com/"),
437 Err(ValidatorError::InvalidUrl(_))
438 ));
439 assert!(matches!(
440 Url::new("https:///path"),
441 Err(ValidatorError::InvalidUrl(_))
442 ));
443 assert!(matches!(
444 Url::new("not a url"),
445 Err(ValidatorError::InvalidUrl(_))
446 ));
447 }
448 #[test]
449 fn validate_text_format_dispatches() {
450 assert!(validate_text_format("slug", "ok-1").is_ok());
451 assert!(validate_text_format("slug", "no spaces").is_err());
452 assert!(validate_text_format("email", "a@b.c").is_ok());
453 assert!(validate_text_format("url", "https://x.y/").is_ok());
454 assert!(validate_text_format("unknown", "anything").is_ok());
456 }
457}