crtx-core 0.1.1

Core IDs, errors, and schema constants for Cortex.
Documentation
//! Strongly-typed identifiers for Cortex primitives.
//!
//! Every ID is a **prefix + ULID** newtype (see BUILD_SPEC ยง9). The textual form is
//! `<prefix>_<26-char Crockford ULID>` (e.g. `evt_01ARZ3NDEKTSV4RRFFQ69G5FAV`). The
//! prefix is part of the wire format and is verified on parse: this prevents an
//! `EventId` from accidentally being constructed from, say, a `TraceId` string.
//!
//! IDs implement `Display` (writes the prefixed form), `FromStr` (parses and
//! validates the prefix + ULID), and `serde::{Serialize, Deserialize}` as a
//! transparent string. `JsonSchema` is implemented manually as a `string` so
//! generated schemas stay stable across `schemars` versions.
//!
//! Generation is via `<Id>::new()` (random ULID) or `<Id>::from_ulid(u)` (caller
//! supplies the ULID, useful for time-ordered IDs in tests / fixtures).

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;

/// Defines a prefix-ULID newtype with `Display` / `FromStr` / `Serialize` /
/// `Deserialize` / `JsonSchema` impls.
///
/// The textual form is `<prefix>_<26-char ULID>`; parse rejects any other shape.
macro_rules! prefix_ulid_newtype {
    ($name:ident, $prefix:literal, $doc:literal) => {
        #[doc = $doc]
        ///
        /// Wire format: `
        #[doc = $prefix]
        /// _<26-char Crockford ULID>`.
        #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
        pub struct $name(pub Ulid);

        impl $name {
            /// The textual prefix (without the trailing underscore).
            pub const PREFIX: &'static str = $prefix;

            /// Generate a new random ULID-backed ID.
            #[must_use]
            pub fn new() -> Self {
                Self(Ulid::new())
            }

            /// Wrap an existing ULID without parsing.
            #[must_use]
            pub const fn from_ulid(u: Ulid) -> Self {
                Self(u)
            }

            /// Borrow the underlying ULID.
            #[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 {
                // ULID `Display` writes the 26-char Crockford form.
                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::*;

    /// Round-trip every ID through `Display` -> `FromStr`. One body, nine types.
    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));

                // Wrong-prefix rejection: swapping `evt_` etc. for `xxx_` must fail.
                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() {
        // Right prefix, wrong body length / charset.
        assert!("evt_NOT_A_VALID_ULID".parse::<EventId>().is_err());
        assert!("evt_".parse::<EventId>().is_err());
        // Right prefix, but body uses `I` which is excluded from Crockford.
        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);
    }
}