Skip to main content

made_core/value_objects/
ids.rs

1//! Identifier value objects.
2//!
3//! Each domain concept that needs identity gets its own newtype so that
4//! the compiler rejects mixing, e.g., an [`AgentId`] where a [`TaskId`]
5//! is expected. Identifiers are opaque strings at the wire level but
6//! validated for basic hygiene here.
7
8use 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            /// Construct a validated identifier.
43            pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
44                Ok(Self(validate_id($field, &raw.into())?))
45            }
46
47            /// Borrow the underlying string.
48            #[must_use]
49            pub fn as_str(&self) -> &str {
50                &self.0
51            }
52
53            /// Consume the value object and return the raw string.
54            #[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    /// Identifier of an [`Agent`](crate::value_objects) within MADE.
84    AgentId,
85    "agent_id"
86);
87
88id_newtype!(
89    /// Identifier of a task submitted for deliberation.
90    TaskId,
91    "task_id"
92);
93
94id_newtype!(
95    /// Identifier of a concrete proposal produced during deliberation.
96    ProposalId,
97    "proposal_id"
98);
99
100id_newtype!(
101    /// Identifier of a council (group of agents for a given specialty).
102    CouncilId,
103    "council_id"
104);
105
106id_newtype!(
107    /// Identifier of a domain event as emitted by MADE.
108    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        // The following must not compile:
162        // takes_agent(task);
163        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}