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 belongs to an allowed set.
#[derive(Debug, Clone, Default)]
pub struct InclusionValidator {
    values: Vec<Value>,
    message: Option<String>,
    pub(crate) options: ValidatorOptions,
}

impl InclusionValidator {
    /// Creates a new inclusion 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 inclusion 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 not included in the list"))
    }
}

impl Validator for InclusionValidator {
    fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
        if value.is_none_or(|candidate| !self.values.iter().any(|allowed| allowed == candidate)) {
            errors.add(attribute, ErrorType::Inclusion, self.error_message());
        }
    }

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

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

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

    use serde_json::json;

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

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

    #[test]
    fn allows_member_of_set() {
        let validator = InclusionValidator::new(vec![json!("admin"), json!("editor")]);
        let mut errors = Errors::new();

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

        assert!(errors.is_empty());
    }

    #[test]
    fn rejects_value_outside_set() {
        let validator = InclusionValidator::new(vec![json!(1), json!(2)]);
        let mut errors = Errors::new();

        validator.validate("priority", Some(&json!(3)), &mut errors);

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

    #[test]
    fn rejects_nil_value() {
        let validator = InclusionValidator::new(vec![json!(true), json!(false)]);
        let mut errors = Errors::new();

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

        assert_eq!(
            errors.on("published")[0].message,
            "is not included in the list"
        );
    }

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

        validator.validate("currency", Some(&json!("eur")), &mut errors);

        assert_eq!(errors.on("currency")[0].message, "unsupported");
    }

    #[test]
    fn rejects_empty_allowed_list() {
        let errors = validate_inclusion(InclusionValidator::new(Vec::new()), Some(json!("usd")));

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

    #[test]
    fn allows_null_when_null_is_in_allowed_list() {
        let errors = validate_inclusion(
            InclusionValidator::new(vec![json!(null)]),
            Some(json!(null)),
        );

        assert!(errors.is_empty());
    }

    #[test]
    fn rejects_null_when_null_is_not_in_allowed_list() {
        let errors = validate_inclusion(InclusionValidator::new(vec![json!(1)]), Some(json!(null)));

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

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

        assert!(errors.is_empty());
    }

    #[test]
    fn allows_array_members_by_exact_equality() {
        let errors = validate_inclusion(
            InclusionValidator::new(vec![json!([1, 2]), json!([3, 4])]),
            Some(json!([1, 2])),
        );

        assert!(errors.is_empty());
    }

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

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

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

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

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

    #[test]
    fn range_like_value_lists_are_supported() {
        let errors = validate_inclusion(
            InclusionValidator::new(vec![json!(1), json!(2), json!(3), json!(4)]),
            Some(json!(3)),
        );

        assert!(errors.is_empty());
    }

    #[test]
    fn full_message_humanizes_attribute_name() {
        let mut errors = Errors::new();
        InclusionValidator::new(vec![json!("usd")]).validate(
            "preferred_currency",
            Some(&json!("eur")),
            &mut errors,
        );

        assert_eq!(
            errors.full_messages(),
            vec!["Preferred currency is not included in the list".to_string()],
        );
    }
}