Skip to main content

lunaris_core/
error.rs

1//! Error taxonomy — one umbrella enum, one sub-enum per subsystem.
2
3use thiserror::Error;
4
5/// Top-level error type returned by every public `lunaris` API.
6///
7/// `#[non_exhaustive]` lets us add new top-level subsystems (e.g. a
8/// future `Verify(VerifyError)` variant) in a patch release without
9/// breaking downstream `match` exhaustiveness checks. Downstream code
10/// should always include a wildcard arm.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum LunarisError {
14    #[error("storage: {0}")]
15    Storage(#[from] StorageError),
16    #[error("extract: {0}")]
17    Extract(#[from] ExtractError),
18    #[error("validate: {0}")]
19    Validate(#[from] ValidateError),
20    #[error("retrieve: {0}")]
21    Retrieve(#[from] RetrieveError),
22    #[error("consolidate: {0}")]
23    Consolidate(#[from] ConsolError),
24    /// A partition key was rejected by [`crate::Scope::new`].
25    ///
26    /// Added because `Result<_, LunarisError>` is the natural signature for a
27    /// function that talks to Lunaris, and naming a partition inside one is the
28    /// second thing every caller does:
29    ///
30    /// ```
31    /// # use lunaris_core::{LunarisError, Scope};
32    /// fn open_a_partition(name: &str) -> Result<Scope, LunarisError> {
33    ///     Ok(Scope::new(name)?)
34    /// }
35    /// ```
36    ///
37    /// Without this variant that `?` is E0277 and every caller has to write a
38    /// `map_err` that throws the reason away. Ten cookbook pages taught the
39    /// pattern above before anything compiled them (W4.18).
40    ///
41    /// Kept as its own variant rather than folded into a stringly one: a scope
42    /// typo and a storage outage are different problems and a reader triaging a
43    /// log should not have to tell them apart by message text.
44    #[error("scope: {0}")]
45    Scope(#[from] crate::ScopeError),
46}
47
48macro_rules! subsystems {
49    ($($variant:ident => $label:literal / $code:literal / $sample:expr),+ $(,)?) => {
50        /// Coarse subsystem tag for a [`LunarisError`] — one classifying match, inside
51        /// the crate that owns the enum.
52        ///
53        /// Four places classified `LunarisError` by variant independently: the
54        /// Prometheus `kind` label, the HTTP status map, and the Python and TypeScript
55        /// SDK error codes. Every one ended in a wildcard arm — not by carelessness,
56        /// but because `LunarisError` is `#[non_exhaustive]` and a *downstream* crate
57        /// has no choice. So the compiler could never flag a new variant going
58        /// unclassified, and when `Scope` was added all four silently began reporting
59        /// it as unknown. Two of them carried a claim of totality: a comment reading
60        /// "New variants in the future MUST extend this match", and a test named
61        /// `error_kind_maps_every_lunaris_error_variant`. Writing the instruction down
62        /// did not make the next variant obey it.
63        ///
64        /// `#[non_exhaustive]` does not apply inside the defining crate, so the match
65        /// in [`LunarisError::subsystem`] is exhaustiveness-checked for real: adding a
66        /// variant without tagging it fails to compile *here*. Consumers still write
67        /// their wildcard, but they can now walk [`Subsystem::ALL`] to prove their own
68        /// map is total — turning a silent "unknown" into a failing test.
69        ///
70        /// ```
71        /// # use lunaris_core::{LunarisError, ScopeError, Subsystem};
72        /// let err = LunarisError::Scope(ScopeError::Invalid("bad:scope".into()));
73        /// assert_eq!(err.subsystem(), Subsystem::Scope);
74        /// assert_eq!(err.subsystem().label(), "scope");  // metrics / HTTP envelope
75        /// assert_eq!(err.subsystem().code(), "SCOPE");   // Python + TypeScript SDKs
76        /// ```
77        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78        #[non_exhaustive]
79        pub enum Subsystem { $($variant),+ }
80
81        impl Subsystem {
82            /// Every subsystem, in declaration order. Generated from the same
83            /// list as [`LunarisError::subsystem`], so it cannot fall behind
84            /// the enum the way a hand-written list does.
85            pub const ALL: &'static [Subsystem] = &[$(Subsystem::$variant),+];
86
87            /// Lowercase tag — the Prometheus `kind` label and the `error`
88            /// field of the server's JSON envelope.
89            pub const fn label(self) -> &'static str {
90                match self { $(Subsystem::$variant => $label),+ }
91            }
92
93            /// Uppercase tag — the `code` the Python and TypeScript SDKs put
94            /// in front of the message so callers can branch without parsing.
95            pub const fn code(self) -> &'static str {
96                match self { $(Subsystem::$variant => $code),+ }
97            }
98
99            /// A representative error of this subsystem.
100            ///
101            /// Exists so a consumer that maps `LunarisError` to something of
102            /// its own — an HTTP status, an SDK code — can walk [`Self::ALL`]
103            /// and feed its mapper a real value for every subsystem, instead
104            /// of hand-listing the ones whoever wrote the test remembered.
105            /// Every such mapper needs a wildcard arm it cannot delete, so
106            /// this is the only way to prove the arm is unreachable.
107            pub fn sample_error(self) -> LunarisError {
108                match self { $(Subsystem::$variant => LunarisError::$variant($sample)),+ }
109            }
110        }
111
112        impl LunarisError {
113            /// The coarse subsystem this error came from.
114            ///
115            /// Prefer this over matching the variant yourself: a downstream
116            /// match needs a wildcard arm and will silently swallow whatever
117            /// gets added next.
118            pub fn subsystem(&self) -> Subsystem {
119                match self { $(LunarisError::$variant(_) => Subsystem::$variant),+ }
120            }
121        }
122    };
123}
124
125subsystems! {
126    Storage     => "storage"     / "STORAGE"     / StorageError::Backend("sample".into()),
127    Extract     => "extract"     / "EXTRACT"     / ExtractError::Backend("sample".into()),
128    Validate    => "validate"    / "VALIDATE"    / ValidateError::Temporal,
129    Retrieve    => "retrieve"    / "RETRIEVE"    / RetrieveError::Backend("sample".into()),
130    Consolidate => "consolidate" / "CONSOLIDATE" / ConsolError::Backend("sample".into()),
131    Scope       => "scope"       / "SCOPE"       / crate::ScopeError::Invalid("sample".into()),
132}
133
134#[derive(Debug, Error)]
135#[non_exhaustive]
136pub enum StorageError {
137    #[error("backend: {0}")]
138    Backend(String),
139    #[error("not supported: {0}")]
140    NotSupported(&'static str),
141    #[error("unsupported scheme: {0}")]
142    UnsupportedScheme(String),
143    #[error("serialization: {0}")]
144    Serde(#[from] serde_json::Error),
145    #[error("io: {0}")]
146    Io(#[from] std::io::Error),
147}
148
149#[derive(Debug, Error)]
150#[non_exhaustive]
151pub enum ExtractError {
152    #[error("model timeout")]
153    Timeout,
154    #[error("grammar reject: {0}")]
155    GrammarReject(String),
156    #[error("backend: {0}")]
157    Backend(String),
158}
159
160#[derive(Debug, Error)]
161#[non_exhaustive]
162pub enum ValidateError {
163    #[error("temporal: valid_from >= valid_to")]
164    Temporal,
165    #[error("contradiction: {0}")]
166    Contradiction(String),
167    /// B-3 fix (Plan 04-05): hard-delete in `Lunaris::forget(target.hard())`
168    /// requires a confirmation token from a prior `dry_run` +
169    /// `confirm_hard_forget` round-trip. Returned when caller invokes
170    /// `.hard()` without `.with_token(...)` (D-21 safety rail).
171    #[error("confirmation required: {0}")]
172    ConfirmationRequired(String),
173}
174
175#[derive(Debug, Error)]
176#[non_exhaustive]
177pub enum RetrieveError {
178    #[error("operator failed: {0}")]
179    OperatorFailed(String),
180    #[error("backend: {0}")]
181    Backend(String),
182}
183
184#[derive(Debug, Error)]
185#[non_exhaustive]
186pub enum ConsolError {
187    #[error("activation underflow")]
188    ActivationUnderflow,
189    #[error("backend: {0}")]
190    Backend(String),
191}
192
193#[cfg(test)]
194mod subsystem_tests {
195    use super::*;
196
197    /// The point of the whole type: a variant that reaches a consumer must
198    /// already have a tag. This match is exhaustive *inside* the defining
199    /// crate, so adding a `LunarisError` variant breaks this test's
200    /// compilation — which is the only signal that reliably survives, since
201    /// every downstream match is forced to carry a wildcard.
202    #[test]
203    fn every_error_variant_has_a_tag() {
204        let cases: Vec<LunarisError> = vec![
205            StorageError::Backend("x".into()).into(),
206            ExtractError::Backend("x".into()).into(),
207            ValidateError::Temporal.into(),
208            RetrieveError::Backend("x".into()).into(),
209            ConsolError::Backend("x".into()).into(),
210            crate::ScopeError::Invalid("bad:scope".into()).into(),
211        ];
212        for err in &cases {
213            let sub = err.subsystem();
214            assert!(Subsystem::ALL.contains(&sub), "{sub:?} is not in Subsystem::ALL");
215        }
216        assert_eq!(
217            cases.len(),
218            Subsystem::ALL.len(),
219            "this list and Subsystem::ALL disagree — a variant is untested or untagged"
220        );
221
222        // `sample_error` must round-trip: the error it hands back for a
223        // subsystem has to classify back to that same subsystem, or a
224        // consumer walking ALL would be testing the wrong arm.
225        for sub in Subsystem::ALL {
226            assert_eq!(sub.sample_error().subsystem(), *sub, "{sub:?} sample_error round-trip");
227        }
228    }
229
230    /// A copy-paste in the macro invocation would give two subsystems the same
231    /// tag, which reads as one subsystem in a metrics dashboard and in an SDK
232    /// caller's `if code == ...`. Duplicate arms in `label`/`code` are only a
233    /// warning, so assert it.
234    #[test]
235    fn tags_are_distinct_and_consistently_cased() {
236        let labels: std::collections::HashSet<_> =
237            Subsystem::ALL.iter().map(|s| s.label()).collect();
238        let codes: std::collections::HashSet<_> = Subsystem::ALL.iter().map(|s| s.code()).collect();
239        assert_eq!(labels.len(), Subsystem::ALL.len(), "duplicate label");
240        assert_eq!(codes.len(), Subsystem::ALL.len(), "duplicate code");
241        for s in Subsystem::ALL {
242            assert_eq!(
243                s.code(),
244                s.label().to_ascii_uppercase(),
245                "{s:?}: code and label must be the same word, cased for their audience"
246            );
247            assert_ne!(s.label(), "unknown", "\"unknown\" is the wildcard's answer, not a tag");
248        }
249    }
250}