rustrails-model 0.1.2

Model layer (ActiveModel equivalent)
Documentation
use serde_json::Value;

use super::{Validator, ValidatorOptions};
use crate::errors::{ErrorType, Errors};

/// Validates that a value does not belong to a forbidden set.
#[derive(Debug, Clone, Default)]
pub struct ExclusionValidator {
    values: Vec<Value>,
    message: Option<String>,
    pub(crate) options: ValidatorOptions,
}

impl ExclusionValidator {
    /// Creates a new exclusion validator.
    #[must_use]
    pub fn new<T>(values: T) -> Self
    where
        T: Into<Vec<Value>>,
    {
        Self {
            values: values.into(),
            message: None,
            options: ValidatorOptions::default(),
        }
    }

    crate::validations::impl_common_validator_methods!();

    /// Overrides the default exclusion message.
    #[must_use]
    pub fn message(mut self, message: impl Into<String>) -> Self {
        self.message = Some(message.into());
        self
    }

    fn error_message(&self) -> String {
        self.message
            .clone()
            .unwrap_or_else(|| String::from("is reserved"))
    }
}

impl Validator for ExclusionValidator {
    fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
        if value.is_some_and(|candidate| self.values.iter().any(|forbidden| forbidden == candidate))
        {
            errors.add(attribute, ErrorType::Exclusion, self.error_message());
        }
    }

    fn name(&self) -> &str {
        "exclusion"
    }

    fn options(&self) -> &ValidatorOptions {
        &self.options
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use serde_json::json;

    use super::ExclusionValidator;
    use crate::{
        errors::{ErrorType, Errors},
        validations::{ValidationSet, Validator},
    };

    fn validate_exclusion(
        validator: ExclusionValidator,
        value: Option<serde_json::Value>,
    ) -> Errors {
        let mut errors = Errors::new();
        validator.validate("field", value.as_ref(), &mut errors);
        errors
    }

    #[test]
    fn rejects_forbidden_value() {
        let validator = ExclusionValidator::new(vec![json!("admin")]);
        let mut errors = Errors::new();

        validator.validate("role", Some(&json!("admin")), &mut errors);

        assert_eq!(errors.on("role")[0].error_type, ErrorType::Exclusion);
    }

    #[test]
    fn allows_non_member() {
        let validator = ExclusionValidator::new(vec![json!("admin")]);
        let mut errors = Errors::new();

        validator.validate("role", Some(&json!("user")), &mut errors);

        assert!(errors.is_empty());
    }

    #[test]
    fn allows_nil_value() {
        let validator = ExclusionValidator::new(vec![json!("admin")]);
        let mut errors = Errors::new();

        validator.validate("role", None, &mut errors);

        assert!(errors.is_empty());
    }

    #[test]
    fn custom_message_is_used() {
        let validator = ExclusionValidator::new(vec![json!("root")]).message("blocked");
        let mut errors = Errors::new();

        validator.validate("username", Some(&json!("root")), &mut errors);

        assert_eq!(errors.on("username")[0].message, "blocked");
    }

    #[test]
    fn rejects_null_when_null_is_forbidden() {
        let errors = validate_exclusion(
            ExclusionValidator::new(vec![json!(null)]),
            Some(json!(null)),
        );

        assert_eq!(errors.on("field")[0].error_type, ErrorType::Exclusion);
    }

    #[test]
    fn allows_null_when_null_is_not_forbidden() {
        let errors = validate_exclusion(ExclusionValidator::new(vec![json!(1)]), Some(json!(null)));

        assert!(errors.is_empty());
    }

    #[test]
    fn allows_any_value_when_forbidden_set_is_empty() {
        let errors = validate_exclusion(ExclusionValidator::new(Vec::new()), Some(json!("guest")));

        assert!(errors.is_empty());
    }

    #[test]
    fn rejects_forbidden_object_by_exact_equality() {
        let errors = validate_exclusion(
            ExclusionValidator::new(vec![json!({ "kind": "vip", "level": 2 })]),
            Some(json!({ "kind": "vip", "level": 2 })),
        );

        assert_eq!(errors.on("field")[0].error_type, ErrorType::Exclusion);
    }

    #[test]
    fn rejects_forbidden_array_by_exact_equality() {
        let errors = validate_exclusion(
            ExclusionValidator::new(vec![json!([1, 2])]),
            Some(json!([1, 2])),
        );

        assert_eq!(errors.on("field")[0].error_type, ErrorType::Exclusion);
    }

    #[test]
    fn distinguishes_strings_from_numbers() {
        let errors = validate_exclusion(ExclusionValidator::new(vec![json!(1)]), Some(json!("1")));

        assert!(errors.is_empty());
    }

    #[test]
    fn allow_nil_skips_missing_values_in_validation_set() {
        let mut set = ValidationSet::new();
        set.add(
            "role",
            ExclusionValidator::new(vec![json!("admin")]).allow_nil(),
        );
        let mut errors = Errors::new();

        let _ = set.validate(&|_| None, &mut errors);

        assert!(errors.is_empty());
    }

    #[test]
    fn allow_blank_skips_blank_values_in_validation_set() {
        let mut set = ValidationSet::new();
        set.add(
            "role",
            ExclusionValidator::new(vec![json!("admin")]).allow_blank(),
        );
        let attrs = HashMap::from([("role".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_does_not_skip_non_blank_forbidden_values() {
        let mut set = ValidationSet::new();
        set.add(
            "role",
            ExclusionValidator::new(vec![json!("admin")]).allow_blank(),
        );
        let attrs = HashMap::from([("role".to_string(), json!("admin"))]);
        let mut errors = Errors::new();

        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);

        assert_eq!(errors.on("role")[0].error_type, ErrorType::Exclusion);
    }

    #[test]
    fn multiple_forbidden_values_still_report_single_error() {
        let errors = validate_exclusion(
            ExclusionValidator::new(vec![json!("admin"), json!("root")]),
            Some(json!("root")),
        );

        assert_eq!(errors.count(), 1);
    }

    #[test]
    fn full_message_humanizes_attribute_name() {
        let mut errors = Errors::new();
        ExclusionValidator::new(vec![json!("root")]).validate(
            "user_role",
            Some(&json!("root")),
            &mut errors,
        );

        assert_eq!(
            errors.full_messages(),
            vec!["User role is reserved".to_string()]
        );
    }
}