1use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14use uuid::Uuid;
15
16pub const RECORD_ID_SCHEME: &str = "pd";
18
19#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
20pub enum IdError {
21 #[error("invalid origin {0:?}: expected lowercase DNS-like name (a-z, 0-9, '-', '.')")]
22 InvalidOrigin(String),
23 #[error("invalid record id {0:?}: expected pd:<origin>:<uuid>")]
24 InvalidRecordId(String),
25 #[error("invalid namespaced name {0:?}: expected <namespace>:<name>")]
26 InvalidName(String),
27}
28
29#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct Origin(String);
32
33impl Origin {
34 pub fn new(value: impl Into<String>) -> Result<Self, IdError> {
35 let value = value.into();
36 let valid = !value.is_empty()
37 && value.len() <= 253
38 && value.split('.').all(|label| {
39 !label.is_empty()
40 && label.len() <= 63
41 && !label.starts_with('-')
42 && !label.ends_with('-')
43 && label.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
44 });
45 if valid { Ok(Self(value)) } else { Err(IdError::InvalidOrigin(value)) }
46 }
47
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51}
52
53impl fmt::Display for Origin {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.write_str(&self.0)
56 }
57}
58
59impl FromStr for Origin {
60 type Err = IdError;
61 fn from_str(s: &str) -> Result<Self, Self::Err> {
62 Origin::new(s)
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
68pub struct RecordId {
69 origin: Origin,
70 local: Uuid,
71}
72
73impl RecordId {
74 pub fn generate(origin: &Origin) -> Self {
76 Self { origin: origin.clone(), local: Uuid::now_v7() }
77 }
78
79 pub fn from_parts(origin: Origin, local: Uuid) -> Self {
80 Self { origin, local }
81 }
82
83 pub fn origin(&self) -> &Origin {
84 &self.origin
85 }
86
87 pub fn local(&self) -> Uuid {
88 self.local
89 }
90
91 pub fn is_from(&self, origin: &Origin) -> bool {
92 &self.origin == origin
93 }
94}
95
96impl fmt::Display for RecordId {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 write!(f, "{RECORD_ID_SCHEME}:{}:{}", self.origin, self.local.hyphenated())
99 }
100}
101
102impl FromStr for RecordId {
103 type Err = IdError;
104 fn from_str(s: &str) -> Result<Self, Self::Err> {
105 let err = || IdError::InvalidRecordId(s.to_string());
106 let rest = s.strip_prefix("pd:").ok_or_else(err)?;
107 let (origin, local) = rest.rsplit_once(':').ok_or_else(err)?;
108 let origin = Origin::new(origin).map_err(|_| err())?;
109 let local = Uuid::parse_str(local).map_err(|_| err())?;
112 if local.hyphenated().to_string() != rest.rsplit_once(':').unwrap().1 {
113 return Err(err());
114 }
115 Ok(Self { origin, local })
116 }
117}
118
119impl Serialize for RecordId {
120 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
121 s.collect_str(self)
122 }
123}
124
125impl<'de> Deserialize<'de> for RecordId {
126 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
127 let s = String::deserialize(d)?;
128 s.parse().map_err(serde::de::Error::custom)
129 }
130}
131
132impl Serialize for Origin {
133 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
134 s.serialize_str(&self.0)
135 }
136}
137
138impl<'de> Deserialize<'de> for Origin {
139 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
140 let s = String::deserialize(d)?;
141 Origin::new(s).map_err(serde::de::Error::custom)
142 }
143}
144
145macro_rules! uuid_id {
146 ($(#[$meta:meta])* $name:ident) => {
147 $(#[$meta])*
148 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
149 #[serde(transparent)]
150 pub struct $name(pub Uuid);
151
152 impl $name {
153 pub fn generate() -> Self {
154 Self(Uuid::now_v7())
155 }
156 }
157
158 impl fmt::Display for $name {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 fmt::Display::fmt(&self.0, f)
161 }
162 }
163
164 impl FromStr for $name {
165 type Err = uuid::Error;
166 fn from_str(s: &str) -> Result<Self, Self::Err> {
167 Uuid::parse_str(s).map(Self)
168 }
169 }
170 };
171}
172
173uuid_id!(
174 EventId
176);
177uuid_id!(
178 RelationId
180);
181uuid_id!(
182 SourceId
184);
185
186#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
190pub struct NamespacedName {
191 namespace: String,
192 name: String,
193}
194
195impl NamespacedName {
196 pub fn new(namespace: &str, name: &str) -> Result<Self, IdError> {
197 let err = || IdError::InvalidName(format!("{namespace}:{name}"));
198 if !is_namespace(namespace) || !is_simple_name(name) {
199 return Err(err());
200 }
201 Ok(Self { namespace: namespace.to_string(), name: name.to_string() })
202 }
203
204 pub fn namespace(&self) -> &str {
205 &self.namespace
206 }
207
208 pub fn name(&self) -> &str {
209 &self.name
210 }
211}
212
213pub fn is_namespace(s: &str) -> bool {
215 let mut bytes = s.bytes();
216 s.len() <= 64
217 && matches!(bytes.next(), Some(b'a'..=b'z'))
218 && bytes.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-'))
219}
220
221pub fn is_simple_name(s: &str) -> bool {
223 let mut bytes = s.bytes();
224 s.len() <= 64
225 && matches!(bytes.next(), Some(b'a'..=b'z'))
226 && bytes.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
227}
228
229impl fmt::Display for NamespacedName {
230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231 write!(f, "{}:{}", self.namespace, self.name)
232 }
233}
234
235impl FromStr for NamespacedName {
236 type Err = IdError;
237 fn from_str(s: &str) -> Result<Self, Self::Err> {
238 let (ns, name) = s.split_once(':').ok_or_else(|| IdError::InvalidName(s.to_string()))?;
239 NamespacedName::new(ns, name)
240 }
241}
242
243impl Serialize for NamespacedName {
244 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
245 s.collect_str(self)
246 }
247}
248
249impl<'de> Deserialize<'de> for NamespacedName {
250 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
251 let s = String::deserialize(d)?;
252 s.parse().map_err(serde::de::Error::custom)
253 }
254}
255
256macro_rules! open_vocabulary {
259 ($(#[$meta:meta])* $name:ident { $($(#[$vmeta:meta])* $variant:ident => $s:literal),* $(,)? }) => {
260 $(#[$meta])*
261 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
262 pub enum $name {
263 $($(#[$vmeta])* $variant,)*
264 Extension($crate::id::NamespacedName),
266 }
267
268 impl $name {
269 pub const CORE: &'static [&'static str] = &[$($s),*];
271
272 pub fn as_str(&self) -> std::borrow::Cow<'_, str> {
273 match self {
274 $(Self::$variant => std::borrow::Cow::Borrowed($s),)*
275 Self::Extension(n) => std::borrow::Cow::Owned(n.to_string()),
276 }
277 }
278
279 pub fn is_extension(&self) -> bool {
280 matches!(self, Self::Extension(_))
281 }
282 }
283
284 impl std::fmt::Display for $name {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.write_str(&self.as_str())
287 }
288 }
289
290 impl std::str::FromStr for $name {
291 type Err = $crate::id::IdError;
292 fn from_str(s: &str) -> Result<Self, Self::Err> {
293 match s {
294 $($s => Ok(Self::$variant),)*
295 other => other.parse().map(Self::Extension),
296 }
297 }
298 }
299
300 impl serde::Serialize for $name {
301 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
302 s.serialize_str(&self.as_str())
303 }
304 }
305
306 impl<'de> serde::Deserialize<'de> for $name {
307 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
308 let s = <String as serde::Deserialize>::deserialize(d)?;
309 s.parse().map_err(serde::de::Error::custom)
310 }
311 }
312 };
313}
314pub(crate) use open_vocabulary;
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn record_id_round_trips() {
322 let origin = Origin::new("ledger.example.org").unwrap();
323 let id = RecordId::generate(&origin);
324 let text = id.to_string();
325 assert!(text.starts_with("pd:ledger.example.org:"));
326 assert_eq!(text.parse::<RecordId>().unwrap(), id);
327 }
328
329 #[test]
330 fn rejects_malformed_ids() {
331 for bad in [
332 "",
333 "pd:",
334 "pd:Example.org:0190a5d2-5b8e-7cc4-9a43-6e3d6f0c3c11",
335 "xx:example.org:0190a5d2-5b8e-7cc4-9a43-6e3d6f0c3c11",
336 "pd:example.org:not-a-uuid",
337 "pd:example.org:0190A5D2-5B8E-7CC4-9A43-6E3D6F0C3C11",
338 "pd:-bad.org:0190a5d2-5b8e-7cc4-9a43-6e3d6f0c3c11",
339 ] {
340 assert!(bad.parse::<RecordId>().is_err(), "{bad} should be rejected");
341 }
342 }
343
344 #[test]
345 fn namespaced_names() {
346 let n: NamespacedName = "app:status_change".parse().unwrap();
347 assert_eq!(n.namespace(), "app");
348 assert_eq!(n.name(), "status_change");
349 assert!("App:x".parse::<NamespacedName>().is_err());
350 assert!("nocolon".parse::<NamespacedName>().is_err());
351 assert!("a:b:c".parse::<NamespacedName>().is_err());
352 }
353}