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, serde::Deserialize))]
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    // ── Builder API ─────────────────────────────────────────────────
174
175    /// Attach a structured key-value metadata field.
176    ///
177    /// ```
178    /// # use qql_core::error::QqlError;
179    /// let err = QqlError::backend("QQL-BACKEND", "unexpected status", None)
180    ///     .with_field("status_code", "404")
181    ///     .with_field("collection", "my_collection");
182    /// assert_eq!(err.field("status_code"), Some("404"));
183    /// assert_eq!(err.field("collection"), Some("my_collection"));
184    /// ```
185    pub fn with_field(
186        mut self,
187        key: impl Into<Cow<'static, str>>,
188        value: impl Into<Cow<'static, str>>,
189    ) -> Self {
190        self.fields.push(ErrorField::new(key, value));
191        self
192    }
193
194    /// Shorthand for `.with_field("collection", name)`.
195    pub fn with_collection(self, name: impl Into<Cow<'static, str>>) -> Self {
196        self.with_field("collection", name)
197    }
198
199    /// Shorthand for `.with_field("status_code", code)`.
200    pub fn with_status(self, code: u16) -> Self {
201        self.with_field("status_code", alloc::format!("{code}"))
202    }
203
204    /// Shorthand for `.with_field("url", url)`.
205    pub fn with_url(self, url: impl Into<Cow<'static, str>>) -> Self {
206        self.with_field("url", url)
207    }
208
209    /// Shorthand for `.with_field("field_name", name)`.
210    pub fn with_field_name(self, name: impl Into<Cow<'static, str>>) -> Self {
211        self.with_field("field_name", name)
212    }
213
214    /// Shorthand for `.with_field("index_name", name)`.
215    pub fn with_index_name(self, name: impl Into<Cow<'static, str>>) -> Self {
216        self.with_field("index_name", name)
217    }
218
219    /// Shorthand for `.with_field("vector_name", name)`.
220    pub fn with_vector_name(self, name: impl Into<Cow<'static, str>>) -> Self {
221        self.with_field("vector_name", name)
222    }
223
224    /// Shorthand for `.with_field("model", name)`.
225    pub fn with_model(self, name: impl Into<Cow<'static, str>>) -> Self {
226        self.with_field("model", name)
227    }
228
229    /// Attach an optional source-code span.
230    pub fn with_span(mut self, span: Span) -> Self {
231        self.span = Some(span);
232        self
233    }
234
235    /// Chain a causal error.
236    ///
237    /// The `source` error will be displayed when the outer error is printed
238    /// and is accessible via [`std::error::Error::source`].
239    pub fn caused_by(mut self, source: QqlError) -> Self {
240        self.source = Some(Box::new(source));
241        self
242    }
243
244    /// Look up the first value for a given metadata key, case-insensitive.
245    pub fn field(&self, key: &str) -> Option<&str> {
246        self.fields
247            .iter()
248            .find(|f| f.key.eq_ignore_ascii_case(key))
249            .map(|f| f.value.as_ref())
250    }
251}
252
253impl fmt::Display for QqlError {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        write!(f, "[{}] {}", self.code, self.message)?;
256        if let Some(span) = self.span {
257            write!(f, " at {}..{}", span.start, span.end)?;
258        }
259        // Print structured fields when not empty
260        if !self.fields.is_empty() {
261            write!(f, " {{")?;
262            for (i, field) in self.fields.iter().enumerate() {
263                if i > 0 {
264                    write!(f, ", ")?;
265                }
266                write!(f, "{}: {}", field.key, field.value)?;
267            }
268            write!(f, "}}")?;
269        }
270        // Print causal chain
271        if let Some(ref source) = self.source {
272            write!(f, "\n  caused by: {source}")?;
273        }
274        Ok(())
275    }
276}
277
278impl core::error::Error for QqlError {
279    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
280        self.source
281            .as_ref()
282            .map(|s| s.as_ref() as &(dyn core::error::Error + 'static))
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_core_error_impl() {
292        let err = QqlError::parse("QQL-PARSE-SYNTAX", "unexpected token", Span::point(0));
293        let dyn_err: &dyn core::error::Error = &err;
294        assert!(dyn_err.source().is_none());
295        assert!(!dyn_err.to_string().is_empty());
296    }
297}