Skip to main content

qql_core/
error.rs

1use alloc::borrow::Cow;
2use alloc::boxed::Box;
3use alloc::vec::Vec;
4use core::fmt;
5
6/// Source-code span as UTF-8 byte offsets into the query text.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize))]
9pub struct Span {
10    /// Inclusive start byte offset.
11    pub start: usize,
12    /// Exclusive end byte offset.
13    pub end: usize,
14}
15
16impl Span {
17    /// Create a span from explicit byte offsets.
18    pub const fn new(start: usize, end: usize) -> Self {
19        Self { start, end }
20    }
21
22    /// Zero-length span at a single byte position.
23    pub const fn point(position: usize) -> Self {
24        Self::new(position, position)
25    }
26}
27
28/// Broad category of error origin within the QQL pipeline.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize))]
31pub enum ErrorKind {
32    /// Lexer-level error (invalid token, unexpected character).
33    Lex,
34    /// Parser-level error (syntax error, unexpected token).
35    Parse,
36    /// Semantic validation error (invalid configuration, type mismatch).
37    Validation,
38    /// Execution-layer error (embedding failure, invariant violation).
39    Execution,
40    /// Transport-layer error (HTTP/gRPC connectivity, timeout).
41    Transport,
42    /// Qdrant backend error (non-success response, malformed response).
43    Backend,
44}
45
46/// A key-value metadata field attached to a [`QqlError`] for structured context.
47///
48/// Use [`QqlError::with_field`] or the convenience builders
49/// ([`QqlError::with_collection`], [`QqlError::with_status`], etc.) to attach
50/// machine-readable context that clients can inspect without parsing the
51/// human-readable message string.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize))]
54pub struct ErrorField {
55    /// Field name, e.g. `collection` or `status_code`.
56    pub key: Cow<'static, str>,
57    /// Field value as a string.
58    pub value: Cow<'static, str>,
59}
60
61impl ErrorField {
62    /// Build an `ErrorField` from anything convertible into `Cow<'static, str>`.
63    pub fn new(key: impl Into<Cow<'static, str>>, value: impl Into<Cow<'static, str>>) -> Self {
64        Self {
65            key: key.into(),
66            value: value.into(),
67        }
68    }
69}
70
71/// Unified error type for the entire QQL pipeline.
72///
73/// Every error carries:
74/// - a broad [`ErrorKind`] category,
75/// - a machine-readable `code` (e.g. `QQL-EDGE-COLLECTION-NOT-FOUND`),
76/// - a human-readable `message`,
77/// - an optional source-code [`Span`],
78/// - optional structured [`ErrorField`]s for machine-readable context, and
79/// - an optional causal `source` error for chaining.
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize))]
82pub struct QqlError {
83    /// Broad origin category within the pipeline.
84    pub kind: ErrorKind,
85    /// Machine-readable error code, e.g. `QQL-PARSE-SYNTAX`.
86    pub code: Cow<'static, str>,
87    /// Human-readable description of the failure.
88    pub message: Cow<'static, str>,
89    /// Optional source span in the original query text.
90    pub span: Option<Span>,
91    /// Structured key-value metadata providing machine-readable context
92    /// (e.g. `collection`, `status_code`, `field_name`, `url`).
93    pub fields: Vec<ErrorField>,
94    /// Causal error that led to this one (error chaining).
95    #[cfg_attr(feature = "serde", serde(skip))]
96    pub source: Option<Box<QqlError>>,
97}
98
99impl QqlError {
100    /// Build an error with an explicit kind, code, message, and optional span.
101    pub fn new(
102        kind: ErrorKind,
103        code: impl Into<Cow<'static, str>>,
104        message: impl Into<Cow<'static, str>>,
105        span: Option<Span>,
106    ) -> Self {
107        Self {
108            kind,
109            code: code.into(),
110            message: message.into(),
111            span,
112            fields: Vec::new(),
113            source: None,
114        }
115    }
116
117    // ── Named constructors (keep existing API) ──────────────────────
118
119    /// Create an error with `ErrorKind::Lex` and the given source span.
120    pub fn lex(
121        code: impl Into<Cow<'static, str>>,
122        message: impl Into<Cow<'static, str>>,
123        span: Span,
124    ) -> Self {
125        Self::new(ErrorKind::Lex, code, message, Some(span))
126    }
127
128    /// Create an error with `ErrorKind::Parse` and the given source span.
129    pub fn parse(
130        code: impl Into<Cow<'static, str>>,
131        message: impl Into<Cow<'static, str>>,
132        span: Span,
133    ) -> Self {
134        Self::new(ErrorKind::Parse, code, message, Some(span))
135    }
136
137    /// Create an error with `ErrorKind::Validation` and an optional span.
138    pub fn validation(
139        code: impl Into<Cow<'static, str>>,
140        message: impl Into<Cow<'static, str>>,
141        span: Option<Span>,
142    ) -> Self {
143        Self::new(ErrorKind::Validation, code, message, span)
144    }
145
146    /// Create an error with `ErrorKind::Execution` and an optional span.
147    pub fn execution(
148        code: impl Into<Cow<'static, str>>,
149        message: impl Into<Cow<'static, str>>,
150        span: Option<Span>,
151    ) -> Self {
152        Self::new(ErrorKind::Execution, code, message, span)
153    }
154
155    /// Create an error with `ErrorKind::Transport` and an optional span.
156    pub fn transport(
157        code: impl Into<Cow<'static, str>>,
158        message: impl Into<Cow<'static, str>>,
159        span: Option<Span>,
160    ) -> Self {
161        Self::new(ErrorKind::Transport, code, message, span)
162    }
163
164    /// Create an error with `ErrorKind::Backend` and an optional span.
165    pub fn backend(
166        code: impl Into<Cow<'static, str>>,
167        message: impl Into<Cow<'static, str>>,
168        span: Option<Span>,
169    ) -> Self {
170        Self::new(ErrorKind::Backend, code, message, span)
171    }
172
173    pub(crate) fn syntax(message: impl Into<Cow<'static, str>>, position: usize) -> Self {
174        Self::parse("QQL-PARSE-SYNTAX", message, Span::point(position))
175    }
176
177    // ── Builder API ─────────────────────────────────────────────────
178
179    /// Attach a structured key-value metadata field.
180    ///
181    /// ```
182    /// # use qql_core::error::QqlError;
183    /// let err = QqlError::backend("QQL-BACKEND", "unexpected status", None)
184    ///     .with_field("status_code", "404")
185    ///     .with_field("collection", "my_collection");
186    /// assert_eq!(err.field("status_code"), Some("404"));
187    /// assert_eq!(err.field("collection"), Some("my_collection"));
188    /// ```
189    pub fn with_field(
190        mut self,
191        key: impl Into<Cow<'static, str>>,
192        value: impl Into<Cow<'static, str>>,
193    ) -> Self {
194        self.fields.push(ErrorField::new(key, value));
195        self
196    }
197
198    /// Shorthand for `.with_field("collection", name)`.
199    pub fn with_collection(self, name: impl Into<Cow<'static, str>>) -> Self {
200        self.with_field("collection", name)
201    }
202
203    /// Shorthand for `.with_field("status_code", code)`.
204    pub fn with_status(self, code: u16) -> Self {
205        self.with_field("status_code", alloc::format!("{code}"))
206    }
207
208    /// Shorthand for `.with_field("url", url)`.
209    pub fn with_url(self, url: impl Into<Cow<'static, str>>) -> Self {
210        self.with_field("url", url)
211    }
212
213    /// Shorthand for `.with_field("field_name", name)`.
214    pub fn with_field_name(self, name: impl Into<Cow<'static, str>>) -> Self {
215        self.with_field("field_name", name)
216    }
217
218    /// Shorthand for `.with_field("index_name", name)`.
219    pub fn with_index_name(self, name: impl Into<Cow<'static, str>>) -> Self {
220        self.with_field("index_name", name)
221    }
222
223    /// Shorthand for `.with_field("vector_name", name)`.
224    pub fn with_vector_name(self, name: impl Into<Cow<'static, str>>) -> Self {
225        self.with_field("vector_name", name)
226    }
227
228    /// Shorthand for `.with_field("model", name)`.
229    pub fn with_model(self, name: impl Into<Cow<'static, str>>) -> Self {
230        self.with_field("model", name)
231    }
232
233    /// Attach an optional source-code span.
234    pub fn with_span(mut self, span: Span) -> Self {
235        self.span = Some(span);
236        self
237    }
238
239    /// Chain a causal error.
240    ///
241    /// The `source` error will be displayed when the outer error is printed
242    /// and is accessible via [`std::error::Error::source`].
243    pub fn caused_by(mut self, source: QqlError) -> Self {
244        self.source = Some(Box::new(source));
245        self
246    }
247
248    /// Look up the first value for a given metadata key, case-insensitive.
249    pub fn field(&self, key: &str) -> Option<&str> {
250        self.fields
251            .iter()
252            .find(|f| f.key.eq_ignore_ascii_case(key))
253            .map(|f| f.value.as_ref())
254    }
255}
256
257impl fmt::Display for QqlError {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        write!(f, "[{}] {}", self.code, self.message)?;
260        if let Some(span) = self.span {
261            write!(f, " at {}..{}", span.start, span.end)?;
262        }
263        // Print structured fields when not empty
264        if !self.fields.is_empty() {
265            write!(f, " {{")?;
266            for (i, field) in self.fields.iter().enumerate() {
267                if i > 0 {
268                    write!(f, ", ")?;
269                }
270                write!(f, "{}: {}", field.key, field.value)?;
271            }
272            write!(f, "}}")?;
273        }
274        // Print causal chain
275        if let Some(ref source) = self.source {
276            write!(f, "\n  caused by: {source}")?;
277        }
278        Ok(())
279    }
280}
281
282impl core::error::Error for QqlError {
283    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
284        self.source
285            .as_ref()
286            .map(|s| s.as_ref() as &(dyn core::error::Error + 'static))
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_core_error_impl() {
296        let err = QqlError::syntax("unexpected token", 0);
297        let dyn_err: &dyn core::error::Error = &err;
298        assert!(dyn_err.source().is_none());
299        assert!(!dyn_err.to_string().is_empty());
300    }
301}