1use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6
7pub const MAX_IDENTIFIER_LEN: usize = 256;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IdentifierKind {
16 Workflow,
18 Execution,
20 Message,
22 Node,
24 Port,
26}
27
28impl IdentifierKind {
29 const fn label(self) -> &'static str {
30 match self {
31 Self::Workflow => "workflow id",
32 Self::Execution => "execution id",
33 Self::Message => "message id",
34 Self::Node => "node id",
35 Self::Port => "port id",
36 }
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum IdentifierError {
43 Empty {
45 kind: IdentifierKind,
47 },
48 Whitespace {
50 kind: IdentifierKind,
52 },
53 Control {
55 kind: IdentifierKind,
57 },
58 TooLong {
60 kind: IdentifierKind,
62 limit: usize,
64 },
65}
66
67impl fmt::Display for IdentifierError {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::Empty { kind } => write!(f, "{} must not be empty", kind.label()),
71 Self::Whitespace { kind } => write!(f, "{} must not contain whitespace", kind.label()),
72 Self::Control { kind } => {
73 write!(f, "{} must not contain control characters", kind.label())
74 }
75 Self::TooLong { kind, limit } => {
76 write!(f, "{} must not exceed {} bytes", kind.label(), limit)
77 }
78 }
79 }
80}
81
82impl Error for IdentifierError {}
83
84fn validate_identifier(kind: IdentifierKind, value: &str) -> Result<(), IdentifierError> {
85 if value.len() > MAX_IDENTIFIER_LEN {
86 return Err(IdentifierError::TooLong {
87 kind,
88 limit: MAX_IDENTIFIER_LEN,
89 });
90 }
91
92 if value.trim().is_empty() {
93 return Err(IdentifierError::Empty { kind });
94 }
95
96 if value.chars().any(char::is_whitespace) {
97 return Err(IdentifierError::Whitespace { kind });
98 }
99
100 if value.chars().any(char::is_control) {
101 return Err(IdentifierError::Control { kind });
102 }
103
104 Ok(())
105}
106
107macro_rules! id_type {
108 ($name:ident, $kind:expr, $docs:literal) => {
109 #[doc = $docs]
110 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
111 pub struct $name(String);
112
113 impl $name {
114 pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
121 let value = value.into();
122 validate_identifier($kind, &value)?;
123 Ok(Self(value))
124 }
125
126 pub fn as_str(&self) -> &str {
128 &self.0
129 }
130 }
131
132 impl fmt::Display for $name {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 f.write_str(self.as_str())
135 }
136 }
137
138 impl FromStr for $name {
139 type Err = IdentifierError;
140
141 fn from_str(s: &str) -> Result<Self, Self::Err> {
142 Self::new(s)
143 }
144 }
145
146 impl AsRef<str> for $name {
147 fn as_ref(&self) -> &str {
148 self.as_str()
149 }
150 }
151
152 impl From<$name> for String {
153 fn from(value: $name) -> Self {
154 value.0
155 }
156 }
157 };
158}
159
160id_type!(
161 WorkflowId,
162 IdentifierKind::Workflow,
163 "Stable workflow identifier."
164);
165id_type!(
166 ExecutionId,
167 IdentifierKind::Execution,
168 "Stable identifier for one workflow execution."
169);
170id_type!(
171 MessageId,
172 IdentifierKind::Message,
173 "Stable identifier for one message envelope."
174);
175id_type!(
176 NodeId,
177 IdentifierKind::Node,
178 "Stable node identifier inside a workflow graph."
179);
180id_type!(
181 PortId,
182 IdentifierKind::Port,
183 "Stable port identifier on a node."
184);
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use proptest::prelude::*;
190
191 fn valid_identifier_strategy() -> impl Strategy<Value = String> {
192 prop::collection::vec(
193 any::<char>().prop_filter(
194 "identifier characters must not be whitespace or control",
195 |ch| !ch.is_whitespace() && !ch.is_control(),
196 ),
197 1..16,
198 )
199 .prop_map(|chars: Vec<char>| chars.into_iter().collect())
200 }
201
202 fn assert_identifier_round_trip<T>(value: &str)
203 where
204 T: FromStr<Err = IdentifierError> + AsRef<str>,
205 {
206 let parsed: T = value.parse().expect("generated identifier must parse");
207 assert_eq!(parsed.as_ref(), value);
208 }
209
210 #[test]
211 fn workflow_id_rejects_empty_values() {
212 let err = WorkflowId::new("").expect_err("empty identifiers must fail");
213 assert_eq!(
214 err,
215 IdentifierError::Empty {
216 kind: IdentifierKind::Workflow
217 }
218 );
219 }
220
221 #[test]
222 fn node_id_rejects_whitespace() {
223 let err = NodeId::new("node one").expect_err("whitespace identifiers must fail");
224 assert_eq!(
225 err,
226 IdentifierError::Whitespace {
227 kind: IdentifierKind::Node
228 }
229 );
230 }
231
232 #[test]
233 fn execution_id_rejects_empty_values() {
234 let err = ExecutionId::new(" ").expect_err("blank identifiers must fail");
235 assert_eq!(
236 err,
237 IdentifierError::Empty {
238 kind: IdentifierKind::Execution
239 }
240 );
241 }
242
243 #[test]
244 fn message_id_rejects_control_characters() {
245 let err = MessageId::new("msg\u{001f}one").expect_err("control characters must fail");
246 assert_eq!(
247 err,
248 IdentifierError::Control {
249 kind: IdentifierKind::Message
250 }
251 );
252 }
253
254 #[test]
255 fn port_id_round_trips_through_display_and_parse() {
256 let id = PortId::new("out-1").expect("valid identifier");
257 let parsed = PortId::from_str(id.as_str()).expect("round-trip should succeed");
258
259 assert_eq!(id, parsed);
260 assert_eq!(id.to_string(), "out-1");
261 assert_eq!(id.as_ref(), "out-1");
262 }
263
264 #[test]
265 fn identifiers_reject_control_characters() {
266 let err = WorkflowId::new("flow\u{0007}one").expect_err("control characters must fail");
267 assert!(matches!(
268 err,
269 IdentifierError::Control {
270 kind: IdentifierKind::Workflow
271 }
272 ));
273 }
274
275 #[test]
276 fn identifiers_reject_values_over_length_cap() {
277 let value: String = "a".repeat(MAX_IDENTIFIER_LEN + 1);
278 let err = PortId::new(value).expect_err("overlong identifiers must fail");
279
280 assert_eq!(
281 err,
282 IdentifierError::TooLong {
283 kind: IdentifierKind::Port,
284 limit: MAX_IDENTIFIER_LEN,
285 }
286 );
287 }
288
289 proptest! {
290 #[test]
291 fn generated_valid_identifiers_are_accepted(value in valid_identifier_strategy()) {
292 assert_identifier_round_trip::<WorkflowId>(&value);
293 assert_identifier_round_trip::<ExecutionId>(&value);
294 assert_identifier_round_trip::<MessageId>(&value);
295 assert_identifier_round_trip::<NodeId>(&value);
296 assert_identifier_round_trip::<PortId>(&value);
297 }
298
299 #[test]
300 fn generated_valid_identifiers_reject_appended_whitespace(value in valid_identifier_strategy()) {
301 let invalid: String = format!("{value} ");
302 prop_assert_eq!(
303 WorkflowId::new(invalid),
304 Err(IdentifierError::Whitespace { kind: IdentifierKind::Workflow })
305 );
306 }
307
308 #[test]
309 fn generated_valid_identifiers_respect_length_cap(value in valid_identifier_strategy()) {
310 prop_assert!(value.len() <= MAX_IDENTIFIER_LEN);
311 prop_assert!(WorkflowId::new(value).is_ok());
312 }
313 }
314}