use std::fmt;
use std::str::FromStr;
use schemars::gen::SchemaGenerator;
use schemars::schema::{InstanceType, Schema, SchemaObject};
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use ulid::Ulid;
use crate::error::CoreError;
macro_rules! prefix_ulid_newtype {
($name:ident, $prefix:literal, $doc:literal) => {
#[doc = $doc]
#[doc = $prefix]
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $name(pub Ulid);
impl $name {
pub const PREFIX: &'static str = $prefix;
#[must_use]
pub fn new() -> Self {
Self(Ulid::new())
}
#[must_use]
pub const fn from_ulid(u: Ulid) -> Self {
Self(u)
}
#[must_use]
pub const fn as_ulid(&self) -> &Ulid {
&self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}_{}", $prefix, self.0)
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple(stringify!($name))
.field(&self.to_string())
.finish()
}
}
impl FromStr for $name {
type Err = CoreError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let expected_prefix = concat!($prefix, "_");
let body = s.strip_prefix(expected_prefix).ok_or_else(|| {
CoreError::IdParse(format!(
"id `{s}` does not start with expected prefix `{expected_prefix}`"
))
})?;
let ulid = Ulid::from_string(body).map_err(|e| {
CoreError::IdParse(format!("invalid ULID body in id `{s}`: {e}"))
})?;
Ok(Self(ulid))
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.collect_str(self)
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl JsonSchema for $name {
fn schema_name() -> String {
stringify!($name).to_string()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let mut s = SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
};
s.metadata().description = Some(format!(
"Prefix-ULID identifier in the form `{prefix}_<26-char Crockford ULID>`",
prefix = $prefix,
));
let pattern = format!("^{prefix}_[0-9A-HJKMNP-TV-Z]{{26}}$", prefix = $prefix);
s.string().pattern = Some(pattern);
Schema::Object(s)
}
}
};
}
prefix_ulid_newtype!(EventId, "evt", "Append-only event identifier.");
prefix_ulid_newtype!(TraceId, "trc", "Trace / causal chain identifier.");
prefix_ulid_newtype!(EpisodeId, "epi", "Interpreted episode identifier.");
prefix_ulid_newtype!(MemoryId, "mem", "Durable memory identifier.");
prefix_ulid_newtype!(PrincipleId, "prn", "Hypothesis principle identifier.");
prefix_ulid_newtype!(DoctrineId, "doc", "Promoted doctrine identifier.");
prefix_ulid_newtype!(ContextPackId, "ctx", "Context pack identifier.");
prefix_ulid_newtype!(
ContradictionId,
"con",
"Contradiction (first-class conflict) identifier."
);
prefix_ulid_newtype!(AuditRecordId, "aud", "Audit record identifier.");
prefix_ulid_newtype!(
CorrelationId,
"cor",
"Cross-trace correlation identifier (joins related causal chains for audit)."
);
prefix_ulid_newtype!(
DecayJobId,
"dcy",
"Scheduled decay job identifier (Phase 4.D operator-fired compression)."
);
#[cfg(test)]
mod tests {
use super::*;
macro_rules! round_trip_test {
($fn_name:ident, $ty:ident, $prefix:literal) => {
#[test]
fn $fn_name() {
let id = $ty::new();
let s = id.to_string();
assert!(
s.starts_with(concat!($prefix, "_")),
"{} should start with `{}_`, got `{s}`",
stringify!($ty),
$prefix,
);
assert_eq!(s.len(), $prefix.len() + 1 + 26);
let back: $ty = s.parse().expect("parse back");
assert_eq!(id, back, "{} round-trip failed", stringify!($ty));
let bad = format!("xxx_{}", id.as_ulid());
assert!($ty::from_str(&bad).is_err());
}
};
}
round_trip_test!(round_trip_event_id, EventId, "evt");
round_trip_test!(round_trip_trace_id, TraceId, "trc");
round_trip_test!(round_trip_episode_id, EpisodeId, "epi");
round_trip_test!(round_trip_memory_id, MemoryId, "mem");
round_trip_test!(round_trip_principle_id, PrincipleId, "prn");
round_trip_test!(round_trip_doctrine_id, DoctrineId, "doc");
round_trip_test!(round_trip_context_pack_id, ContextPackId, "ctx");
round_trip_test!(round_trip_contradiction_id, ContradictionId, "con");
round_trip_test!(round_trip_audit_record_id, AuditRecordId, "aud");
round_trip_test!(round_trip_correlation_id, CorrelationId, "cor");
round_trip_test!(round_trip_decay_job_id, DecayJobId, "dcy");
#[test]
fn ids_serialize_as_transparent_strings() {
let id = EventId::new();
let j = serde_json::to_value(id).unwrap();
assert_eq!(j, serde_json::Value::String(id.to_string()));
let back: EventId = serde_json::from_value(j).unwrap();
assert_eq!(id, back);
}
#[test]
fn ids_reject_garbage_ulid_body() {
assert!("evt_NOT_A_VALID_ULID".parse::<EventId>().is_err());
assert!("evt_".parse::<EventId>().is_err());
assert!("evt_IIIIIIIIIIIIIIIIIIIIIIIIII".parse::<EventId>().is_err());
}
#[test]
fn ids_round_trip_a_known_ulid() {
let u = Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap();
let id = EventId::from_ulid(u);
assert_eq!(id.to_string(), "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV");
let back: EventId = id.to_string().parse().unwrap();
assert_eq!(back, id);
}
}