indusagi-core 0.1.0

Cross-cutting primitives every indusagi crate depends on: cancellation, env registry, brand, locator, canonical-JSON, version, ids, errors, re-iterable channel.
Documentation
//! The shared error vocabulary for cross-cutting core primitives.
//!
//! This is a *closed* enum (deliberately not `#[non_exhaustive]`): the core
//! surface is small and every variant is enumerated so callers get compile-time
//! exhaustiveness on `match`. Subsystem crates define their own richer error
//! types (`GatewayError`, `RunError`, `ProtocolFault`, …); this enum covers only
//! the primitives that live in `indusagi-core`.
//!
//! Per the cancellation contract (`02_MODULE_CONVERSION_GUIDE.md` §1.5), a
//! cancelled operation yields a *typed* error — [`CoreError::Cancelled`] — never
//! a panic. Tools/connectors fold that into their own typed outcomes at the
//! seam; nothing in core ever swallows it into an `Ok`.

use std::fmt;

/// The closed error vocabulary for `indusagi-core` primitives.
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
    /// A cooperative cancellation was observed. Carries an optional reason for
    /// diagnostics; the byte-exact `Display` is `cancelled` when no reason is set
    /// (mirrors the Python `CancelledByToken` default message).
    #[error("{}", .0.as_deref().unwrap_or("cancelled"))]
    Cancelled(Option<String>),

    /// A value could not be canonically encoded for hashing (a non-finite number
    /// is *not* an error — it folds to `null` — so this is reserved for shapes
    /// the encoder genuinely cannot represent).
    #[error("canonical encoding failed: {0}")]
    Encoding(String),

    /// A required environment variable was absent or empty.
    #[error("missing environment variable: {0}")]
    MissingEnv(String),

    /// A filesystem location could not be prepared.
    #[error("filesystem: {0}")]
    Io(#[from] std::io::Error),
}

impl CoreError {
    /// Construct a [`CoreError::Cancelled`] with no reason.
    pub fn cancelled() -> Self {
        CoreError::Cancelled(None)
    }

    /// Construct a [`CoreError::Cancelled`] carrying `reason`.
    pub fn cancelled_with(reason: impl Into<String>) -> Self {
        CoreError::Cancelled(Some(reason.into()))
    }

    /// True iff this is a cancellation error. Lets call sites branch on
    /// "was this a cancel?" without a full `match`.
    pub fn is_cancelled(&self) -> bool {
        matches!(self, CoreError::Cancelled(_))
    }
}

/// Convenience alias for results carrying a [`CoreError`].
pub type CoreResult<T> = Result<T, CoreError>;

// A tiny manual helper so `?`-bubbled cancellations keep their reason when
// re-wrapped by subsystem error types that store a `String` cause.
impl CoreError {
    /// The human-readable detail, used when a caller flattens this into its own
    /// `String`-carrying typed error.
    pub fn detail(&self) -> impl fmt::Display + '_ {
        struct D<'a>(&'a CoreError);
        impl fmt::Display for D<'_> {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                write!(f, "{}", self.0)
            }
        }
        D(self)
    }
}

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

    #[test]
    fn cancelled_default_message_is_byte_exact() {
        assert_eq!(CoreError::cancelled().to_string(), "cancelled");
    }

    #[test]
    fn cancelled_with_reason_uses_the_reason() {
        assert_eq!(
            CoreError::cancelled_with("user aborted").to_string(),
            "user aborted"
        );
        assert!(CoreError::cancelled_with("x").is_cancelled());
    }

    #[test]
    fn non_cancel_variants_are_not_cancelled() {
        assert!(!CoreError::MissingEnv("X".into()).is_cancelled());
        assert_eq!(
            CoreError::MissingEnv("OPENAI_API_KEY".into()).to_string(),
            "missing environment variable: OPENAI_API_KEY"
        );
    }
}