use std::collections::HashMap;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AttributeError {
#[error("unknown attribute: {0}")]
UnknownAttribute(String),
#[error("type mismatch for {attribute}: expected {expected}, got {actual}")]
TypeMismatch {
attribute: String,
expected: String,
actual: String,
},
#[error("attribute {0} is readonly")]
Readonly(String),
}
pub trait Attributes {
fn attribute_names() -> &'static [&'static str];
fn read_attribute(&self, name: &str) -> Option<Value>;
fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError>;
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>;
fn has_attribute(name: &str) -> bool {
Self::attribute_names().contains(&name)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use rustrails_support::ignored_rails_test;
use serde_json::{Value, json};
use super::{AttributeError, Attributes};
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestUser {
id: u64,
name: String,
active: bool,
}
impl TestUser {
fn new() -> Self {
Self {
id: 1,
name: "Alice".to_owned(),
active: true,
}
}
}
impl Attributes for TestUser {
fn attribute_names() -> &'static [&'static str] {
&["id", "name", "active"]
}
fn read_attribute(&self, name: &str) -> Option<Value> {
match name {
"id" => Some(Value::from(self.id)),
"name" => Some(Value::String(self.name.clone())),
"active" => Some(Value::Bool(self.active)),
_ => None,
}
}
fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
match name {
"id" => Err(AttributeError::Readonly(name.to_owned())),
"name" => match value {
Value::String(text) => {
self.name = text;
Ok(())
}
other => Err(AttributeError::TypeMismatch {
attribute: name.to_owned(),
expected: "string".to_owned(),
actual: value_kind(&other).to_owned(),
}),
},
"active" => match value {
Value::Bool(flag) => {
self.active = flag;
Ok(())
}
other => Err(AttributeError::TypeMismatch {
attribute: name.to_owned(),
expected: "boolean".to_owned(),
actual: value_kind(&other).to_owned(),
}),
},
_ => Err(AttributeError::UnknownAttribute(name.to_owned())),
}
}
fn attributes(&self) -> HashMap<String, Value> {
HashMap::from([
("id".to_owned(), Value::from(self.id)),
("name".to_owned(), Value::String(self.name.clone())),
("active".to_owned(), Value::Bool(self.active)),
])
}
}
fn value_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[test]
fn attribute_names_and_has_attribute_report_known_fields() {
assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
assert!(TestUser::has_attribute("name"));
assert!(!TestUser::has_attribute("email"));
}
#[test]
fn read_attribute_returns_dynamic_values() {
let user = TestUser::new();
assert_eq!(user.read_attribute("id"), Some(Value::from(1_u64)));
assert_eq!(
user.read_attribute("name"),
Some(Value::String("Alice".to_owned()))
);
assert_eq!(user.read_attribute("active"), Some(Value::Bool(true)));
assert_eq!(user.read_attribute("missing"), None);
}
#[test]
fn write_attribute_updates_supported_fields() {
let mut user = TestUser::new();
let result = user.write_attribute("name", Value::String("Bob".to_owned()));
assert_eq!(result, Ok(()));
assert_eq!(user.name, "Bob");
assert_eq!(
user.read_attribute("name"),
Some(Value::String("Bob".to_owned()))
);
}
#[test]
fn write_attribute_rejects_unknown_attributes() {
let mut user = TestUser::new();
let result = user.write_attribute("email", Value::String("a@example.test".to_owned()));
assert_eq!(
result,
Err(AttributeError::UnknownAttribute("email".to_owned()))
);
}
#[test]
fn write_attribute_reports_type_mismatch() {
let mut user = TestUser::new();
let result = user.write_attribute("active", json!("yes"));
assert_eq!(
result,
Err(AttributeError::TypeMismatch {
attribute: "active".to_owned(),
expected: "boolean".to_owned(),
actual: "string".to_owned(),
})
);
}
#[test]
fn write_attribute_rejects_readonly_attributes() {
let mut user = TestUser::new();
let result = user.write_attribute("id", Value::from(2_u64));
assert_eq!(result, Err(AttributeError::Readonly("id".to_owned())));
}
#[test]
fn assign_attributes_updates_multiple_fields() {
let mut user = TestUser::new();
let attrs = HashMap::from([
("name".to_owned(), Value::String("Carol".to_owned())),
("active".to_owned(), Value::Bool(false)),
]);
let result = user.assign_attributes(attrs);
assert_eq!(result, Ok(()));
assert_eq!(user.name, "Carol");
assert!(!user.active);
}
#[test]
fn attributes_returns_complete_snapshot() {
let user = TestUser::new();
let attrs = user.attributes();
assert_eq!(attrs.get("id"), Some(&Value::from(1_u64)));
assert_eq!(attrs.get("name"), Some(&Value::String("Alice".to_owned())));
assert_eq!(attrs.get("active"), Some(&Value::Bool(true)));
}
#[test]
fn assign_attributes_rejects_unknown_keys() {
let mut user = TestUser::new();
let result = user.assign_attributes(HashMap::from([(
"email".to_owned(),
Value::String("alice@example.test".to_owned()),
)]));
assert_eq!(
result,
Err(AttributeError::UnknownAttribute("email".to_owned()))
);
}
#[test]
fn assign_attributes_rejects_type_mismatches() {
let mut user = TestUser::new();
let result = user.assign_attributes(HashMap::from([(
"active".to_owned(),
Value::String("yes".to_owned()),
)]));
assert_eq!(
result,
Err(AttributeError::TypeMismatch {
attribute: "active".to_owned(),
expected: "boolean".to_owned(),
actual: "string".to_owned(),
})
);
}
#[test]
fn attributes_snapshot_reflects_successful_writes() {
let mut user = TestUser::new();
let result = user.write_attribute("name", Value::String("Dana".to_owned()));
assert_eq!(result, Ok(()));
assert_eq!(
user.attributes().get("name"),
Some(&Value::String("Dana".to_owned()))
);
}
#[test]
fn has_attribute_is_case_sensitive() {
assert!(TestUser::has_attribute("name"));
assert!(!TestUser::has_attribute("Name"));
}
#[test]
fn assign_attributes_with_empty_map_is_a_noop() {
let mut user = TestUser::new();
let result = user.assign_attributes(HashMap::new());
assert_eq!(result, Ok(()));
assert_eq!(user, TestUser::new());
}
#[test]
fn attribute_names_order_is_stable() {
assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
}
#[test]
fn has_attribute_rejects_empty_name() {
assert!(!TestUser::has_attribute(""));
}
#[test]
fn read_attribute_reflects_updated_name_after_write() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("name", Value::String("Beatrice".to_owned())),
Ok(())
);
assert_eq!(
user.read_attribute("name"),
Some(Value::String("Beatrice".to_owned()))
);
}
#[test]
fn read_attribute_reflects_updated_active_after_write() {
let mut user = TestUser::new();
assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
assert_eq!(user.read_attribute("active"), Some(Value::Bool(false)));
}
#[test]
fn write_attribute_updates_active_flag() {
let mut user = TestUser::new();
assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
assert!(!user.active);
}
#[test]
fn write_attribute_name_rejects_numbers() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("name", Value::from(42)),
Err(AttributeError::TypeMismatch {
attribute: "name".to_owned(),
expected: "string".to_owned(),
actual: "number".to_owned(),
})
);
}
#[test]
fn write_attribute_name_rejects_null() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("name", Value::Null),
Err(AttributeError::TypeMismatch {
attribute: "name".to_owned(),
expected: "string".to_owned(),
actual: "null".to_owned(),
})
);
}
#[test]
fn write_attribute_name_rejects_arrays() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("name", json!(["Alice", "Bob"])),
Err(AttributeError::TypeMismatch {
attribute: "name".to_owned(),
expected: "string".to_owned(),
actual: "array".to_owned(),
})
);
}
#[test]
fn write_attribute_name_rejects_objects() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("name", json!({ "first": "Alice" })),
Err(AttributeError::TypeMismatch {
attribute: "name".to_owned(),
expected: "string".to_owned(),
actual: "object".to_owned(),
})
);
}
#[test]
fn write_attribute_active_rejects_numbers() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("active", Value::from(1)),
Err(AttributeError::TypeMismatch {
attribute: "active".to_owned(),
expected: "boolean".to_owned(),
actual: "number".to_owned(),
})
);
}
#[test]
fn assign_attributes_rejects_readonly_keys() {
let mut user = TestUser::new();
let result = user.assign_attributes(HashMap::from([("id".to_owned(), Value::from(2_u64))]));
assert_eq!(result, Err(AttributeError::Readonly("id".to_owned())));
}
#[test]
fn attributes_snapshot_reflects_boolean_update() {
let mut user = TestUser::new();
assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
assert_eq!(user.attributes().get("active"), Some(&Value::Bool(false)));
}
#[test]
fn unknown_attribute_error_display_is_human_readable() {
assert_eq!(
AttributeError::UnknownAttribute("email".to_owned()).to_string(),
"unknown attribute: email"
);
}
#[test]
fn type_mismatch_error_display_is_human_readable() {
assert_eq!(
AttributeError::TypeMismatch {
attribute: "active".to_owned(),
expected: "boolean".to_owned(),
actual: "string".to_owned(),
}
.to_string(),
"type mismatch for active: expected boolean, got string"
);
}
#[test]
fn readonly_error_display_is_human_readable() {
assert_eq!(
AttributeError::Readonly("id".to_owned()).to_string(),
"attribute id is readonly"
);
}
#[test]
fn failed_write_keeps_previous_successful_value() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("name", Value::String("Bob".to_owned())),
Ok(())
);
assert_eq!(
user.write_attribute("name", Value::from(42)),
Err(AttributeError::TypeMismatch {
attribute: "name".to_owned(),
expected: "string".to_owned(),
actual: "number".to_owned(),
})
);
assert_eq!(user.name, "Bob");
assert_eq!(
user.read_attribute("name"),
Some(Value::String("Bob".to_owned()))
);
}
#[test]
fn failed_assign_does_not_rollback_previous_successful_writes() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("name", Value::String("Bob".to_owned())),
Ok(())
);
let result = user.assign_attributes(HashMap::from([(
"active".to_owned(),
Value::String("yes".to_owned()),
)]));
assert_eq!(
result,
Err(AttributeError::TypeMismatch {
attribute: "active".to_owned(),
expected: "boolean".to_owned(),
actual: "string".to_owned(),
})
);
assert_eq!(user.name, "Bob");
assert!(user.active);
}
#[test]
fn readonly_write_leaves_existing_id_unchanged() {
let mut user = TestUser::new();
assert_eq!(
user.write_attribute("id", Value::from(2_u64)),
Err(AttributeError::Readonly("id".to_owned()))
);
assert_eq!(user.read_attribute("id"), Some(Value::from(1_u64)));
assert_eq!(user.attributes().get("id"), Some(&Value::from(1_u64)));
}
#[derive(Debug, Clone)]
struct SpecialAttributeModel {
values: HashMap<String, Value>,
}
impl SpecialAttributeModel {
fn new() -> Self {
Self {
values: HashMap::from([
("foo bar".to_owned(), json!("value of foo bar")),
("a?b".to_owned(), json!("value of a?b")),
("begin".to_owned(), json!("value of begin")),
("end".to_owned(), json!("value of end")),
]),
}
}
}
impl Attributes for SpecialAttributeModel {
fn attribute_names() -> &'static [&'static str] {
&["foo bar", "a?b", "begin", "end"]
}
fn read_attribute(&self, name: &str) -> Option<Value> {
self.values.get(name).cloned()
}
fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
if Self::has_attribute(name) {
self.values.insert(name.to_owned(), value);
Ok(())
} else {
Err(AttributeError::UnknownAttribute(name.to_owned()))
}
}
fn attributes(&self) -> HashMap<String, Value> {
self.values.clone()
}
}
ignored_rails_test!(
test_method_missing_works_correctly_even_if_attributes_method_is_not_defined,
"Rails-specific: Rust attributes require an Attributes impl at compile time instead of Ruby method_missing dispatch"
);
ignored_rails_test!(
test_unrelated_classes_should_not_share_attribute_method_matchers,
"Rails-specific: rustrails-model has no per-class attribute method matcher registry"
);
ignored_rails_test!(
test_define_attribute_method_generates_attribute_method,
"Rails-specific: rustrails-model exposes read_attribute/write_attribute instead of generating Ruby methods at runtime"
);
ignored_rails_test!(
test_define_attribute_methods_defines_alias_attribute_methods_after_undefining,
"Rails-specific: rustrails-model has no runtime alias_attribute or undefine_attribute_methods metaprogramming API"
);
ignored_rails_test!(
test_define_attribute_method_does_not_generate_attribute_method_if_already_defined_in_attribute_module,
"Rails-specific: rustrails-model does not synthesize attribute reader methods into generated modules"
);
ignored_rails_test!(
test_define_attribute_method_generates_a_method_that_is_already_defined_on_the_host,
"Rails-specific: rustrails-model does not override or generate host methods for attributes"
);
#[test]
fn test_define_attribute_method_generates_attribute_method_with_invalid_identifier_characters()
{
let mut model = SpecialAttributeModel::new();
assert_eq!(model.read_attribute("a?b"), Some(json!("value of a?b")));
assert_eq!(model.write_attribute("a?b", json!("updated")), Ok(()));
assert_eq!(model.read_attribute("a?b"), Some(json!("updated")));
}
ignored_rails_test!(
test_define_attribute_methods_works_passing_multiple_arguments,
"Rails-specific: rustrails-model does not batch-generate Ruby attribute methods from a variadic API"
);
ignored_rails_test!(
test_define_attribute_methods_generates_attribute_methods,
"Rails-specific: rustrails-model exposes explicit attribute access traits instead of generated methods"
);
ignored_rails_test!(
test_alias_attribute_generates_attribute_aliases_lookup_hash,
"Rails-specific: rustrails-model has no alias_attribute registry for alternate method names"
);
#[test]
fn test_define_attribute_methods_generates_attribute_methods_with_spaces_in_their_names() {
let mut model = SpecialAttributeModel::new();
assert_eq!(
model.read_attribute("foo bar"),
Some(json!("value of foo bar"))
);
assert_eq!(model.write_attribute("foo bar", json!("renamed")), Ok(()));
assert_eq!(model.read_attribute("foo bar"), Some(json!("renamed")));
}
ignored_rails_test!(
test_alias_attribute_works_with_attributes_with_spaces_in_their_names,
"Rails-specific: rustrails-model can address string attribute names with spaces but has no alias_attribute support"
);
ignored_rails_test!(
test_alias_attribute_works_with_attributes_named_as_a_ruby_keyword,
"Rails-specific: rustrails-model accepts string keys like begin/end but has no alias_attribute API"
);
ignored_rails_test!(
test_undefine_attribute_methods_removes_attribute_methods,
"Rails-specific: rustrails-model does not define or undefine Ruby methods for attributes"
);
ignored_rails_test!(
test_undefine_attribute_methods_undefines_alias_attribute_methods,
"Rails-specific: rustrails-model has no alias_attribute or undefine_attribute_methods metaprogramming hooks"
);
ignored_rails_test!(
test_accessing_a_suffixed_attribute,
"Rails-specific: rustrails-model has no attribute_method_suffix dispatch API"
);
ignored_rails_test!(
test_defined_attribute_does_not_expand_positional_hash_argument,
"Rails-specific: rustrails-model has no generated Ruby methods with positional hash argument semantics"
);
ignored_rails_test!(
test_should_not_interfere_with_method_missing_if_the_attr_has_a_private_or_protected_method,
"Rails-specific: rustrails-model has no Ruby visibility or method_missing interception for attributes"
);
ignored_rails_test!(
test_should_not_interfere_with_respond_to_if_the_attribute_has_a_private_or_protected_method,
"Rails-specific: rustrails-model has no Ruby respond_to? or private/protected method dispatch layer"
);
ignored_rails_test!(
test_should_use_attribute_missing_to_dispatch_a_missing_attribute,
"Rails-specific: rustrails-model has no attribute_missing callback for unresolved Ruby methods"
);
ignored_rails_test!(
test_name_clashes_are_handled,
"Rails-specific: rustrails-model does not synthesize overlapping Ruby method names for attributes"
);
ignored_rails_test!(
test_alias_attribute_respects_user_defined_method,
"Rails-specific: rustrails-model has no alias_attribute behavior to reconcile with user-defined Ruby methods"
);
ignored_rails_test!(
test_alias_attribute_respects_user_defined_method_in_parent_classes,
"Rails-specific: rustrails-model has no alias_attribute inheritance behavior over Ruby method lookup"
);
}