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 is explicitly accepted.
#[derive(Debug, Clone)]
pub struct AcceptanceValidator {
    accepted_values: Vec<Value>,
    message: Option<String>,
    pub(crate) options: ValidatorOptions,
}

impl Default for AcceptanceValidator {
    fn default() -> Self {
        Self {
            accepted_values: vec![
                Value::Bool(true),
                Value::String("1".to_string()),
                Value::String("yes".to_string()),
            ],
            message: None,
            options: ValidatorOptions {
                allow_nil: true,
                ..ValidatorOptions::default()
            },
        }
    }
}

impl AcceptanceValidator {
    /// Creates a new acceptance validator with the default accepted values.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    crate::validations::impl_common_validator_methods!();

    /// Replaces the accepted value list.
    #[must_use]
    pub fn accept<T>(mut self, values: T) -> Self
    where
        T: Into<Vec<Value>>,
    {
        self.accepted_values = values.into();
        self
    }

    /// Overrides the default acceptance 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("must be accepted"))
    }
}

impl Validator for AcceptanceValidator {
    fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
        if value.is_some_and(|candidate| {
            !self
                .accepted_values
                .iter()
                .any(|accepted| accepted == candidate)
        }) {
            errors.add(attribute, ErrorType::Accepted, self.error_message());
        }
    }

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

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

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

    use serde_json::json;

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

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

    #[test]
    fn nil_value_is_ignored() {
        let validator = AcceptanceValidator::new();
        let mut errors = Errors::new();

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

        assert!(errors.is_empty());
    }

    #[test]
    fn default_true_is_accepted() {
        let validator = AcceptanceValidator::new();
        let mut errors = Errors::new();

        validator.validate("terms", Some(&json!(true)), &mut errors);

        assert!(errors.is_empty());
    }

    #[test]
    fn default_yes_is_accepted() {
        let validator = AcceptanceValidator::new();
        let mut errors = Errors::new();

        validator.validate("terms", Some(&json!("yes")), &mut errors);

        assert!(errors.is_empty());
    }

    #[test]
    fn blank_string_is_rejected() {
        let validator = AcceptanceValidator::new();
        let mut errors = Errors::new();

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

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

    #[test]
    fn custom_accept_values_are_used() {
        let validator = AcceptanceValidator::new().accept(vec![json!(1), json!("I agree")]);
        let mut errors = Errors::new();

        validator.validate("terms", Some(&json!(1)), &mut errors);
        validator.validate("terms", Some(&json!("nope")), &mut errors);

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

    #[test]
    fn validation_set_skips_nil_when_default_allow_nil_applies() {
        let mut set = ValidationSet::new();
        set.add("terms", AcceptanceValidator::new());
        let mut errors = Errors::new();

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

        assert!(errors.is_empty());
    }

    #[test]
    fn default_string_one_is_accepted() {
        let errors = validate_acceptance(AcceptanceValidator::new(), Some(json!("1")));

        assert!(errors.is_empty());
    }

    #[test]
    fn false_is_rejected_by_default() {
        let errors = validate_acceptance(AcceptanceValidator::new(), Some(json!(false)));

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

    #[test]
    fn no_is_rejected_by_default() {
        let errors = validate_acceptance(AcceptanceValidator::new(), Some(json!("no")));

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

    #[test]
    fn numeric_one_is_not_equal_to_default_string_one() {
        let errors = validate_acceptance(AcceptanceValidator::new(), Some(json!(1)));

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

    #[test]
    fn custom_accept_values_replace_defaults() {
        let errors = validate_acceptance(
            AcceptanceValidator::new().accept(vec![json!("I agree")]),
            Some(json!(true)),
        );

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

    #[test]
    fn custom_message_is_used() {
        let errors = validate_acceptance(
            AcceptanceValidator::new().message("must be accepted explicitly"),
            Some(json!(false)),
        );

        assert_eq!(errors.on("field")[0].message, "must be accepted explicitly");
    }

    #[test]
    fn allow_blank_skips_blank_values_in_validation_set() {
        let mut set = ValidationSet::new();
        set.add("terms", AcceptanceValidator::new().allow_blank());
        let attrs = HashMap::from([("terms".to_string(), json!("   "))]);
        let mut errors = Errors::new();

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

        assert!(errors.is_empty());
    }

    #[test]
    fn custom_boolean_accept_values_are_supported() {
        let errors = validate_acceptance(
            AcceptanceValidator::new().accept(vec![json!(false)]),
            Some(json!(false)),
        );

        assert!(errors.is_empty());
    }

    #[test]
    fn validation_set_adds_error_for_rejected_value() {
        let mut set = ValidationSet::new();
        set.add("terms", AcceptanceValidator::new());
        let attrs = HashMap::from([("terms".to_string(), json!("no"))]);
        let mut errors = Errors::new();

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

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

    #[test]
    fn full_message_humanizes_attribute_name() {
        let mut errors = Errors::new();
        AcceptanceValidator::new().validate("terms_of_service", Some(&json!(false)), &mut errors);

        assert_eq!(
            errors.full_messages(),
            vec!["Terms of service must be accepted".to_string()],
        );
    }

    #[test]
    fn explicit_null_values_are_rejected_by_direct_validation() {
        let errors = validate_acceptance(AcceptanceValidator::new(), Some(json!(null)));

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

    #[test]
    fn empty_accept_list_rejects_present_values() {
        let errors = validate_acceptance(
            AcceptanceValidator::new().accept(Vec::new()),
            Some(json!(true)),
        );

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