Skip to main content

pureflow_types/
lib.rs

1//! Shared domain types for Pureflow.
2
3use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6
7/// Maximum identifier length in raw UTF-8 bytes.
8///
9/// Internal identifiers are opaque slugs, not user-facing text; the cap
10/// protects transport and storage boundaries rather than display width.
11pub const MAX_IDENTIFIER_LEN: usize = 256;
12
13/// Kinds of opaque identifiers used by Pureflow.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IdentifierKind {
16    /// A workflow identifier.
17    Workflow,
18    /// An execution identifier for one workflow run.
19    Execution,
20    /// A message identifier for one envelope.
21    Message,
22    /// A node identifier within a workflow graph.
23    Node,
24    /// A port identifier on a node.
25    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/// Error returned when an identifier is malformed.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum IdentifierError {
43    /// The identifier was empty or only whitespace.
44    Empty {
45        /// Kind of identifier that failed validation.
46        kind: IdentifierKind,
47    },
48    /// The identifier contained whitespace.
49    Whitespace {
50        /// Kind of identifier that failed validation.
51        kind: IdentifierKind,
52    },
53    /// The identifier contained a control character.
54    Control {
55        /// Kind of identifier that failed validation.
56        kind: IdentifierKind,
57    },
58    /// The identifier exceeded the maximum allowed length.
59    TooLong {
60        /// Kind of identifier that failed validation.
61        kind: IdentifierKind,
62        /// Maximum allowed byte length.
63        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            /// Create a validated identifier.
115            ///
116            /// # Errors
117            ///
118            /// Returns an error if the value is empty, contains whitespace, or
119            /// contains a control character.
120            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            /// View the identifier as a string slice.
127            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}