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