rustrails-model 0.1.2

Model layer (ActiveModel equivalent)
Documentation
use std::collections::HashMap;

use serde_json::Value;

use crate::{AttributeError, Attributes, Conversion, Errors, ModelNaming, Serialization};

/// The minimum viable model interface shared by RustRails model types.
pub trait Model:
    Attributes + ModelNaming + Conversion + Serialization + Sized + Send + Sync
{
    /// Creates a new instance with default values.
    fn new() -> Self;

    /// Creates a new instance and applies the provided attributes.
    fn initialize(attrs: HashMap<String, Value>) -> Result<Self, AttributeError> {
        let mut model = Self::new();
        model.assign_attributes(attrs)?;
        Ok(model)
    }

    /// Returns `true` when the model considers itself valid.
    fn is_valid(&self) -> bool {
        true
    }

    /// Returns `true` when the model considers itself invalid.
    fn is_invalid(&self) -> bool {
        !self.is_valid()
    }

    /// Returns the model's current error collection.
    fn errors(&self) -> &Errors;

    /// Returns the mutable error collection for validation updates.
    fn errors_mut(&mut self) -> &mut Errors;

    /// Validates the model and updates its error collection.
    fn validate(&mut self) -> bool {
        self.errors_mut().clear();
        true
    }
}

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

    use serde_json::{Value, json};

    use super::Model;
    use crate::{
        AttributeError, Attributes, Conversion, ErrorType, Errors, ModelNaming, Serialization,
    };

    #[derive(Debug, Default, Clone)]
    struct TestUser {
        id: Option<u64>,
        name: String,
        errors: Errors,
    }

    impl Attributes for TestUser {
        fn attribute_names() -> &'static [&'static str] {
            &["id", "name"]
        }

        fn read_attribute(&self, name: &str) -> Option<Value> {
            match name {
                "id" => Some(self.id.map_or(Value::Null, Value::from)),
                "name" => Some(Value::String(self.name.clone())),
                _ => None,
            }
        }

        fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
            match (name, value) {
                ("id", Value::Null) => {
                    self.id = None;
                    Ok(())
                }
                ("id", Value::Number(number)) => {
                    let id = number
                        .as_u64()
                        .ok_or_else(|| AttributeError::TypeMismatch {
                            attribute: "id".to_string(),
                            expected: "u64".to_string(),
                            actual: "number".to_string(),
                        })?;
                    self.id = Some(id);
                    Ok(())
                }
                ("name", Value::String(name)) => {
                    self.name = name;
                    Ok(())
                }
                ("id", other) => Err(AttributeError::TypeMismatch {
                    attribute: "id".to_string(),
                    expected: "u64".to_string(),
                    actual: other.to_string(),
                }),
                ("name", other) => Err(AttributeError::TypeMismatch {
                    attribute: "name".to_string(),
                    expected: "string".to_string(),
                    actual: other.to_string(),
                }),
                (unknown, _) => Err(AttributeError::UnknownAttribute(unknown.to_string())),
            }
        }

        fn assign_attributes(
            &mut self,
            attrs: HashMap<String, Value>,
        ) -> Result<(), AttributeError> {
            for (name, value) in attrs {
                self.write_attribute(&name, value)?;
            }
            Ok(())
        }

        fn attributes(&self) -> HashMap<String, Value> {
            let mut attributes = HashMap::new();
            attributes.insert("id".to_string(), self.id.map_or(Value::Null, Value::from));
            attributes.insert("name".to_string(), Value::String(self.name.clone()));
            attributes
        }
    }

    impl ModelNaming for TestUser {}
    impl Conversion for TestUser {}

    impl Model for TestUser {
        fn new() -> Self {
            Self {
                id: None,
                name: String::new(),
                errors: Errors::new(),
            }
        }

        fn errors(&self) -> &Errors {
            &self.errors
        }

        fn errors_mut(&mut self) -> &mut Errors {
            &mut self.errors
        }

        fn is_valid(&self) -> bool {
            self.errors.is_empty()
        }

        fn validate(&mut self) -> bool {
            self.errors.clear();
            if self.name.trim().is_empty() {
                self.errors.add("name", ErrorType::Blank, "can't be blank");
            }
            self.errors.is_empty()
        }
    }

    #[test]
    fn initialize_assigns_attributes_to_new_model() {
        let model = TestUser::initialize(HashMap::from([
            ("id".to_string(), json!(7)),
            ("name".to_string(), json!("Alice")),
        ]))
        .expect("test data should initialize");

        assert_eq!(model.id, Some(7));
        assert_eq!(model.name, "Alice");
    }

    #[test]
    fn validity_defaults_follow_current_errors() {
        let mut model = TestUser::new();

        assert!(model.is_valid());
        model
            .errors_mut()
            .add("name", ErrorType::Blank, "can't be blank");
        assert!(model.is_invalid());
    }

    #[test]
    fn validate_populates_errors() {
        let mut model = TestUser::new();

        assert!(!model.validate());
        assert!(model.is_invalid());

        model.name = "Alice".to_string();
        assert!(model.validate());
        assert!(model.is_valid());
    }

    #[test]
    fn model_trait_composes_conversion_and_serialization_defaults() {
        let model = TestUser::initialize(HashMap::from([
            ("id".to_string(), json!(12)),
            ("name".to_string(), json!("Alice")),
        ]))
        .expect("test data should initialize");

        assert_eq!(model.to_param(), Some("12".to_string()));
        assert_eq!(model.to_partial_path(), "test_users/test_user");
        assert_eq!(
            serde_json::from_str::<Value>(&model.to_json(None)).expect("model JSON should parse"),
            json!({"id": 12, "name": "Alice"})
        );
    }
    #[derive(Debug, Default, Clone)]
    struct MinimalModel {
        errors: Errors,
    }

    impl Attributes for MinimalModel {
        fn attribute_names() -> &'static [&'static str] {
            &[]
        }

        fn read_attribute(&self, _name: &str) -> Option<Value> {
            None
        }

        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
            Err(AttributeError::UnknownAttribute(name.to_string()))
        }

        fn attributes(&self) -> HashMap<String, Value> {
            HashMap::new()
        }
    }

    impl ModelNaming for MinimalModel {}
    impl Conversion for MinimalModel {}

    impl Model for MinimalModel {
        fn new() -> Self {
            Self {
                errors: Errors::new(),
            }
        }

        fn errors(&self) -> &Errors {
            &self.errors
        }

        fn errors_mut(&mut self) -> &mut Errors {
            &mut self.errors
        }
    }

    #[test]
    fn initialize_propagates_attribute_errors() {
        let result = TestUser::initialize(HashMap::from([(
            "email".to_string(),
            json!("alice@example.com"),
        )]));

        assert!(matches!(
            result,
            Err(AttributeError::UnknownAttribute(attribute)) if attribute == "email"
        ));
    }

    #[test]
    fn default_is_valid_returns_true_without_validation_override() {
        let mut model = MinimalModel::new();
        model
            .errors_mut()
            .add("name", ErrorType::Blank, "can't be blank");

        assert!(model.is_valid());
        assert!(!model.is_invalid());
    }

    #[test]
    fn default_validate_clears_existing_errors_and_returns_true() {
        let mut model = MinimalModel::new();
        model
            .errors_mut()
            .add("name", ErrorType::Blank, "can't be blank");

        assert!(model.validate());
        assert!(model.errors().is_empty());
    }

    #[test]
    fn custom_model_can_override_validity_checks() {
        let mut model = TestUser::new();
        assert!(model.is_valid());

        model.errors.add("name", ErrorType::Blank, "can't be blank");
        assert!(model.is_invalid());
    }
}