af-context 0.4.0

Transport-neutral tenant, subject, locale, and entitlement context.
Documentation
//! Branded identifiers shared by every Factory crate.
//!
//! Each id is a distinct newtype over a non-empty string so a `SessionId` can
//! never be passed where a `RunId` is expected. Serialization, `Display`,
//! ordering and hashing behave like the underlying string; store adapters bind
//! [`as_str`](TenantId::as_str) and wire adapters convert with `TryFrom<String>`
//! / [`FromStr`], which reject empty or whitespace-only input. There is no
//! infallible constructor and no `Default`.

use std::borrow::Borrow;
use std::fmt;
use std::str::FromStr;

/// The value given to an id constructor was empty or whitespace.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0} must not be empty")]
pub struct EmptyId(pub &'static str);

macro_rules! define_id {
    ($(#[$doc:meta])* $name:ident) => {
        $(#[$doc])*
        #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
        #[cfg_attr(feature = "sqlx", derive(sqlx::Type), sqlx(transparent))]
        #[serde(try_from = "String", into = "String")]
        pub struct $name(String);

        impl $name {
            /// Wraps a non-empty string; the only constructor, also used by
            /// `TryFrom`, `FromStr` and `Deserialize`.
            pub fn parse(value: impl Into<String>) -> Result<Self, EmptyId> {
                let value = value.into();
                if value.trim().is_empty() {
                    return Err(EmptyId(stringify!($name)));
                }
                Ok(Self(value))
            }

            /// Borrows the identifier as a string slice.
            pub fn as_str(&self) -> &str {
                &self.0
            }

            /// Consumes the identifier and returns the owned string.
            pub fn into_string(self) -> String {
                self.0
            }
        }

        impl fmt::Debug for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                write!(f, "{}({:?})", stringify!($name), self.0)
            }
        }

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

        impl FromStr for $name {
            type Err = EmptyId;
            fn from_str(value: &str) -> Result<Self, EmptyId> {
                Self::parse(value)
            }
        }

        impl TryFrom<String> for $name {
            type Error = EmptyId;
            fn try_from(value: String) -> Result<Self, EmptyId> {
                Self::parse(value)
            }
        }

        impl TryFrom<&str> for $name {
            type Error = EmptyId;
            fn try_from(value: &str) -> Result<Self, EmptyId> {
                Self::parse(value)
            }
        }

        impl TryFrom<&String> for $name {
            type Error = EmptyId;
            fn try_from(value: &String) -> Result<Self, EmptyId> {
                Self::parse(value.as_str())
            }
        }

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

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

        impl AsRef<str> for $name {
            fn as_ref(&self) -> &str {
                &self.0
            }
        }

        impl Borrow<str> for $name {
            fn borrow(&self) -> &str {
                &self.0
            }
        }

        impl std::ops::Deref for $name {
            type Target = str;
            fn deref(&self) -> &str {
                &self.0
            }
        }

        impl PartialEq<str> for $name {
            fn eq(&self, other: &str) -> bool {
                self.0 == other
            }
        }

        impl PartialEq<&str> for $name {
            fn eq(&self, other: &&str) -> bool {
                self.0 == *other
            }
        }

        impl PartialEq<String> for $name {
            fn eq(&self, other: &String) -> bool {
                &self.0 == other
            }
        }

        impl PartialEq<$name> for String {
            fn eq(&self, other: &$name) -> bool {
                self == &other.0
            }
        }

        impl PartialEq<$name> for &str {
            fn eq(&self, other: &$name) -> bool {
                *self == other.0
            }
        }
    };
}

define_id!(
    /// Tenant that owns every durable record.
    TenantId
);
define_id!(
    /// Authenticated subject acting inside a tenant.
    SubjectId
);
define_id!(
    /// Transport request identity used for tracing and idempotency.
    RequestId
);
define_id!(
    /// Append-only Agent Session identity.
    SessionId
);
define_id!(
    /// One Run inside a Session.
    RunId
);
define_id!(
    /// One queued input inside a Session inbox.
    InputId
);
define_id!(
    /// Correlates a tool call with its result.
    ToolCallId
);
define_id!(
    /// One interaction (approval or question) awaiting resolution.
    InteractionId
);
define_id!(
    /// Immutable Agent Profile revision pinned by a Session.
    ProfileRevisionId
);
define_id!(
    /// Durable workflow instance identity.
    InstanceId
);
define_id!(
    /// Durable workflow action intent identity.
    ActionIntentId
);
define_id!(
    /// Docs tree node (folder or document) identity.
    NodeId
);
define_id!(
    /// Docs Space identity.
    SpaceId
);
define_id!(
    /// Immutable Docs revision identity.
    RevisionId
);
define_id!(
    /// Docs asset identity.
    AssetId
);
define_id!(
    /// Immutable Docs release identity.
    ReleaseId
);
define_id!(
    /// Docs invitation identity.
    InviteId
);
define_id!(
    /// Idempotency identity for a durable command.
    CommandId
);

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

    #[test]
    fn ids_are_distinct_types_with_string_behaviour() {
        let session = SessionId::try_from("s1").unwrap();
        assert_eq!(session, "s1");
        assert_eq!(session.to_string(), "s1");
        assert_eq!(serde_json::to_string(&session).unwrap(), "\"s1\"");
        let parsed: SessionId = serde_json::from_str("\"s2\"").unwrap();
        assert_eq!(parsed.as_str(), "s2");
        assert_eq!(format!("{session:?}"), "SessionId(\"s1\")");
        assert_eq!(SessionId::parse("  "), Err(EmptyId("SessionId")));
        assert!("".parse::<RunId>().is_err());
        let mut set = std::collections::BTreeSet::new();
        set.insert(RunId::try_from(String::from("r")).unwrap());
        assert!(set.contains("r"));
    }

    #[test]
    fn every_constructor_rejects_empty_and_whitespace() {
        for blank in ["", "  ", "\t\n"] {
            assert_eq!(TenantId::try_from(blank), Err(EmptyId("TenantId")));
            assert_eq!(
                TenantId::try_from(blank.to_owned()),
                Err(EmptyId("TenantId"))
            );
            assert_eq!(
                TenantId::try_from(&blank.to_owned()),
                Err(EmptyId("TenantId"))
            );
            assert_eq!(blank.parse::<TenantId>(), Err(EmptyId("TenantId")));
            let json = serde_json::to_string(blank).unwrap();
            let rejected = serde_json::from_str::<TenantId>(&json).unwrap_err();
            assert!(
                rejected.to_string().contains("TenantId must not be empty"),
                "{rejected}"
            );
            #[derive(serde::Deserialize)]
            struct Envelope {
                #[allow(dead_code)]
                tenant_id: TenantId,
            }
            assert!(
                serde_json::from_str::<Envelope>(&format!("{{\"tenant_id\":{json}}}")).is_err()
            );
        }
    }
}