rustrails-model 0.1.2

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

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

/// Validates that an attribute matches a sibling confirmation attribute.
#[derive(Debug, Clone)]
pub struct ConfirmationValidator {
    confirmation_attribute: String,
    case_sensitive: bool,
    message: Option<String>,
    pub(crate) options: ValidatorOptions,
}

impl ConfirmationValidator {
    /// Creates a new confirmation validator.
    #[must_use]
    pub fn new(confirmation_attribute: &str) -> Self {
        Self {
            confirmation_attribute: confirmation_attribute.to_owned(),
            case_sensitive: true,
            message: None,
            options: ValidatorOptions::default(),
        }
    }

    crate::validations::impl_common_validator_methods!();

    /// Enables or disables case-sensitive comparison for string values.
    #[must_use]
    pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
        self.case_sensitive = case_sensitive;
        self
    }

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

    fn error_message(&self, attribute: &str) -> String {
        self.message
            .clone()
            .unwrap_or_else(|| format!("doesn't match {}", humanize(attribute)))
    }

    fn matches(&self, value: Option<&Value>, confirmation: &Value) -> bool {
        match (value, confirmation) {
            (Some(Value::String(left)), Value::String(right)) if !self.case_sensitive => {
                left.to_lowercase() == right.to_lowercase()
            }
            (Some(left), right) => left == right,
            (None, Value::Null) => true,
            _ => false,
        }
    }
}

impl Validator for ConfirmationValidator {
    fn validate(&self, _attribute: &str, _value: Option<&Value>, _errors: &mut Errors) {}

    fn validate_with_attrs(
        &self,
        attribute: &str,
        value: Option<&Value>,
        attrs: &dyn Fn(&str) -> Option<Value>,
        errors: &mut Errors,
    ) {
        let Some(confirmation) = attrs(&self.confirmation_attribute) else {
            return;
        };

        if confirmation.is_null() {
            return;
        }

        if !self.matches(value, &confirmation) {
            errors.add(
                &self.confirmation_attribute,
                ErrorType::Confirmation,
                self.error_message(attribute),
            );
        }
    }

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

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

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

    use serde_json::json;

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

    fn validate_confirmation(
        validator: &ConfirmationValidator,
        attribute: &str,
        value: Option<&serde_json::Value>,
        attrs: &HashMap<String, serde_json::Value>,
    ) -> Errors {
        let mut errors = Errors::new();
        validator.validate_with_attrs(
            attribute,
            value,
            &|name| attrs.get(name).cloned(),
            &mut errors,
        );
        errors
    }

    #[test]
    fn missing_confirmation_attribute_is_ignored() {
        let validator = ConfirmationValidator::new("password_confirmation");
        let attrs = HashMap::from([(String::from("password"), json!("secret"))]);
        let mut errors = Errors::new();

        validator.validate_with_attrs(
            "password",
            attrs.get("password"),
            &|name| attrs.get(name).cloned(),
            &mut errors,
        );

        assert!(errors.is_empty());
    }

