use regex::Regex;
use serde_json::Value;
use super::{Validator, ValidatorOptions};
use crate::errors::{ErrorType, Errors};
#[derive(Debug, Clone)]
enum MatchMode {
With(Regex),
Without(Regex),
}
#[derive(Debug, Clone, Default)]
pub struct FormatValidator {
mode: Option<MatchMode>,
message: Option<String>,
pub(crate) options: ValidatorOptions,
}
impl FormatValidator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_pattern(pattern: &str) -> Self {
Self::new().with(pattern)
}
crate::validations::impl_common_validator_methods!();
#[must_use]
pub fn with(mut self, pattern: &str) -> Self {
self.mode = Some(MatchMode::With(Self::compile(pattern)));
self
}
#[must_use]
pub fn without(mut self, pattern: &str) -> Self {
self.mode = Some(MatchMode::Without(Self::compile(pattern)));
self
}
#[must_use]
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
fn compile(pattern: &str) -> Regex {
match Regex::new(pattern) {
Ok(regex) => regex,
Err(error) => panic!("invalid format pattern '{pattern}': {error}"),
}
}
fn error_message(&self) -> String {
self.message
.clone()
.unwrap_or_else(|| String::from("is invalid"))
}
fn value_as_text(value: Option<&Value>) -> String {
match value {
None | Some(Value::Null) => String::new(),
Some(Value::String(text)) => text.clone(),
Some(other) => other.to_string(),
}
}
}
impl Validator for FormatValidator {
fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
let Some(mode) = &self.mode else {
return;
};
let text = Self::value_as_text(value);
let invalid = match mode {
MatchMode::With(regex) => !regex.is_match(&text),
MatchMode::Without(regex) => regex.is_match(&text),
};
if invalid {
errors.add(attribute, ErrorType::Invalid, self.error_message());
}
}
fn name(&self) -> &str {
"format"
}
fn options(&self) -> &ValidatorOptions {
&self.options
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::json;
use super::FormatValidator;
use crate::{
errors::{ErrorType, Errors},
validations::{ValidationSet, Validator},
};
fn validate_format(validator: FormatValidator, value: Option<serde_json::Value>) -> Errors {
let mut errors = Errors::new();
validator.validate("field", value.as_ref(), &mut errors);
errors
}
#[test]
fn matching_pattern_passes() {
let validator = FormatValidator::with_pattern(r"^[a-z]+$");
let mut errors = Errors::new();
validator.validate("slug", Some(&json!("alpha")), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn non_matching_pattern_fails() {
let validator = FormatValidator::with_pattern(r"^[a-z]+$");
let mut errors = Errors::new();
validator.validate("slug", Some(&json!("Alpha1")), &mut errors);
assert_eq!(errors.on("slug")[0].error_type, ErrorType::Invalid);
}
#[test]
fn without_pattern_rejects_matches() {
let validator = FormatValidator::new().without("spam");
let mut errors = Errors::new();
validator.validate("body", Some(&json!("contains spam")), &mut errors);
assert_eq!(errors.on("body")[0].message, "is invalid");
}
#[test]
fn custom_message_is_used() {
let validator = FormatValidator::with_pattern(r"^\d+$").message("digits only");
let mut errors = Errors::new();
validator.validate("pin", Some(&json!("12ab")), &mut errors);
assert_eq!(errors.on("pin")[0].message, "digits only");
}
#[test]
fn non_string_values_are_stringified() {
let validator = FormatValidator::with_pattern(r"^42$");
let mut errors = Errors::new();
validator.validate("answer", Some(&json!(42)), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn without_pattern_allows_non_matching_text() {
let errors = validate_format(FormatValidator::new().without("spam"), Some(json!("ham")));
assert!(errors.is_empty());
}
#[test]
fn allow_nil_skips_missing_values_in_validation_set() {
let mut set = ValidationSet::new();
set.add(
"slug",
FormatValidator::with_pattern(r"^[a-z]+$").allow_nil(),
);
let mut errors = Errors::new();
let _ = set.validate(&|_| None, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn allow_nil_skips_null_values_in_validation_set() {
let mut set = ValidationSet::new();
set.add(
"slug",
FormatValidator::with_pattern(r"^[a-z]+$").allow_nil(),
);
let attrs = HashMap::from([("slug".to_string(), json!(null))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn allow_blank_skips_whitespace_values_in_validation_set() {
let mut set = ValidationSet::new();
set.add(
"slug",
FormatValidator::with_pattern(r"^[a-z]+$").allow_blank(),
);
let attrs = HashMap::from([("slug".to_string(), json!(" "))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn allow_blank_skips_empty_arrays_in_validation_set() {
let mut set = ValidationSet::new();
set.add(
"slug",
FormatValidator::with_pattern(r"^[a-z]+$").allow_blank(),
);
let attrs = HashMap::from([("slug".to_string(), json!([]))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn validator_without_mode_is_no_op() {
let errors = validate_format(FormatValidator::new(), Some(json!("anything")));
assert!(errors.is_empty());
}
#[test]
fn null_value_can_match_empty_pattern() {
let errors = validate_format(FormatValidator::with_pattern(r"^$"), Some(json!(null)));
assert!(errors.is_empty());
}
#[test]
fn null_value_fails_non_empty_pattern() {
let errors = validate_format(FormatValidator::with_pattern(r".+"), Some(json!(null)));
assert_eq!(errors.on("field")[0].error_type, ErrorType::Invalid);
}
#[test]
fn boolean_values_are_stringified() {
let errors = validate_format(
FormatValidator::with_pattern(r"^false$"),
Some(json!(false)),
);
assert!(errors.is_empty());
}
#[test]
fn custom_message_is_used_for_without_failures() {
let errors = validate_format(
FormatValidator::new()
.without("spam")
.message("forbidden phrase"),
Some(json!("spam")),
);
assert_eq!(errors.on("field")[0].message, "forbidden phrase");
}
#[test]
fn multiline_text_fails_without_dotall() {
let errors = validate_format(FormatValidator::with_pattern(r"^.+$"), Some(json!("a\nb")));
assert_eq!(errors.on("field")[0].error_type, ErrorType::Invalid);
}
#[test]
fn multiline_text_passes_with_dotall() {
let errors = validate_format(
FormatValidator::with_pattern(r"(?s)^.+$"),
Some(json!("a\nb")),
);
assert!(errors.is_empty());
}
#[test]
#[should_panic(expected = "invalid format pattern")]
fn invalid_patterns_panic() {
let _validator = FormatValidator::with_pattern("(");
}
#[test]
fn allow_blank_does_not_skip_non_blank_invalid_values() {
let mut set = ValidationSet::new();
set.add(
"slug",
FormatValidator::with_pattern(r"^[a-z]+$").allow_blank(),
);
let attrs = HashMap::from([("slug".to_string(), json!("Alpha1"))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert_eq!(errors.on("slug")[0].error_type, ErrorType::Invalid);
}
#[test]
fn full_message_humanizes_attribute_name() {
let mut errors = Errors::new();
FormatValidator::with_pattern(r"^[a-z]+$").validate(
"email_address",
Some(&json!("INVALID")),
&mut errors,
);
assert_eq!(
errors.full_messages(),
vec!["Email address is invalid".to_string()]
);
}
}