Skip to main content

arcp_core/
ids.rs

1//! Newtype wrappers for the protocol's identifier fields (RFC §6.1.1).
2//!
3//! Identifiers are categorised into two flavours:
4//!
5//! - **Prefixed ULIDs** for runtime-minted identifiers. The on-the-wire form
6//!   is `<prefix>_<ULID>`; the prefix is asserted on parse. Mixing a
7//!   `SessionId` with a `MessageId` is a compile error.
8//! - **Free-form opaque strings** for ids whose format is determined by the
9//!   environment: `TraceId` and `SpanId` (per OpenTelemetry / Datadog /
10//!   Honeycomb conventions, §17.1) and `IdempotencyKey` (client-supplied
11//!   logical intent key, §6.4).
12
13use std::fmt;
14use std::str::FromStr;
15
16use serde::de::Error as _;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use ulid::Ulid;
19
20/// Errors produced when parsing a typed ID from a string.
21#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
22pub enum IdParseError {
23    /// The input did not start with the expected `<prefix>_` prefix.
24    #[error("expected prefix `{expected}_` for {kind}, got `{got}`")]
25    WrongPrefix {
26        /// Identifier name (e.g. `"SessionId"`).
27        kind: &'static str,
28        /// Expected prefix without the trailing underscore.
29        expected: &'static str,
30        /// The full string that was offered.
31        got: String,
32    },
33    /// The string was empty or had no body after the prefix.
34    #[error("empty id body for {kind}")]
35    Empty {
36        /// Identifier name.
37        kind: &'static str,
38    },
39}
40
41macro_rules! prefixed_id {
42    ($name:ident, $prefix:literal, $doc:literal) => {
43        #[doc = $doc]
44        ///
45        /// On-the-wire form is
46        #[doc = concat!("`", $prefix, "_<ULID>`")]
47        /// where the ULID provides monotonic, sortable uniqueness.
48        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
49        pub struct $name(String);
50
51        impl $name {
52            /// Mint a fresh id with a freshly generated ULID body.
53            #[must_use]
54            pub fn new() -> Self {
55                Self(format!("{}_{}", $prefix, Ulid::new()))
56            }
57
58            /// Borrow the underlying string representation.
59            #[must_use]
60            pub fn as_str(&self) -> &str {
61                &self.0
62            }
63
64            /// The prefix used by this id type (without the trailing
65            /// underscore).
66            #[must_use]
67            pub const fn prefix() -> &'static str {
68                $prefix
69            }
70        }
71
72        impl Default for $name {
73            fn default() -> Self {
74                Self::new()
75            }
76        }
77
78        impl fmt::Display for $name {
79            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80                f.write_str(&self.0)
81            }
82        }
83
84        impl FromStr for $name {
85            type Err = IdParseError;
86
87            fn from_str(s: &str) -> Result<Self, Self::Err> {
88                let with_underscore = concat!($prefix, "_");
89                let Some(rest) = s.strip_prefix(with_underscore) else {
90                    return Err(IdParseError::WrongPrefix {
91                        kind: stringify!($name),
92                        expected: $prefix,
93                        got: s.to_owned(),
94                    });
95                };
96                if rest.is_empty() {
97                    return Err(IdParseError::Empty {
98                        kind: stringify!($name),
99                    });
100                }
101                Ok(Self(s.to_owned()))
102            }
103        }
104
105        impl Serialize for $name {
106            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
107                serializer.serialize_str(&self.0)
108            }
109        }
110
111        impl<'de> Deserialize<'de> for $name {
112            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
113                let raw = String::deserialize(deserializer)?;
114                raw.parse().map_err(D::Error::custom)
115            }
116        }
117    };
118}
119
120macro_rules! freeform_id {
121    ($name:ident, $doc:literal) => {
122        #[doc = $doc]
123        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
124        pub struct $name(String);
125
126        impl $name {
127            /// Construct from any non-empty string.
128            ///
129            /// # Errors
130            ///
131            /// Returns [`IdParseError::Empty`] if `value` is empty.
132            pub fn new(value: impl Into<String>) -> Result<Self, IdParseError> {
133                let s = value.into();
134                if s.is_empty() {
135                    Err(IdParseError::Empty {
136                        kind: stringify!($name),
137                    })
138                } else {
139                    Ok(Self(s))
140                }
141            }
142
143            /// Borrow the underlying string representation.
144            #[must_use]
145            pub fn as_str(&self) -> &str {
146                &self.0
147            }
148        }
149
150        impl fmt::Display for $name {
151            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152                f.write_str(&self.0)
153            }
154        }
155
156        impl FromStr for $name {
157            type Err = IdParseError;
158
159            fn from_str(s: &str) -> Result<Self, Self::Err> {
160                Self::new(s)
161            }
162        }
163
164        impl Serialize for $name {
165            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
166                serializer.serialize_str(&self.0)
167            }
168        }
169
170        impl<'de> Deserialize<'de> for $name {
171            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
172                let raw = String::deserialize(deserializer)?;
173                raw.parse().map_err(D::Error::custom)
174            }
175        }
176    };
177}
178
179prefixed_id!(
180    SessionId,
181    "sess",
182    "Identifier for an ARCP session (RFC §9)."
183);
184prefixed_id!(
185    MessageId,
186    "msg",
187    "Globally unique envelope identifier (RFC §6.1.1)."
188);
189prefixed_id!(JobId, "job", "Identifier for a durable job (RFC §10).");
190prefixed_id!(StreamId, "str", "Identifier for a stream (RFC §11).");
191prefixed_id!(
192    SubscriptionId,
193    "sub",
194    "Identifier for an observer subscription (RFC §13)."
195);
196prefixed_id!(
197    LeaseId,
198    "lease",
199    "Identifier for a permission lease (RFC §15.5)."
200);
201prefixed_id!(
202    ArtifactId,
203    "art",
204    "Identifier for an addressable artifact (RFC §16)."
205);
206
207freeform_id!(
208    TraceId,
209    "Distributed-trace identifier (RFC §17.1). Format is environment-defined."
210);
211freeform_id!(
212    SpanId,
213    "Span identifier within a trace (RFC §17.1). Format is environment-defined."
214);
215freeform_id!(
216    IdempotencyKey,
217    "Logical idempotency key supplied by the client for a command intent (RFC §6.4)."
218);
219
220#[cfg(test)]
221#[allow(
222    clippy::expect_used,
223    clippy::unwrap_used,
224    clippy::panic,
225    clippy::missing_panics_doc
226)]
227mod tests {
228    use std::collections::HashSet;
229
230    use super::*;
231
232    #[test]
233    fn prefixed_id_round_trips_through_string() {
234        let id = SessionId::new();
235        let s = id.to_string();
236        assert!(s.starts_with("sess_"), "got {s}");
237        let parsed: SessionId = s.parse().expect("round-trip");
238        assert_eq!(id, parsed);
239    }
240
241    #[test]
242    fn prefixed_id_rejects_wrong_prefix() {
243        let err = "msg_01ABC".parse::<SessionId>().expect_err("must reject");
244        match err {
245            IdParseError::WrongPrefix { expected, .. } => assert_eq!(expected, "sess"),
246            IdParseError::Empty { .. } => panic!("expected WrongPrefix, got Empty"),
247        }
248    }
249
250    #[test]
251    fn prefixed_id_rejects_empty_body() {
252        let err = "sess_"
253            .parse::<SessionId>()
254            .expect_err("must reject empty body");
255        assert!(matches!(err, IdParseError::Empty { .. }));
256    }
257
258    #[test]
259    fn prefixed_id_serde_round_trip() {
260        let id = MessageId::new();
261        let json = serde_json::to_string(&id).expect("serialize");
262        assert!(json.starts_with("\"msg_"));
263        let back: MessageId = serde_json::from_str(&json).expect("deserialize");
264        assert_eq!(id, back);
265    }
266
267    #[test]
268    fn prefixed_id_serde_rejects_wrong_prefix() {
269        let json = "\"sess_01ABC\"";
270        let err = serde_json::from_str::<JobId>(json).expect_err("must fail");
271        assert!(err.to_string().contains("expected prefix"));
272    }
273
274    #[test]
275    fn freeform_id_accepts_arbitrary_strings() {
276        let key = IdempotencyKey::new("refund-ord_4812").expect("non-empty");
277        assert_eq!(key.as_str(), "refund-ord_4812");
278        let s = serde_json::to_string(&key).expect("serialize");
279        let back: IdempotencyKey = serde_json::from_str(&s).expect("deserialize");
280        assert_eq!(key, back);
281    }
282
283    #[test]
284    fn freeform_id_rejects_empty() {
285        let err = IdempotencyKey::new("").expect_err("must reject empty");
286        assert!(matches!(err, IdParseError::Empty { .. }));
287    }
288
289    #[test]
290    fn id_types_are_compile_time_distinct() {
291        // SessionId and MessageId are distinct types — this tests that
292        // the type system enforces distinctness. We can't *actually* mix
293        // them at the call site without a compile error. But we can show
294        // that distinct ids produced by both types have distinct prefixes.
295        let s = SessionId::new().to_string();
296        let m = MessageId::new().to_string();
297        assert_ne!(s, m);
298        assert!(s.starts_with("sess_"));
299        assert!(m.starts_with("msg_"));
300    }
301
302    #[test]
303    fn ids_are_hashable() {
304        let mut set = HashSet::new();
305        set.insert(JobId::new());
306        set.insert(JobId::new());
307        assert_eq!(set.len(), 2);
308    }
309
310    #[test]
311    fn all_prefixes_are_unique() {
312        let prefixes = [
313            SessionId::prefix(),
314            MessageId::prefix(),
315            JobId::prefix(),
316            StreamId::prefix(),
317            SubscriptionId::prefix(),
318            LeaseId::prefix(),
319            ArtifactId::prefix(),
320        ];
321        let unique: HashSet<&&str> = prefixes.iter().collect();
322        assert_eq!(unique.len(), prefixes.len(), "id prefixes must not collide");
323    }
324}