Skip to main content

indusagi_core/
errors.rs

1//! The shared error vocabulary for cross-cutting core primitives.
2//!
3//! This is a *closed* enum (deliberately not `#[non_exhaustive]`): the core
4//! surface is small and every variant is enumerated so callers get compile-time
5//! exhaustiveness on `match`. Subsystem crates define their own richer error
6//! types (`GatewayError`, `RunError`, `ProtocolFault`, …); this enum covers only
7//! the primitives that live in `indusagi-core`.
8//!
9//! Per the cancellation contract (`02_MODULE_CONVERSION_GUIDE.md` §1.5), a
10//! cancelled operation yields a *typed* error — [`CoreError::Cancelled`] — never
11//! a panic. Tools/connectors fold that into their own typed outcomes at the
12//! seam; nothing in core ever swallows it into an `Ok`.
13
14use std::fmt;
15
16/// The closed error vocabulary for `indusagi-core` primitives.
17#[derive(Debug, thiserror::Error)]
18pub enum CoreError {
19    /// A cooperative cancellation was observed. Carries an optional reason for
20    /// diagnostics; the byte-exact `Display` is `cancelled` when no reason is set
21    /// (mirrors the Python `CancelledByToken` default message).
22    #[error("{}", .0.as_deref().unwrap_or("cancelled"))]
23    Cancelled(Option<String>),
24
25    /// A value could not be canonically encoded for hashing (a non-finite number
26    /// is *not* an error — it folds to `null` — so this is reserved for shapes
27    /// the encoder genuinely cannot represent).
28    #[error("canonical encoding failed: {0}")]
29    Encoding(String),
30
31    /// A required environment variable was absent or empty.
32    #[error("missing environment variable: {0}")]
33    MissingEnv(String),
34
35    /// A filesystem location could not be prepared.
36    #[error("filesystem: {0}")]
37    Io(#[from] std::io::Error),
38}
39
40impl CoreError {
41    /// Construct a [`CoreError::Cancelled`] with no reason.
42    pub fn cancelled() -> Self {
43        CoreError::Cancelled(None)
44    }
45
46    /// Construct a [`CoreError::Cancelled`] carrying `reason`.
47    pub fn cancelled_with(reason: impl Into<String>) -> Self {
48        CoreError::Cancelled(Some(reason.into()))
49    }
50
51    /// True iff this is a cancellation error. Lets call sites branch on
52    /// "was this a cancel?" without a full `match`.
53    pub fn is_cancelled(&self) -> bool {
54        matches!(self, CoreError::Cancelled(_))
55    }
56}
57
58/// Convenience alias for results carrying a [`CoreError`].
59pub type CoreResult<T> = Result<T, CoreError>;
60
61// A tiny manual helper so `?`-bubbled cancellations keep their reason when
62// re-wrapped by subsystem error types that store a `String` cause.
63impl CoreError {
64    /// The human-readable detail, used when a caller flattens this into its own
65    /// `String`-carrying typed error.
66    pub fn detail(&self) -> impl fmt::Display + '_ {
67        struct D<'a>(&'a CoreError);
68        impl fmt::Display for D<'_> {
69            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70                write!(f, "{}", self.0)
71            }
72        }
73        D(self)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn cancelled_default_message_is_byte_exact() {
83        assert_eq!(CoreError::cancelled().to_string(), "cancelled");
84    }
85
86    #[test]
87    fn cancelled_with_reason_uses_the_reason() {
88        assert_eq!(
89            CoreError::cancelled_with("user aborted").to_string(),
90            "user aborted"
91        );
92        assert!(CoreError::cancelled_with("x").is_cancelled());
93    }
94
95    #[test]
96    fn non_cancel_variants_are_not_cancelled() {
97        assert!(!CoreError::MissingEnv("X".into()).is_cancelled());
98        assert_eq!(
99            CoreError::MissingEnv("OPENAI_API_KEY".into()).to_string(),
100            "missing environment variable: OPENAI_API_KEY"
101        );
102    }
103}