    #[test]
    fn mismatch_adds_error_on_confirmation_attribute() {
        let validator = ConfirmationValidator::new("password_confirmation");
        let attrs = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("password_confirmation"), json!("other")),
        ]);
        let mut errors = Errors::new();

        validator.validate_with_attrs(
            "password",
            attrs.get("password"),
            &|name| attrs.get(name).cloned(),
            &mut errors,
        );

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

    #[test]
    fn matching_values_pass() {
        let validator = ConfirmationValidator::new("email_confirmation");
        let attrs = HashMap::from([
            (String::from("email"), json!("alice@example.com")),
            (
                String::from("email_confirmation"),
                json!("alice@example.com"),
            ),
        ]);
        let mut errors = Errors::new();

        validator.validate_with_attrs(
            "email",
            attrs.get("email"),
            &|name| attrs.get(name).cloned(),
            &mut errors,
        );

        assert!(errors.is_empty());
    }

    #[test]
    fn case_insensitive_strings_can_match() {
        let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(false);
        let attrs = HashMap::from([
            (String::from("email"), json!("Alice@example.com")),
            (
                String::from("email_confirmation"),
                json!("alice@example.com"),
            ),
        ]);
        let mut errors = Errors::new();

        validator.validate_with_attrs(
            "email",
            attrs.get("email"),
            &|name| attrs.get(name).cloned(),
            &mut errors,
        );

        assert!(errors.is_empty());
    }

    #[test]
    fn validation_set_uses_sibling_attribute_lookup() {
        let mut set = ValidationSet::new();
        set.add(
            "password",
            ConfirmationValidator::new("password_confirmation"),
        );
        let attrs = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("password_confirmation"), json!("other")),
        ]);
        let mut errors = Errors::new();

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

        assert_eq!(
            errors.on("password_confirmation")[0].message,
            "doesn't match Password"
        );
    }

    #[test]
    fn null_confirmation_is_ignored() {
        let validator = ConfirmationValidator::new("password_confirmation");
        let attrs = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("password_confirmation"), json!(null)),
        ]);

        let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);

        assert!(errors.is_empty());
    }

    #[test]
    fn missing_primary_value_with_non_null_confirmation_fails() {
        let validator = ConfirmationValidator::new("password_confirmation");
        let attrs = HashMap::from([(String::from("password_confirmation"), json!("secret"))]);

        let errors = validate_confirmation(&validator, "password", None, &attrs);

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

    #[test]
    fn custom_message_override_is_used() {
        let validator = ConfirmationValidator::new("password_confirmation").message("must match");
        let attrs = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("password_confirmation"), json!("other")),
        ]);

        let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);

        assert_eq!(errors.on("password_confirmation")[0].message, "must match");
    }

    #[test]
    fn explicit_case_sensitive_matching_rejects_casing_difference() {
        let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(true);
        let attrs = HashMap::from([
            (String::from("email"), json!("Alice@example.com")),
            (
                String::from("email_confirmation"),
                json!("alice@example.com"),
            ),
        ]);

        let errors = validate_confirmation(&validator, "email", attrs.get("email"), &attrs);

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

    #[test]
    fn case_insensitive_matching_still_rejects_different_strings() {
        let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(false);
        let attrs = HashMap::from([
            (String::from("email"), json!("alice@example.com")),
            (String::from("email_confirmation"), json!("bob@example.com")),
        ]);

        let errors = validate_confirmation(&validator, "email", attrs.get("email"), &attrs);

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

    #[test]
    fn matching_boolean_values_pass() {
        let validator = ConfirmationValidator::new("published_confirmation");
        let attrs = HashMap::from([
            (String::from("published"), json!(true)),
            (String::from("published_confirmation"), json!(true)),
        ]);

        let errors = validate_confirmation(&validator, "published", attrs.get("published"), &attrs);

        assert!(errors.is_empty());
    }

    #[test]
    fn mismatched_boolean_values_fail() {
        let validator = ConfirmationValidator::new("published_confirmation");
        let attrs = HashMap::from([
            (String::from("published"), json!(true)),
            (String::from("published_confirmation"), json!(false)),
        ]);

        let errors = validate_confirmation(&validator, "published", attrs.get("published"), &attrs);

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

    #[test]
    fn direct_validate_is_a_no_op() {
        let validator = ConfirmationValidator::new("password_confirmation");
        let mut errors = Errors::new();

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

        assert!(errors.is_empty());
    }

    #[test]
    fn validation_set_passes_when_confirmation_matches() {
        let mut set = ValidationSet::new();
        set.add(
            "password",
            ConfirmationValidator::new("password_confirmation"),
        );
        let attrs = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("password_confirmation"), json!("secret")),
        ]);
        let mut errors = Errors::new();

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

        assert!(errors.is_empty());
    }

    #[test]
    fn full_message_humanizes_confirmation_attribute() {
        let validator = ConfirmationValidator::new("password_confirmation");
        let attrs = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("password_confirmation"), json!("other")),
        ]);

        let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);

        assert_eq!(
            errors.full_messages(),
            vec!["Password confirmation doesn't match Password".to_string()],
        );
    }
}