saddle-core 0.3.12

Shared contracts for Saddle components
Documentation
use std::{fmt, sync::Arc};

pub const MAX_TRACE_CORRELATION_ID_BYTES: usize = 256;

macro_rules! string_id {
    ($name:ident) => {
        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
        pub struct $name(Arc<str>);

        impl $name {
            pub fn new(value: impl Into<Arc<str>>) -> Self {
                Self(value.into())
            }

            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl From<&str> for $name {
            fn from(value: &str) -> Self {
                Self::new(value)
            }
        }

        impl From<String> for $name {
            fn from(value: String) -> Self {
                Self::new(value)
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(&self.0)
            }
        }
    };
}

string_id!(ApplicationId);
string_id!(ModuleId);
string_id!(ServiceId);
string_id!(OperationId);

/// Validated protocol RPC correlation, distinct from an internal span ID.
/// This value carries identity only; it never allocates a child sequence.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RpcCorrelationId(Arc<str>);

impl RpcCorrelationId {
    pub fn new(value: &str) -> Option<Self> {
        if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
            return None;
        }
        Some(Self(Arc::from(value)))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct TraceId(u128);

impl TraceId {
    pub const fn from_u128(value: u128) -> Self {
        Self(value)
    }

    pub const fn as_u128(self) -> u128 {
        self.0
    }
}

impl fmt::Display for TraceId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{:032x}", self.0)
    }
}

#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct TraceCorrelationId(Arc<str>);

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TraceCorrelationIdError {
    Empty,
    TooLong,
    ControlCharacter,
}

impl TraceCorrelationId {
    pub fn new(value: impl Into<Arc<str>>) -> Result<Self, TraceCorrelationIdError> {
        let value = value.into();
        if value.is_empty() {
            return Err(TraceCorrelationIdError::Empty);
        }
        if value.len() > MAX_TRACE_CORRELATION_ID_BYTES {
            return Err(TraceCorrelationIdError::TooLong);
        }
        if value.chars().any(char::is_control) {
            return Err(TraceCorrelationIdError::ControlCharacter);
        }
        Ok(Self(value))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for TraceCorrelationId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.0)
    }
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SpanId(u64);

impl SpanId {
    pub const fn from_u64(value: u64) -> Self {
        Self(value)
    }

    pub const fn as_u64(self) -> u64 {
        self.0
    }
}

impl fmt::Display for SpanId {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{:016x}", self.0)
    }
}

/// The minimum identity propagated through Service, DB and Observability in V1.
///
/// Deadline, cancellation and resource-budget fields are deliberately absent
/// until a version explicitly defines their behavior.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CallContext {
    application: ApplicationId,
    module: ModuleId,
    service: ServiceId,
    operation: OperationId,
    trace_id: TraceId,
    trace_correlation_id: TraceCorrelationId,
    span_id: SpanId,
    rpc_correlation_id: Option<RpcCorrelationId>,
}

impl CallContext {
    pub fn new(
        application: ApplicationId,
        module: ModuleId,
        service: ServiceId,
        operation: OperationId,
        trace_id: TraceId,
        span_id: SpanId,
    ) -> Self {
        let trace_correlation_id = TraceCorrelationId(Arc::from(trace_id.to_string()));
        Self {
            application,
            module,
            service,
            operation,
            trace_id,
            trace_correlation_id,
            span_id,
            rpc_correlation_id: None,
        }
    }

    pub fn with_trace_correlation_id(mut self, trace_correlation_id: TraceCorrelationId) -> Self {
        self.trace_correlation_id = trace_correlation_id;
        self
    }

    pub fn application(&self) -> &ApplicationId {
        &self.application
    }

    pub fn with_rpc_correlation_id(mut self, rpc: Option<RpcCorrelationId>) -> Self {
        self.rpc_correlation_id = rpc;
        self
    }

    pub fn rpc_correlation_id(&self) -> Option<&RpcCorrelationId> {
        self.rpc_correlation_id.as_ref()
    }

    pub fn module(&self) -> &ModuleId {
        &self.module
    }

    pub fn service(&self) -> &ServiceId {
        &self.service
    }

    pub fn operation(&self) -> &OperationId {
        &self.operation
    }

    pub const fn trace_id(&self) -> TraceId {
        self.trace_id
    }

    pub fn trace_correlation_id(&self) -> &TraceCorrelationId {
        &self.trace_correlation_id
    }

    pub const fn span_id(&self) -> SpanId {
        self.span_id
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn protocol_rpc_validation_does_not_mint_or_replace_identity() {
        for value in ["0", "0.1", "0.12.3"] {
            assert_eq!(RpcCorrelationId::new(value).unwrap().as_str(), value);
        }
        for value in ["", " ", "0\n1", "0\u{7f}"] {
            assert!(RpcCorrelationId::new(value).is_none());
        }
        assert!(RpcCorrelationId::new(&"1".repeat(256)).is_some());
        assert!(RpcCorrelationId::new(&"1".repeat(257)).is_none());
    }

    #[test]
    fn identifiers_have_stable_display_forms() {
        assert_eq!(TraceId::from_u128(42).to_string().len(), 32);
        assert_eq!(SpanId::from_u64(42).to_string().len(), 16);
        assert_eq!(ServiceId::from("orders").to_string(), "orders");
    }

    #[test]
    fn opaque_trace_correlation_id_is_bounded_and_rejects_controls() {
        assert_eq!(
            TraceCorrelationId::new("trace-1").unwrap().as_str(),
            "trace-1"
        );
        assert_eq!(
            TraceCorrelationId::new(""),
            Err(TraceCorrelationIdError::Empty)
        );
        assert_eq!(
            TraceCorrelationId::new("x".repeat(MAX_TRACE_CORRELATION_ID_BYTES + 1)),
            Err(TraceCorrelationIdError::TooLong)
        );
        assert_eq!(
            TraceCorrelationId::new("trace\n1"),
            Err(TraceCorrelationIdError::ControlCharacter)
        );
    }
}