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}