made_core/value_objects/
ids.rs1use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13
14const MAX_ID_LEN: usize = 256;
15
16fn validate_id(field: &'static str, raw: &str) -> Result<String, DomainError> {
17 let trimmed = raw.trim();
18 if trimmed.is_empty() {
19 return Err(DomainError::EmptyField { field });
20 }
21 if trimmed.len() > MAX_ID_LEN {
22 return Err(DomainError::FieldTooLong {
23 field,
24 actual: trimmed.len(),
25 max: MAX_ID_LEN,
26 });
27 }
28 if trimmed.chars().any(char::is_control) {
29 return Err(DomainError::InvalidCharacters { field });
30 }
31 Ok(trimmed.to_owned())
32}
33
34macro_rules! id_newtype {
35 ($(#[$meta:meta])* $name:ident, $field:literal) => {
36 $(#[$meta])*
37 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38 #[serde(transparent)]
39 pub struct $name(String);
40
41 impl $name {
42 pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
44 Ok(Self(validate_id($field, &raw.into())?))
45 }
46
47 #[must_use]
49 pub fn as_str(&self) -> &str {
50 &self.0
51 }
52
53 #[must_use]
55 pub fn into_inner(self) -> String {
56 self.0
57 }
58 }
59
60 impl fmt::Display for $name {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.write_str(&self.0)
63 }
64 }
65
66 impl TryFrom<String> for $name {
67 type Error = DomainError;
68 fn try_from(value: String) -> Result<Self, Self::Error> {
69 Self::new(value)
70 }
71 }
72
73 impl TryFrom<&str> for $name {
74 type Error = DomainError;
75 fn try_from(value: &str) -> Result<Self, Self::Error> {
76 Self::new(value)
77 }
78 }
79 };
80}
81
82id_newtype!(
83 AgentId,
85 "agent_id"
86);
87
88id_newtype!(
89 TaskId,
91 "task_id"
92);
93
94id_newtype!(
95 ProposalId,
97 "proposal_id"
98);
99
100id_newtype!(
101 CouncilId,
103 "council_id"
104);
105
106id_newtype!(
107 EventId,
109 "event_id"
110);
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn new_accepts_valid_id() {
118 let id = AgentId::new("agent-42").expect("should parse");
119 assert_eq!(id.as_str(), "agent-42");
120 }
121
122 #[test]
123 fn new_trims_whitespace() {
124 let id = TaskId::new(" t-1 ").expect("should parse");
125 assert_eq!(id.as_str(), "t-1");
126 }
127
128 #[test]
129 fn empty_is_rejected() {
130 let err = ProposalId::new(" ").expect_err("should reject");
131 assert!(matches!(
132 err,
133 DomainError::EmptyField {
134 field: "proposal_id"
135 }
136 ));
137 }
138
139 #[test]
140 fn control_chars_are_rejected() {
141 let err = CouncilId::new("bad\x00id").expect_err("should reject");
142 assert!(matches!(
143 err,
144 DomainError::InvalidCharacters {
145 field: "council_id"
146 }
147 ));
148 }
149
150 #[test]
151 fn overlong_is_rejected() {
152 let too_long = "x".repeat(super::MAX_ID_LEN + 1);
153 let err = EventId::new(too_long).expect_err("should reject");
154 assert!(matches!(err, DomainError::FieldTooLong { .. }));
155 }
156
157 #[test]
158 fn distinct_newtypes_do_not_mix() {
159 fn takes_agent(_: AgentId) {}
160 let task = TaskId::new("t").unwrap();
161 let _ = task;
164 takes_agent(AgentId::new("a").unwrap());
165 }
166
167 #[test]
168 fn try_from_str_works() {
169 let id: AgentId = "a1".try_into().unwrap();
170 assert_eq!(id.as_str(), "a1");
171 }
172
173 #[test]
174 fn try_from_string_works() {
175 let id: TaskId = String::from("t1").try_into().unwrap();
176 assert_eq!(id.as_str(), "t1");
177 }
178
179 #[test]
180 fn display_matches_inner() {
181 let id = AgentId::new("x").unwrap();
182 assert_eq!(id.to_string(), "x");
183 }
184
185 #[test]
186 fn into_inner_returns_string() {
187 let id = AgentId::new("x").unwrap();
188 assert_eq!(id.into_inner(), "x");
189 }
190
191 #[test]
192 fn serde_roundtrip_is_transparent() {
193 let id = AgentId::new("abc").unwrap();
194 let s = serde_json::to_string(&id).unwrap();
195 assert_eq!(s, "\"abc\"");
196 let back: AgentId = serde_json::from_str(&s).unwrap();
197 assert_eq!(back, id);
198 }
199}