1use std::fmt;
17use std::str::FromStr;
18
19use schemars::gen::SchemaGenerator;
20use schemars::schema::{InstanceType, Schema, SchemaObject};
21use schemars::JsonSchema;
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use ulid::Ulid;
24
25use crate::error::CoreError;
26
27macro_rules! prefix_ulid_newtype {
32 ($name:ident, $prefix:literal, $doc:literal) => {
33 #[doc = $doc]
34 #[doc = $prefix]
37 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
39 pub struct $name(pub Ulid);
40
41 impl $name {
42 pub const PREFIX: &'static str = $prefix;
44
45 #[must_use]
47 pub fn new() -> Self {
48 Self(Ulid::new())
49 }
50
51 #[must_use]
53 pub const fn from_ulid(u: Ulid) -> Self {
54 Self(u)
55 }
56
57 #[must_use]
59 pub const fn as_ulid(&self) -> &Ulid {
60 &self.0
61 }
62 }
63
64 impl Default for $name {
65 fn default() -> Self {
66 Self::new()
67 }
68 }
69
70 impl fmt::Display for $name {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(f, "{}_{}", $prefix, self.0)
74 }
75 }
76
77 impl fmt::Debug for $name {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.debug_tuple(stringify!($name))
80 .field(&self.to_string())
81 .finish()
82 }
83 }
84
85 impl FromStr for $name {
86 type Err = CoreError;
87
88 fn from_str(s: &str) -> Result<Self, Self::Err> {
89 let expected_prefix = concat!($prefix, "_");
90 let body = s.strip_prefix(expected_prefix).ok_or_else(|| {
91 CoreError::IdParse(format!(
92 "id `{s}` does not start with expected prefix `{expected_prefix}`"
93 ))
94 })?;
95 let ulid = Ulid::from_string(body).map_err(|e| {
96 CoreError::IdParse(format!("invalid ULID body in id `{s}`: {e}"))
97 })?;
98 Ok(Self(ulid))
99 }
100 }
101
102 impl Serialize for $name {
103 fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
104 ser.collect_str(self)
105 }
106 }
107
108 impl<'de> Deserialize<'de> for $name {
109 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
110 let s = String::deserialize(de)?;
111 s.parse().map_err(serde::de::Error::custom)
112 }
113 }
114
115 impl JsonSchema for $name {
116 fn schema_name() -> String {
117 stringify!($name).to_string()
118 }
119
120 fn json_schema(_: &mut SchemaGenerator) -> Schema {
121 let mut s = SchemaObject {
122 instance_type: Some(InstanceType::String.into()),
123 ..Default::default()
124 };
125 s.metadata().description = Some(format!(
126 "Prefix-ULID identifier in the form `{prefix}_<26-char Crockford ULID>`",
127 prefix = $prefix,
128 ));
129 let pattern = format!("^{prefix}_[0-9A-HJKMNP-TV-Z]{{26}}$", prefix = $prefix);
130 s.string().pattern = Some(pattern);
131 Schema::Object(s)
132 }
133 }
134 };
135}
136
137prefix_ulid_newtype!(EventId, "evt", "Append-only event identifier.");
138prefix_ulid_newtype!(TraceId, "trc", "Trace / causal chain identifier.");
139prefix_ulid_newtype!(EpisodeId, "epi", "Interpreted episode identifier.");
140prefix_ulid_newtype!(MemoryId, "mem", "Durable memory identifier.");
141prefix_ulid_newtype!(PrincipleId, "prn", "Hypothesis principle identifier.");
142prefix_ulid_newtype!(DoctrineId, "doc", "Promoted doctrine identifier.");
143prefix_ulid_newtype!(ContextPackId, "ctx", "Context pack identifier.");
144prefix_ulid_newtype!(
145 ContradictionId,
146 "con",
147 "Contradiction (first-class conflict) identifier."
148);
149prefix_ulid_newtype!(AuditRecordId, "aud", "Audit record identifier.");
150prefix_ulid_newtype!(
151 CorrelationId,
152 "cor",
153 "Cross-trace correlation identifier (joins related causal chains for audit)."
154);
155prefix_ulid_newtype!(
156 DecayJobId,
157 "dcy",
158 "Scheduled decay job identifier (Phase 4.D operator-fired compression)."
159);
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 macro_rules! round_trip_test {
167 ($fn_name:ident, $ty:ident, $prefix:literal) => {
168 #[test]
169 fn $fn_name() {
170 let id = $ty::new();
171 let s = id.to_string();
172 assert!(
173 s.starts_with(concat!($prefix, "_")),
174 "{} should start with `{}_`, got `{s}`",
175 stringify!($ty),
176 $prefix,
177 );
178 assert_eq!(s.len(), $prefix.len() + 1 + 26);
179 let back: $ty = s.parse().expect("parse back");
180 assert_eq!(id, back, "{} round-trip failed", stringify!($ty));
181
182 let bad = format!("xxx_{}", id.as_ulid());
184 assert!($ty::from_str(&bad).is_err());
185 }
186 };
187 }
188
189 round_trip_test!(round_trip_event_id, EventId, "evt");
190 round_trip_test!(round_trip_trace_id, TraceId, "trc");
191 round_trip_test!(round_trip_episode_id, EpisodeId, "epi");
192 round_trip_test!(round_trip_memory_id, MemoryId, "mem");
193 round_trip_test!(round_trip_principle_id, PrincipleId, "prn");
194 round_trip_test!(round_trip_doctrine_id, DoctrineId, "doc");
195 round_trip_test!(round_trip_context_pack_id, ContextPackId, "ctx");
196 round_trip_test!(round_trip_contradiction_id, ContradictionId, "con");
197 round_trip_test!(round_trip_audit_record_id, AuditRecordId, "aud");
198 round_trip_test!(round_trip_correlation_id, CorrelationId, "cor");
199 round_trip_test!(round_trip_decay_job_id, DecayJobId, "dcy");
200
201 #[test]
202 fn ids_serialize_as_transparent_strings() {
203 let id = EventId::new();
204 let j = serde_json::to_value(id).unwrap();
205 assert_eq!(j, serde_json::Value::String(id.to_string()));
206 let back: EventId = serde_json::from_value(j).unwrap();
207 assert_eq!(id, back);
208 }
209
210 #[test]
211 fn ids_reject_garbage_ulid_body() {
212 assert!("evt_NOT_A_VALID_ULID".parse::<EventId>().is_err());
214 assert!("evt_".parse::<EventId>().is_err());
215 assert!("evt_IIIIIIIIIIIIIIIIIIIIIIIIII".parse::<EventId>().is_err());
217 }
218
219 #[test]
220 fn ids_round_trip_a_known_ulid() {
221 let u = Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap();
222 let id = EventId::from_ulid(u);
223 assert_eq!(id.to_string(), "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV");
224 let back: EventId = id.to_string().parse().unwrap();
225 assert_eq!(back, id);
226 }
227}