made_core/value_objects/
rubric.rs1use std::collections::BTreeMap;
14use std::fmt;
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::error::DomainError;
20
21pub const MAX_RUBRIC_KEYS: usize = 256;
24
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct Rubric(BTreeMap<String, Value>);
32
33impl Rubric {
34 pub fn new(entries: BTreeMap<String, Value>) -> Result<Self, DomainError> {
35 if entries.len() > MAX_RUBRIC_KEYS {
36 return Err(DomainError::OutOfRange {
37 field: "rubric.keys",
38 value: entries.len() as f64,
39 min: 0.0,
40 max: MAX_RUBRIC_KEYS as f64,
41 });
42 }
43 for key in entries.keys() {
44 if key.trim().is_empty() {
45 return Err(DomainError::EmptyField {
46 field: "rubric.key",
47 });
48 }
49 }
50 Ok(Self(entries))
51 }
52
53 #[must_use]
54 pub fn empty() -> Self {
55 Self::default()
56 }
57
58 #[must_use]
59 pub fn len(&self) -> usize {
60 self.0.len()
61 }
62
63 #[must_use]
64 pub fn is_empty(&self) -> bool {
65 self.0.is_empty()
66 }
67
68 #[must_use]
69 pub fn get(&self, key: &str) -> Option<&Value> {
70 self.0.get(key)
71 }
72
73 #[must_use]
74 pub fn as_map(&self) -> &BTreeMap<String, Value> {
75 &self.0
76 }
77
78 #[must_use]
79 pub fn into_inner(self) -> BTreeMap<String, Value> {
80 self.0
81 }
82}
83
84impl fmt::Display for Rubric {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 write!(f, "Rubric({} keys)", self.0.len())
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use serde_json::json;
94
95 fn entry(key: &str, value: Value) -> BTreeMap<String, Value> {
96 let mut m = BTreeMap::new();
97 m.insert(key.to_owned(), value);
98 m
99 }
100
101 #[test]
102 fn empty_rubric_is_valid() {
103 let r = Rubric::empty();
104 assert!(r.is_empty());
105 assert_eq!(r.len(), 0);
106 }
107
108 #[test]
109 fn arbitrary_keys_and_values_are_accepted() {
110 let r = Rubric::new(entry("criteria", json!({"rigor": "high"}))).unwrap();
111 assert_eq!(r.get("criteria"), Some(&json!({"rigor": "high"})));
112 }
113
114 #[test]
115 fn blank_key_is_rejected() {
116 let err = Rubric::new(entry(" ", json!(null))).unwrap_err();
117 assert!(matches!(
118 err,
119 DomainError::EmptyField {
120 field: "rubric.key"
121 }
122 ));
123 }
124
125 #[test]
126 fn too_many_keys_is_rejected() {
127 let mut m = BTreeMap::new();
128 for i in 0..=MAX_RUBRIC_KEYS {
129 m.insert(format!("k{i}"), json!(i));
130 }
131 assert!(matches!(
132 Rubric::new(m).unwrap_err(),
133 DomainError::OutOfRange {
134 field: "rubric.keys",
135 ..
136 }
137 ));
138 }
139
140 #[test]
141 fn domain_neutrality_is_preserved() {
142 let cases = [
144 ("software", json!({"quality": "high"})),
145 ("clinical", json!({"capa_required": true})),
146 ("logistics", json!({"sla_minutes": 120})),
147 ];
148 for (key, value) in cases {
149 Rubric::new(entry(key, value)).unwrap();
150 }
151 }
152
153 #[test]
154 fn serde_is_transparent_map() {
155 let r = Rubric::new(entry("k", json!(1))).unwrap();
156 let s = serde_json::to_string(&r).unwrap();
157 assert_eq!(s, r#"{"k":1}"#);
158 let back: Rubric = serde_json::from_str(&s).unwrap();
159 assert_eq!(back, r);
160 }
161
162 #[test]
163 fn display_shows_count() {
164 let r = Rubric::new(entry("k", json!(1))).unwrap();
165 assert_eq!(r.to_string(), "Rubric(1 keys)");
166 }
167}