1use std::fmt;
14use std::str::FromStr;
15
16use serde::de::Error as _;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use ulid::Ulid;
19
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
22pub enum IdParseError {
23 #[error("expected prefix `{expected}_` for {kind}, got `{got}`")]
25 WrongPrefix {
26 kind: &'static str,
28 expected: &'static str,
30 got: String,
32 },
33 #[error("empty id body for {kind}")]
35 Empty {
36 kind: &'static str,
38 },
39}
40
41macro_rules! prefixed_id {
42 ($name:ident, $prefix:literal, $doc:literal) => {
43 #[doc = $doc]
44 #[doc = concat!("`", $prefix, "_<ULID>`")]
47 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
49 pub struct $name(String);
50
51 impl $name {
52 #[must_use]
54 pub fn new() -> Self {
55 Self(format!("{}_{}", $prefix, Ulid::new()))
56 }
57
58 #[must_use]
60 pub fn as_str(&self) -> &str {
61 &self.0
62 }
63
64 #[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 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 #[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 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}