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}