use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidatorError {
InvalidSlug(String),
InvalidEmail(String),
InvalidUrl(String),
}
impl fmt::Display for ValidatorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidatorError::InvalidSlug(s) => write!(f, "invalid slug: `{s}`"),
ValidatorError::InvalidEmail(s) => write!(f, "invalid email: `{s}`"),
ValidatorError::InvalidUrl(s) => write!(f, "invalid url: `{s}`"),
}
}
}
impl std::error::Error for ValidatorError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Slug(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Email(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Url(String);
impl Slug {
pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
let s = s.into();
if validate_slug_str(&s) {
Ok(Self(s))
} else {
Err(ValidatorError::InvalidSlug(s))
}
}
pub fn unchecked(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Email {
pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
let s = s.into();
if validate_email_str(&s) {
Ok(Self(s))
} else {
Err(ValidatorError::InvalidEmail(s))
}
}
pub fn unchecked(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Url {
pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
let s = s.into();
if validate_url_str(&s) {
Ok(Self(s))
} else {
Err(ValidatorError::InvalidUrl(s))
}
}
pub fn unchecked(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Slug {
fn as_ref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for Url {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for Slug {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Display for Email {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Display for Url {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Slug {
type Err = ValidatorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s.to_string())
}
}
impl FromStr for Email {
type Err = ValidatorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s.to_string())
}
}
impl FromStr for Url {
type Err = ValidatorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s.to_string())
}
}
impl<DB: sqlx::Database> sqlx::Type<DB> for Slug
where
String: sqlx::Type<DB>,
{
fn type_info() -> DB::TypeInfo {
<String as sqlx::Type<DB>>::type_info()
}
fn compatible(ty: &DB::TypeInfo) -> bool {
<String as sqlx::Type<DB>>::compatible(ty)
}
}
impl<DB: sqlx::Database> sqlx::Type<DB> for Email
where
String: sqlx::Type<DB>,
{
fn type_info() -> DB::TypeInfo {
<String as sqlx::Type<DB>>::type_info()
}
fn compatible(ty: &DB::TypeInfo) -> bool {
<String as sqlx::Type<DB>>::compatible(ty)
}
}
impl<DB: sqlx::Database> sqlx::Type<DB> for Url
where
String: sqlx::Type<DB>,
{
fn type_info() -> DB::TypeInfo {
<String as sqlx::Type<DB>>::type_info()
}
fn compatible(ty: &DB::TypeInfo) -> bool {
<String as sqlx::Type<DB>>::compatible(ty)
}
}
impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Slug
where
String: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
Ok(Slug::unchecked(s))
}
}
impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Email
where
String: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
Ok(Email::unchecked(s))
}
}
impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Url
where
String: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
Ok(Url::unchecked(s))
}
}
impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Slug
where
String: sqlx::Encode<'q, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
<String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
}
}
impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Email
where
String: sqlx::Encode<'q, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
<String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
}
}
impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Url
where
String: sqlx::Encode<'q, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
<String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
}
}
pub fn validate_text_format(format: &str, value: &str) -> Result<(), ValidatorError> {
match format {
"slug" => {
if validate_slug_str(value) {
Ok(())
} else {
Err(ValidatorError::InvalidSlug(value.to_string()))
}
}
"email" => {
if validate_email_str(value) {
Ok(())
} else {
Err(ValidatorError::InvalidEmail(value.to_string()))
}
}
"url" => {
if validate_url_str(value) {
Ok(())
} else {
Err(ValidatorError::InvalidUrl(value.to_string()))
}
}
_ => Ok(()),
}
}
fn validate_slug_str(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn validate_email_str(s: &str) -> bool {
let Some((local, domain)) = s.split_once('@') else {
return false;
};
if local.is_empty() || domain.is_empty() {
return false;
}
if domain.contains('@') || s.contains(char::is_whitespace) {
return false;
}
domain
.rsplit_once('.')
.map(|(left, right)| !left.is_empty() && !right.is_empty())
.unwrap_or(false)
}
fn validate_url_str(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
return false;
}
let after = &s[s.find("://").map(|i| i + 3).unwrap_or(s.len())..];
let host = after.split('/').next().unwrap_or("");
!host.is_empty() && !host.contains(char::is_whitespace) && host.contains('.')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_accepts_url_safe() {
assert!(Slug::new("hello-world_2").is_ok());
assert!(Slug::new("A").is_ok());
}
#[test]
fn slug_rejects_empty_and_special_chars() {
assert!(matches!(Slug::new(""), Err(ValidatorError::InvalidSlug(_))));
assert!(matches!(
Slug::new("hello world"),
Err(ValidatorError::InvalidSlug(_))
));
assert!(matches!(
Slug::new("hi/there"),
Err(ValidatorError::InvalidSlug(_))
));
}
#[test]
fn email_accepts_structural_shape() {
assert!(Email::new("a@b.c").is_ok());
assert!(Email::new("user+tag@example.com").is_ok());
}
#[test]
fn email_rejects_obvious_breaks() {
assert!(matches!(
Email::new("plain"),
Err(ValidatorError::InvalidEmail(_))
));
assert!(matches!(
Email::new("@no-local.com"),
Err(ValidatorError::InvalidEmail(_))
));
assert!(matches!(
Email::new("no-at"),
Err(ValidatorError::InvalidEmail(_))
));
assert!(matches!(
Email::new("two@@sign.com"),
Err(ValidatorError::InvalidEmail(_))
));
assert!(matches!(
Email::new("a@localhost"),
Err(ValidatorError::InvalidEmail(_))
));
}
#[test]
fn url_accepts_http_and_https() {
assert!(Url::new("http://example.com/path?x=1").is_ok());
assert!(Url::new("https://example.com/").is_ok());
}
#[test]
fn url_rejects_non_http_or_missing_host() {
assert!(matches!(
Url::new("ftp://example.com/"),
Err(ValidatorError::InvalidUrl(_))
));
assert!(matches!(
Url::new("https:///path"),
Err(ValidatorError::InvalidUrl(_))
));
assert!(matches!(
Url::new("not a url"),
Err(ValidatorError::InvalidUrl(_))
));
}
#[test]
fn validate_text_format_dispatches() {
assert!(validate_text_format("slug", "ok-1").is_ok());
assert!(validate_text_format("slug", "no spaces").is_err());
assert!(validate_text_format("email", "a@b.c").is_ok());
assert!(validate_text_format("url", "https://x.y/").is_ok());
assert!(validate_text_format("unknown", "anything").is_ok());
}
}