reliar-inbox 0.2.0

Transactional inbox deduplication: InboxStore/InboxHandler contracts, claim/complete/fail/purge semantics (no storage or transport dependency).
Documentation
//! The consumer identity half of the inbox's dedup key.

use core::fmt;
use core::str::FromStr;

/// Names the consumer whose progress a row records: the deduplication key is
/// `(scope, message_id)`, so two independent consumers of the same stream each process every
/// message once.
///
/// Capped at [`Self::MAX_LEN`] = 128 bytes, like `reliar-outbox`'s `WorkerId` — it lands in a
/// `text` column read on every claim. Non-empty and free of control characters; otherwise
/// unvalidated, because the value is the host's vocabulary, not Reliar's.
///
/// It is **stable across restarts** — the opposite of `WorkerId`, and for the opposite reason: a
/// restarted consumer must recognise its own completed work, whereas a restarted dispatcher must
/// not be able to complete its predecessor's claims.
///
/// ```
/// use reliar_inbox::InboxScope;
///
/// let scope = InboxScope::new("orders-projection").unwrap();
/// assert_eq!(scope.as_str(), "orders-projection");
/// assert!(InboxScope::new("").is_err());
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InboxScope(String);

impl InboxScope {
    /// Maximum length in bytes.
    pub const MAX_LEN: usize = 128;

    /// Validates and wraps a consumer scope name.
    ///
    /// # Errors
    ///
    /// [`InboxScopeError::Empty`] for an empty string, [`InboxScopeError::ControlCharacter`] for
    /// one containing a control character (checked in that order, then length), or
    /// [`InboxScopeError::TooLong`] for one over [`Self::MAX_LEN`] bytes.
    ///
    /// ```
    /// use reliar_inbox::{InboxScope, InboxScopeError};
    ///
    /// assert_eq!(InboxScope::new("").unwrap_err(), InboxScopeError::Empty);
    /// assert_eq!(
    ///     InboxScope::new("orders\nprojection").unwrap_err(),
    ///     InboxScopeError::ControlCharacter
    /// );
    /// assert!(InboxScope::new("a".repeat(129)).is_err());
    /// assert!(InboxScope::new("a".repeat(128)).is_ok());
    /// ```
    pub fn new(scope: impl Into<String>) -> Result<Self, InboxScopeError> {
        let scope = scope.into();

        if scope.is_empty() {
            return Err(InboxScopeError::Empty);
        }

        // Duplicated rather than shared: `reliar_core::ids::contains_control_char` is
        // `pub(crate)` there, and a one-line check is cheaper than widening core's public surface
        // to export it (ADR 0042 Amendment C.4). `InboxScope` is host-supplied configuration that
        // lands in a `text` column and is recorded on five of the inbox's spans, so a CR/LF or an
        // ESC inside it is the same log-injection surface `CorrelationId`/`ContentType`/`Headers`
        // each already close.
        if scope.chars().any(char::is_control) {
            return Err(InboxScopeError::ControlCharacter);
        }

        if scope.len() > Self::MAX_LEN {
            return Err(InboxScopeError::TooLong {
                len: scope.len(),
                max: Self::MAX_LEN,
            });
        }

        Ok(Self(scope))
    }

    /// Returns the scope as a string slice.
    ///
    /// ```
    /// use reliar_inbox::InboxScope;
    ///
    /// let scope = InboxScope::new("orders-projection").unwrap();
    /// assert_eq!(scope.as_str(), "orders-projection");
    /// ```
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

impl FromStr for InboxScope {
    type Err = InboxScopeError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::new(s)
    }
}

/// [`InboxScope::new`]'s validation failure. A construction error, not an operation failure —
/// deliberately **not** [`reliar_core::Classify`].
///
/// ```
/// use reliar_inbox::{InboxScope, InboxScopeError};
///
/// let err = InboxScope::new("").unwrap_err();
/// assert_eq!(err, InboxScopeError::Empty);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum InboxScopeError {
    /// The value was empty.
    Empty,

    /// The value contained a control character (including CR/LF — a log-injection surface).
    ControlCharacter,

    /// The value exceeded [`InboxScope::MAX_LEN`].
    TooLong {
        /// The value's actual length in bytes.
        len: usize,
        /// The maximum allowed length in bytes.
        max: usize,
    },
}

impl fmt::Display for InboxScopeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => f.write_str("scope must not be empty"),
            Self::ControlCharacter => f.write_str("scope must not contain a control character"),
            Self::TooLong { len, max } => {
                write!(f, "scope length {len} exceeds the maximum of {max}")
            }
        }
    }
}

impl std::error::Error for InboxScopeError {}

#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
mod serde_impls {
    use serde::{Deserialize, Serialize, de::Error as _};

    use super::InboxScope;

    impl Serialize for InboxScope {
        fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
            s.collect_str(&self.0)
        }
    }

    impl<'de> Deserialize<'de> for InboxScope {
        fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
            let raw = String::deserialize(d)?;

            Self::new(raw).map_err(D::Error::custom)
        }
    }
}