1use std::collections::HashMap;
2
3use serde_json::Value;
4use thiserror::Error;
5
6#[derive(Debug, Clone, PartialEq, Eq, Error)]
8pub enum AttributeError {
9 #[error("unknown attribute: {0}")]
11 UnknownAttribute(String),
12 #[error("type mismatch for {attribute}: expected {expected}, got {actual}")]
14 TypeMismatch {
15 attribute: String,
17 expected: String,
19 actual: String,
21 },
22 #[error("attribute {0} is readonly")]
24 Readonly(String),
25}
26
27pub trait Attributes {
32 fn attribute_names() -> &'static [&'static str];
34
35 fn read_attribute(&self, name: &str) -> Option<Value>;
37
38 fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError>;
40
41 fn assign_attributes(&mut self, attrs: HashMap<String, Value>) -> Result<(), AttributeError> {
43 for (name, value) in attrs {
44 self.write_attribute(&name, value)?;
45 }
46 Ok(())
47 }
48
49 fn attributes(&self) -> HashMap<String, Value>;
51
52 fn has_attribute(name: &str) -> bool {
54 Self::attribute_names().contains(&name)
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use std::collections::HashMap;
61
62 use rustrails_support::ignored_rails_test;
63 use serde_json::{Value, json};
64
65 use super::{AttributeError, Attributes};
66
67 #[derive(Debug, Clone, PartialEq, Eq)]
68 struct TestUser {
69 id: u64,
70 name: String,
71 active: bool,
72 }
73
74 impl TestUser {
75 fn new() -> Self {
76 Self {
77 id: 1,
78 name: "Alice".to_owned(),
79 active: true,
80 }
81 }
82 }
83
84 impl Attributes for TestUser {
85 fn attribute_names() -> &'static [&'static str] {
86 &["id", "name", "active"]
87 }
88
89 fn read_attribute(&self, name: &str) -> Option<Value> {
90 match name {
91 "id" => Some(Value::from(self.id)),
92 "name" => Some(Value::String(self.name.clone())),
93 "active" => Some(Value::Bool(self.active)),
94 _ => None,
95 }
96 }
97
98 fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
99 match name {
100 "id" => Err(AttributeError::Readonly(name.to_owned())),
101 "name" => match value {
102 Value::String(text) => {
103 self.name = text;
104 Ok(())
105 }
106 other => Err(AttributeError::TypeMismatch {
107 attribute: name.to_owned(),
108 expected: "string".to_owned(),
109 actual: value_kind(&other).to_owned(),
110 }),
111 },
112 "active" => match value {
113 Value::Bool(flag) => {
114 self.active = flag;
115 Ok(())
116 }
117 other => Err(AttributeError::TypeMismatch {
118 attribute: name.to_owned(),
119 expected: "boolean".to_owned(),
120 actual: value_kind(&other).to_owned(),
121 }),
122 },
123 _ => Err(AttributeError::UnknownAttribute(name.to_owned())),
124 }
125 }
126
127 fn attributes(&self) -> HashMap<String, Value> {
128 HashMap::from([
129 ("id".to_owned(), Value::from(self.id)),
130 ("name".to_owned(), Value::String(self.name.clone())),
131 ("active".to_owned(), Value::Bool(self.active)),
132 ])
133 }
134 }
135
136 fn value_kind(value: &Value) -> &'static str {
137 match value {
138 Value::Null => "null",
139 Value::Bool(_) => "boolean",
140 Value::Number(_) => "number",
141 Value::String(_) => "string",
142 Value::Array(_) => "array",
143 Value::Object(_) => "object",
144 }
145 }
146
147 #[test]
148 fn attribute_names_and_has_attribute_report_known_fields() {
149 assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
150 assert!(TestUser::has_attribute("name"));
151 assert!(!TestUser::has_attribute("email"));
152 }
153
154 #[test]
155 fn read_attribute_returns_dynamic_values() {
156 let user = TestUser::new();
157
158 assert_eq!(user.read_attribute("id"), Some(Value::from(1_u64)));
159 assert_eq!(
160 user.read_attribute("name"),
161 Some(Value::String("Alice".to_owned()))
162 );
163 assert_eq!(user.read_attribute("active"), Some(Value::Bool(true)));
164 assert_eq!(user.read_attribute("missing"), None);
165 }
166
167 #[test]
168 fn write_attribute_updates_supported_fields() {
169 let mut user = TestUser::new();
170
171 let result = user.write_attribute("name", Value::String("Bob".to_owned()));
172
173 assert_eq!(result, Ok(()));
174 assert_eq!(user.name, "Bob");
175 assert_eq!(
176 user.read_attribute("name"),
177 Some(Value::String("Bob".to_owned()))
178 );
179 }
180
181 #[test]
182 fn write_attribute_rejects_unknown_attributes() {
183 let mut user = TestUser::new();
184
185 let result = user.write_attribute("email", Value::String("a@example.test".to_owned()));
186
187 assert_eq!(
188 result,
189 Err(AttributeError::UnknownAttribute("email".to_owned()))
190 );
191 }
192
193 #[test]
194 fn write_attribute_reports_type_mismatch() {
195 let mut user = TestUser::new();
196
197 let result = user.write_attribute("active", json!("yes"));
198
199 assert_eq!(
200 result,
201 Err(AttributeError::TypeMismatch {
202 attribute: "active".to_owned(),
203 expected: "boolean".to_owned(),
204 actual: "string".to_owned(),
205 })
206 );
207 }
208
209 #[test]
210 fn write_attribute_rejects_readonly_attributes() {
211 let mut user = TestUser::new();
212
213 let result = user.write_attribute("id", Value::from(2_u64));
214
215 assert_eq!(result, Err(AttributeError::Readonly("id".to_owned())));
216 }
217
218 #[test]
219 fn assign_attributes_updates_multiple_fields() {
220 let mut user = TestUser::new();
221 let attrs = HashMap::from([
222 ("name".to_owned(), Value::String("Carol".to_owned())),
223 ("active".to_owned(), Value::Bool(false)),
224 ]);
225
226 let result = user.assign_attributes(attrs);
227
228 assert_eq!(result, Ok(()));
229 assert_eq!(user.name, "Carol");
230 assert!(!user.active);
231 }
232
233 #[test]
234 fn attributes_returns_complete_snapshot() {
235 let user = TestUser::new();
236 let attrs = user.attributes();
237
238 assert_eq!(attrs.get("id"), Some(&Value::from(1_u64)));
239 assert_eq!(attrs.get("name"), Some(&Value::String("Alice".to_owned())));
240 assert_eq!(attrs.get("active"), Some(&Value::Bool(true)));
241 }
242 #[test]
243 fn assign_attributes_rejects_unknown_keys() {
244 let mut user = TestUser::new();
245 let result = user.assign_attributes(HashMap::from([(
246 "email".to_owned(),
247 Value::String("alice@example.test".to_owned()),
248 )]));
249
250 assert_eq!(
251 result,
252 Err(AttributeError::UnknownAttribute("email".to_owned()))
253 );
254 }
255
256 #[test]
257 fn assign_attributes_rejects_type_mismatches() {
258 let mut user = TestUser::new();
259 let result = user.assign_attributes(HashMap::from([(
260 "active".to_owned(),
261 Value::String("yes".to_owned()),
262 )]));
263
264 assert_eq!(
265 result,
266 Err(AttributeError::TypeMismatch {
267 attribute: "active".to_owned(),
268 expected: "boolean".to_owned(),
269 actual: "string".to_owned(),
270 })
271 );
272 }
273
274 #[test]
275 fn attributes_snapshot_reflects_successful_writes() {
276 let mut user = TestUser::new();
277 let result = user.write_attribute("name", Value::String("Dana".to_owned()));
278
279 assert_eq!(result, Ok(()));
280 assert_eq!(
281 user.attributes().get("name"),
282 Some(&Value::String("Dana".to_owned()))
283 );
284 }
285
286 #[test]
287 fn has_attribute_is_case_sensitive() {
288 assert!(TestUser::has_attribute("name"));
289 assert!(!TestUser::has_attribute("Name"));
290 }
291
292 #[test]
293 fn assign_attributes_with_empty_map_is_a_noop() {
294 let mut user = TestUser::new();
295 let result = user.assign_attributes(HashMap::new());
296
297 assert_eq!(result, Ok(()));
298 assert_eq!(user, TestUser::new());
299 }
300
301 #[test]
302 fn attribute_names_order_is_stable() {
303 assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
304 assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
305 }
306
307 #[test]
308 fn has_attribute_rejects_empty_name() {
309 assert!(!TestUser::has_attribute(""));
310 }
311
312 #[test]
313 fn read_attribute_reflects_updated_name_after_write() {
314 let mut user = TestUser::new();
315 assert_eq!(
316 user.write_attribute("name", Value::String("Beatrice".to_owned())),
317 Ok(())
318 );
319
320 assert_eq!(
321 user.read_attribute("name"),
322 Some(Value::String("Beatrice".to_owned()))
323 );
324 }
325
326 #[test]
327 fn read_attribute_reflects_updated_active_after_write() {
328 let mut user = TestUser::new();
329 assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
330
331 assert_eq!(user.read_attribute("active"), Some(Value::Bool(false)));
332 }
333
334 #[test]
335 fn write_attribute_updates_active_flag() {
336 let mut user = TestUser::new();
337
338 assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
339 assert!(!user.active);
340 }
341
342 #[test]
343 fn write_attribute_name_rejects_numbers() {
344 let mut user = TestUser::new();
345
346 assert_eq!(
347 user.write_attribute("name", Value::from(42)),
348 Err(AttributeError::TypeMismatch {
349 attribute: "name".to_owned(),
350 expected: "string".to_owned(),
351 actual: "number".to_owned(),
352 })
353 );
354 }
355
356 #[test]
357 fn write_attribute_name_rejects_null() {
358 let mut user = TestUser::new();
359
360 assert_eq!(
361 user.write_attribute("name", Value::Null),
362 Err(AttributeError::TypeMismatch {
363 attribute: "name".to_owned(),
364 expected: "string".to_owned(),
365 actual: "null".to_owned(),
366 })
367 );
368 }
369
370 #[test]
371 fn write_attribute_name_rejects_arrays() {
372 let mut user = TestUser::new();
373
374 assert_eq!(
375 user.write_attribute("name", json!(["Alice", "Bob"])),
376 Err(AttributeError::TypeMismatch {
377 attribute: "name".to_owned(),
378 expected: "string".to_owned(),
379 actual: "array".to_owned(),
380 })
381 );
382 }
383
384 #[test]
385 fn write_attribute_name_rejects_objects() {
386 let mut user = TestUser::new();
387
388 assert_eq!(
389 user.write_attribute("name", json!({ "first": "Alice" })),
390 Err(AttributeError::TypeMismatch {
391 attribute: "name".to_owned(),
392 expected: "string".to_owned(),
393 actual: "object".to_owned(),
394 })
395 );
396 }
397
398 #[test]
399 fn write_attribute_active_rejects_numbers() {
400 let mut user = TestUser::new();
401
402 assert_eq!(
403 user.write_attribute("active", Value::from(1)),
404 Err(AttributeError::TypeMismatch {
405 attribute: "active".to_owned(),
406 expected: "boolean".to_owned(),
407 actual: "number".to_owned(),
408 })
409 );
410 }
411
412 #[test]
413 fn assign_attributes_rejects_readonly_keys() {
414 let mut user = TestUser::new();
415 let result = user.assign_attributes(HashMap::from([("id".to_owned(), Value::from(2_u64))]));
416
417 assert_eq!(result, Err(AttributeError::Readonly("id".to_owned())));
418 }
419
420 #[test]
421 fn attributes_snapshot_reflects_boolean_update() {
422 let mut user = TestUser::new();
423 assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
424
425 assert_eq!(user.attributes().get("active"), Some(&Value::Bool(false)));
426 }
427
428 #[test]
429 fn unknown_attribute_error_display_is_human_readable() {
430 assert_eq!(
431 AttributeError::UnknownAttribute("email".to_owned()).to_string(),
432 "unknown attribute: email"
433 );
434 }
435
436 #[test]
437 fn type_mismatch_error_display_is_human_readable() {
438 assert_eq!(
439 AttributeError::TypeMismatch {
440 attribute: "active".to_owned(),
441 expected: "boolean".to_owned(),
442 actual: "string".to_owned(),
443 }
444 .to_string(),
445 "type mismatch for active: expected boolean, got string"
446 );
447 }
448
449 #[test]
450 fn readonly_error_display_is_human_readable() {
451 assert_eq!(
452 AttributeError::Readonly("id".to_owned()).to_string(),
453 "attribute id is readonly"
454 );
455 }
456 #[test]
457 fn failed_write_keeps_previous_successful_value() {
458 let mut user = TestUser::new();
459 assert_eq!(
460 user.write_attribute("name", Value::String("Bob".to_owned())),
461 Ok(())
462 );
463
464 assert_eq!(
465 user.write_attribute("name", Value::from(42)),
466 Err(AttributeError::TypeMismatch {
467 attribute: "name".to_owned(),
468 expected: "string".to_owned(),
469 actual: "number".to_owned(),
470 })
471 );
472
473 assert_eq!(user.name, "Bob");
474 assert_eq!(
475 user.read_attribute("name"),
476 Some(Value::String("Bob".to_owned()))
477 );
478 }
479
480 #[test]
481 fn failed_assign_does_not_rollback_previous_successful_writes() {
482 let mut user = TestUser::new();
483 assert_eq!(
484 user.write_attribute("name", Value::String("Bob".to_owned())),
485 Ok(())
486 );
487
488 let result = user.assign_attributes(HashMap::from([(
489 "active".to_owned(),
490 Value::String("yes".to_owned()),
491 )]));
492
493 assert_eq!(
494 result,
495 Err(AttributeError::TypeMismatch {
496 attribute: "active".to_owned(),
497 expected: "boolean".to_owned(),
498 actual: "string".to_owned(),
499 })
500 );
501 assert_eq!(user.name, "Bob");
502 assert!(user.active);
503 }
504
505 #[test]
506 fn readonly_write_leaves_existing_id_unchanged() {
507 let mut user = TestUser::new();
508
509 assert_eq!(
510 user.write_attribute("id", Value::from(2_u64)),
511 Err(AttributeError::Readonly("id".to_owned()))
512 );
513
514 assert_eq!(user.read_attribute("id"), Some(Value::from(1_u64)));
515 assert_eq!(user.attributes().get("id"), Some(&Value::from(1_u64)));
516 }
517
518 #[derive(Debug, Clone)]
519 struct SpecialAttributeModel {
520 values: HashMap<String, Value>,
521 }
522
523 impl SpecialAttributeModel {
524 fn new() -> Self {
525 Self {
526 values: HashMap::from([
527 ("foo bar".to_owned(), json!("value of foo bar")),
528 ("a?b".to_owned(), json!("value of a?b")),
529 ("begin".to_owned(), json!("value of begin")),
530 ("end".to_owned(), json!("value of end")),
531 ]),
532 }
533 }
534 }
535
536 impl Attributes for SpecialAttributeModel {
537 fn attribute_names() -> &'static [&'static str] {
538 &["foo bar", "a?b", "begin", "end"]
539 }
540
541 fn read_attribute(&self, name: &str) -> Option<Value> {
542 self.values.get(name).cloned()
543 }
544
545 fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
546 if Self::has_attribute(name) {
547 self.values.insert(name.to_owned(), value);
548 Ok(())
549 } else {
550 Err(AttributeError::UnknownAttribute(name.to_owned()))
551 }
552 }
553
554 fn attributes(&self) -> HashMap<String, Value> {
555 self.values.clone()
556 }
557 }
558
559 ignored_rails_test!(
560 test_method_missing_works_correctly_even_if_attributes_method_is_not_defined,
561 "Rails-specific: Rust attributes require an Attributes impl at compile time instead of Ruby method_missing dispatch"
562 );
563
564 ignored_rails_test!(
565 test_unrelated_classes_should_not_share_attribute_method_matchers,
566 "Rails-specific: rustrails-model has no per-class attribute method matcher registry"
567 );
568
569 ignored_rails_test!(
570 test_define_attribute_method_generates_attribute_method,
571 "Rails-specific: rustrails-model exposes read_attribute/write_attribute instead of generating Ruby methods at runtime"
572 );
573
574 ignored_rails_test!(
575 test_define_attribute_methods_defines_alias_attribute_methods_after_undefining,
576 "Rails-specific: rustrails-model has no runtime alias_attribute or undefine_attribute_methods metaprogramming API"
577 );
578
579 ignored_rails_test!(
580 test_define_attribute_method_does_not_generate_attribute_method_if_already_defined_in_attribute_module,
581 "Rails-specific: rustrails-model does not synthesize attribute reader methods into generated modules"
582 );
583
584 ignored_rails_test!(
585 test_define_attribute_method_generates_a_method_that_is_already_defined_on_the_host,
586 "Rails-specific: rustrails-model does not override or generate host methods for attributes"
587 );
588
589 #[test]
590 fn test_define_attribute_method_generates_attribute_method_with_invalid_identifier_characters()
591 {
592 let mut model = SpecialAttributeModel::new();
593
594 assert_eq!(model.read_attribute("a?b"), Some(json!("value of a?b")));
595 assert_eq!(model.write_attribute("a?b", json!("updated")), Ok(()));
596 assert_eq!(model.read_attribute("a?b"), Some(json!("updated")));
597 }
598
599 ignored_rails_test!(
600 test_define_attribute_methods_works_passing_multiple_arguments,
601 "Rails-specific: rustrails-model does not batch-generate Ruby attribute methods from a variadic API"
602 );
603
604 ignored_rails_test!(
605 test_define_attribute_methods_generates_attribute_methods,
606 "Rails-specific: rustrails-model exposes explicit attribute access traits instead of generated methods"
607 );
608
609 ignored_rails_test!(
610 test_alias_attribute_generates_attribute_aliases_lookup_hash,
611 "Rails-specific: rustrails-model has no alias_attribute registry for alternate method names"
612 );
613
614 #[test]
615 fn test_define_attribute_methods_generates_attribute_methods_with_spaces_in_their_names() {
616 let mut model = SpecialAttributeModel::new();
617
618 assert_eq!(
619 model.read_attribute("foo bar"),
620 Some(json!("value of foo bar"))
621 );
622 assert_eq!(model.write_attribute("foo bar", json!("renamed")), Ok(()));
623 assert_eq!(model.read_attribute("foo bar"), Some(json!("renamed")));
624 }
625
626 ignored_rails_test!(
627 test_alias_attribute_works_with_attributes_with_spaces_in_their_names,
628 "Rails-specific: rustrails-model can address string attribute names with spaces but has no alias_attribute support"
629 );
630
631 ignored_rails_test!(
632 test_alias_attribute_works_with_attributes_named_as_a_ruby_keyword,
633 "Rails-specific: rustrails-model accepts string keys like begin/end but has no alias_attribute API"
634 );
635
636 ignored_rails_test!(
637 test_undefine_attribute_methods_removes_attribute_methods,
638 "Rails-specific: rustrails-model does not define or undefine Ruby methods for attributes"
639 );
640
641 ignored_rails_test!(
642 test_undefine_attribute_methods_undefines_alias_attribute_methods,
643 "Rails-specific: rustrails-model has no alias_attribute or undefine_attribute_methods metaprogramming hooks"
644 );
645
646 ignored_rails_test!(
647 test_accessing_a_suffixed_attribute,
648 "Rails-specific: rustrails-model has no attribute_method_suffix dispatch API"
649 );
650
651 ignored_rails_test!(
652 test_defined_attribute_does_not_expand_positional_hash_argument,
653 "Rails-specific: rustrails-model has no generated Ruby methods with positional hash argument semantics"
654 );
655
656 ignored_rails_test!(
657 test_should_not_interfere_with_method_missing_if_the_attr_has_a_private_or_protected_method,
658 "Rails-specific: rustrails-model has no Ruby visibility or method_missing interception for attributes"
659 );
660
661 ignored_rails_test!(
662 test_should_not_interfere_with_respond_to_if_the_attribute_has_a_private_or_protected_method,
663 "Rails-specific: rustrails-model has no Ruby respond_to? or private/protected method dispatch layer"
664 );
665
666 ignored_rails_test!(
667 test_should_use_attribute_missing_to_dispatch_a_missing_attribute,
668 "Rails-specific: rustrails-model has no attribute_missing callback for unresolved Ruby methods"
669 );
670
671 ignored_rails_test!(
672 test_name_clashes_are_handled,
673 "Rails-specific: rustrails-model does not synthesize overlapping Ruby method names for attributes"
674 );
675
676 ignored_rails_test!(
677 test_alias_attribute_respects_user_defined_method,
678 "Rails-specific: rustrails-model has no alias_attribute behavior to reconcile with user-defined Ruby methods"
679 );
680
681 ignored_rails_test!(
682 test_alias_attribute_respects_user_defined_method_in_parent_classes,
683 "Rails-specific: rustrails-model has no alias_attribute inheritance behavior over Ruby method lookup"
684 );
685}