Skip to main content

pardarsh_core/
id.rs

1//! Identifiers.
2//!
3//! Every record has a globally unambiguous [`RecordId`] of the form
4//! `pd:<origin>:<uuid>`, where `<origin>` is the DNS-like authority of the
5//! instance that minted it and `<uuid>` is a UUIDv7 (time-ordered). Because
6//! the origin is part of the identifier, records from independently operated
7//! instances can be referenced without coordination, which keeps federation
8//! possible later without changing the identifier format.
9
10use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14use uuid::Uuid;
15
16/// Prefix of every serialized record identifier.
17pub 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/// The authority (instance) that minted an identifier, e.g. `records.example.org`.
30#[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/// Stable, globally unambiguous record identifier: `pd:<origin>:<uuid>`.
67#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
68pub struct RecordId {
69    origin: Origin,
70    local: Uuid,
71}
72
73impl RecordId {
74    /// Mint a new identifier at `origin`. UUIDv7 keeps ids roughly time-ordered.
75    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        // Only the canonical lowercase hyphenated form is accepted so that one
110        // record cannot be referred to by several distinct strings.
111        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    /// Identifier of a single event in a record's history.
175    EventId
176);
177uuid_id!(
178    /// Identifier of a relationship asserted on a record.
179    RelationId
180);
181uuid_id!(
182    /// Identifier of a source reference attached to a record.
183    SourceId
184);
185
186/// A `namespace:name` pair used for extension record kinds, extension
187/// relations, roles, event tags and annotations. Namespaces keep application
188/// vocabulary (e.g. `app:status_change`) out of the core vocabulary.
189#[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
213/// `[a-z][a-z0-9_.-]*`, at most 64 bytes.
214pub 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
221/// `[a-z][a-z0-9_]*`, at most 64 bytes.
222pub 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
256/// Defines a vocabulary with a fixed set of core terms plus namespaced
257/// extension terms, serialized as plain strings (`"issue"`, `"app:thing"`).
258macro_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            /// A namespaced term defined outside the core vocabulary.
265            Extension($crate::id::NamespacedName),
266        }
267
268        impl $name {
269            /// The core (non-extension) terms of this vocabulary.
270            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}