rustrails-model 0.1.2

Model layer (ActiveModel equivalent)
Documentation
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),
}

/// Validates values against a regular expression.
#[derive(Debug, Clone, Default)]
pub struct FormatValidator {
    mode: Option<MatchMode>,
    message: Option<String>,
    pub(crate) options: ValidatorOptions,
}

impl FormatValidator {
    /// Creates a new format validator.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Creates a validator that requires values to match `pattern`.
    #[must_use]
    pub fn with_pattern(pattern: &str) -> Self {
        Self::new().with(pattern)
    }

    crate::validations::impl_common_validator_methods!();

    /// Requires values to match `pattern`.
    #[must_use]
    pub fn with(mut self, pattern: &str) -> Self {
        self.mode = Some(MatchMode::With(Self::compile(pattern)));
        self
    }

    /// Requires values not to match `pattern`.
    #[must_use]
    pub fn without(mut self, pattern: &str) -> Self {
        self.mode = Some(MatchMode::Without(Self::compile(pattern)));
        self
    }

    /// Overrides the default invalid-format message.
    #[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()]
        );
    }
}