use crate::field::{FieldError, FieldResult};
use regex::Regex;
use std::sync::LazyLock;
static URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^https?://[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*(:[0-9]{1,5})?(/[^\s?#]*)?(\?[^\s#]*)?(#[^\s]*)?$",
)
.expect("URL_REGEX: invalid regex pattern")
});
static SLUG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$")
.expect("SLUG_REGEX: invalid regex pattern")
});
#[derive(Debug, Clone)]
pub struct UrlValidator {
message: Option<String>,
}
impl UrlValidator {
pub fn new() -> Self {
Self { message: None }
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
pub fn validate(&self, value: &str) -> FieldResult<()> {
if URL_REGEX.is_match(value) {
Ok(())
} else {
let msg = self.message.as_deref().unwrap_or("Enter a valid URL");
Err(FieldError::Validation(msg.to_string()))
}
}
}
impl Default for UrlValidator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct SlugValidator {
message: Option<String>,
}
impl SlugValidator {
pub fn new() -> Self {
Self { message: None }
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
pub fn validate(&self, value: &str) -> FieldResult<()> {
if value.is_empty() {
let msg = self
.message
.as_deref()
.unwrap_or("Enter a valid slug (non-empty)");
return Err(FieldError::Validation(msg.to_string()));
}
if SLUG_REGEX.is_match(value) {
Ok(())
} else {
let msg = self.message.as_deref().unwrap_or(
"Enter a valid slug consisting of lowercase letters, numbers, hyphens, or underscores. \
The slug must not start or end with a hyphen.",
);
Err(FieldError::Validation(msg.to_string()))
}
}
}
impl Default for SlugValidator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("http://example.com")]
#[case("https://example.com")]
#[case("http://www.example.com")]
#[case("https://www.example.com/")]
#[case("http://localhost")]
#[case("http://localhost:8080")]
#[case("http://localhost:8080/path")]
#[case("https://example.com/path/to/resource")]
#[case("https://example.com/path?query=value")]
#[case("https://example.com/path?query=value#section")]
#[case("http://sub.example.com/")]
#[case("http://example.com:3000")]
#[case("http://valid-domain.com/")]
#[case("https://example.com?q=1&page=2")]
fn test_url_validator_valid(#[case] url: &str) {
let validator = UrlValidator::new();
let result = validator.validate(url);
assert!(result.is_ok(), "Expected '{url}' to be a valid URL");
}
#[rstest]
#[case("")]
#[case("not-a-url")]
#[case("ftp://example.com")]
#[case("http://")]
#[case("http://.com")]
#[case("//example.com")]
#[case("http://-invalid.com")]
#[case("http://invalid-.com")]
#[case("just text")]
#[case("example.com")]
fn test_url_validator_invalid(#[case] url: &str) {
let validator = UrlValidator::new();
let result = validator.validate(url);
assert!(result.is_err(), "Expected '{url}' to be an invalid URL");
}
#[rstest]
fn test_url_validator_error_type() {
let validator = UrlValidator::new();
let result = validator.validate("not-a-url");
assert!(matches!(result, Err(FieldError::Validation(_))));
}
#[rstest]
fn test_url_validator_custom_message() {
let validator = UrlValidator::new().with_message("Custom URL error");
let result = validator.validate("bad-url");
match result {
Err(FieldError::Validation(msg)) => {
assert_eq!(msg, "Custom URL error");
}
_ => panic!("Expected Validation error with custom message"),
}
}
#[rstest]
fn test_url_validator_default() {
let validator = UrlValidator::default();
assert!(validator.validate("https://example.com").is_ok());
}
#[rstest]
#[case("a")]
#[case("slug")]
#[case("my-slug")]
#[case("my_slug")]
#[case("slug-123")]
#[case("my-article-title")]
#[case("page1")]
#[case("a1b2c3")]
#[case("under_score")]
#[case("mix-ed_slug-1")]
fn test_slug_validator_valid(#[case] slug: &str) {
let validator = SlugValidator::new();
let result = validator.validate(slug);
assert!(result.is_ok(), "Expected '{slug}' to be a valid slug");
}
#[rstest]
#[case("")]
#[case("-starts-with-hyphen")]
#[case("ends-with-hyphen-")]
#[case("has space")]
#[case("UPPERCASE")]
#[case("Has-Upper")]
#[case("special!char")]
#[case("dot.in.slug")]
#[case("unicode-日本語")]
fn test_slug_validator_invalid(#[case] slug: &str) {
let validator = SlugValidator::new();
let result = validator.validate(slug);
assert!(result.is_err(), "Expected '{slug}' to be an invalid slug");
}
#[rstest]
fn test_slug_validator_empty_specific_error() {
let validator = SlugValidator::new();
let result = validator.validate("");
assert!(matches!(result, Err(FieldError::Validation(_))));
}
#[rstest]
fn test_slug_validator_invalid_error_type() {
let validator = SlugValidator::new();
let result = validator.validate("-bad-slug");
assert!(matches!(result, Err(FieldError::Validation(_))));
}
#[rstest]
fn test_slug_validator_custom_message() {
let validator = SlugValidator::new().with_message("Custom slug error");
let result = validator.validate("Bad Slug!");
match result {
Err(FieldError::Validation(msg)) => {
assert_eq!(msg, "Custom slug error");
}
_ => panic!("Expected Validation error with custom message"),
}
}
#[rstest]
fn test_slug_validator_custom_message_on_empty() {
let validator = SlugValidator::new().with_message("Slug cannot be empty");
let result = validator.validate("");
match result {
Err(FieldError::Validation(msg)) => {
assert_eq!(msg, "Slug cannot be empty");
}
_ => panic!("Expected Validation error with custom message"),
}
}
#[rstest]
fn test_slug_validator_default() {
let validator = SlugValidator::default();
assert!(validator.validate("valid-slug").is_ok());
}
}