Skip to main content

fraiseql_error/
core_error.rs

1//! Core error types for FraiseQL operations.
2//!
3//! This module provides the primary error enum `FraiseQLError` used throughout
4//! the FraiseQL compilation and execution pipeline.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Result type alias for FraiseQL operations.
10pub type Result<T> = std::result::Result<T, FraiseQLError>;
11
12/// Main error type for FraiseQL operations.
13///
14/// All errors in the core library are converted to this type.
15/// Language bindings convert this to their native error types.
16///
17/// # Error Categories
18///
19/// Errors are organized by domain:
20///
21/// ## GraphQL Errors
22/// - `Parse` — Malformed GraphQL syntax
23/// - `Validation` — Schema validation failures
24/// - `UnknownField` — Field doesn't exist on type
25/// - `UnknownType` — Type doesn't exist in schema
26///
27/// ## Database Errors
28/// - `Database` — PostgreSQL/MySQL/SQLite errors (includes SQL state code)
29/// - `ConnectionPool` — Connection pool exhausted or unavailable
30/// - `Timeout` — Query exceeded configured timeout
31/// - `Cancelled` — Query was cancelled by caller
32///
33/// ## Authorization/Security Errors
34/// - `Authorization` — User lacks permission for operation
35/// - `Authentication` — Invalid/expired JWT token
36/// - `RateLimited` — Too many requests (includes retry-after)
37///
38/// ## Resource Errors
39/// - `NotFound` — Resource doesn't exist (404)
40/// - `Conflict` — Operation would violate constraints (409)
41///
42/// ## Configuration Errors
43/// - `Configuration` — Invalid setup/configuration
44/// - `Unsupported` — Operation not supported by current database backend
45///
46/// ## Internal Errors
47/// - `Internal` — Unexpected internal failures
48///
49/// # Stability
50///
51/// This enum is marked `#[non_exhaustive]` to allow adding new error variants
52/// in future minor versions without breaking backward compatibility.
53///
54/// External `match` expressions must include a wildcard `_` arm:
55///
56/// ```rust
57/// use fraiseql_error::FraiseQLError;
58///
59/// fn describe(e: &FraiseQLError) -> &'static str {
60///     match e {
61///         FraiseQLError::Parse { .. } => "parse error",
62///         FraiseQLError::Validation { .. } => "validation error",
63///         _ => "other error", // required: FraiseQLError is #[non_exhaustive]
64///     }
65/// }
66/// ```
67///
68/// The following would **not** compile, because the `#[non_exhaustive]`
69/// attribute forces downstream crates to handle the possibility of new
70/// variants — a wildcard arm is required even when every currently-defined
71/// variant is enumerated:
72///
73/// ```compile_fail
74/// use fraiseql_error::FraiseQLError;
75///
76/// fn describe(e: &FraiseQLError) -> &'static str {
77///     // Missing wildcard arm: rejected by rustc even though it lists
78///     // a few real variants — the `#[non_exhaustive]` attribute makes
79///     // the enum effectively open from any downstream crate's point
80///     // of view.
81///     match e {
82///         FraiseQLError::Parse { .. } => "parse",
83///         FraiseQLError::Validation { .. } => "validation",
84///         FraiseQLError::Database { .. } => "database",
85///     }
86/// }
87/// ```
88#[derive(Error, Debug)]
89#[non_exhaustive]
90pub enum FraiseQLError {
91    // ========================================================================
92    // GraphQL Errors
93    // ========================================================================
94    /// GraphQL parsing error.
95    #[error("Parse error at {location}: {message}")]
96    Parse {
97        /// Error message describing the parse failure.
98        message:  String,
99        /// Location in the query where the error occurred.
100        location: String,
101    },
102
103    /// GraphQL validation error.
104    #[error("Validation error: {message}")]
105    Validation {
106        /// Error message describing the validation failure.
107        message: String,
108        /// Path to the field with the error (e.g., "user.posts.0.title").
109        path:    Option<String>,
110    },
111
112    /// Unknown field error.
113    #[error("Unknown field '{field}' on type '{type_name}'")]
114    UnknownField {
115        /// The field name that was not found.
116        field:     String,
117        /// The type on which the field was queried.
118        type_name: String,
119    },
120
121    /// Unknown type error.
122    #[error("Unknown type '{type_name}'")]
123    UnknownType {
124        /// The type name that was not found.
125        type_name: String,
126    },
127
128    // ========================================================================
129    // Database Errors
130    // ========================================================================
131    /// Database operation error.
132    #[error("Database error: {message}")]
133    Database {
134        /// Error message from the database.
135        message:   String,
136        /// SQL state code if available (e.g., "23505" for unique violation).
137        sql_state: Option<String>,
138    },
139
140    /// Connection pool error.
141    #[error("Connection pool error: {message}")]
142    ConnectionPool {
143        /// Error message.
144        message: String,
145    },
146
147    /// Query timeout error.
148    #[error("Query timeout after {timeout_ms}ms")]
149    Timeout {
150        /// Timeout duration in milliseconds.
151        timeout_ms: u64,
152        /// The query that timed out (truncated if too long).
153        query:      Option<String>,
154    },
155
156    /// Query cancellation error.
157    #[error("Query cancelled: {reason}")]
158    Cancelled {
159        /// Query identifier for tracking/logging.
160        query_id: String,
161        /// Reason for cancellation.
162        reason:   String,
163    },
164
165    // ========================================================================
166    // Authorization Errors
167    // ========================================================================
168    /// Authorization error.
169    #[error("Authorization error: {message}")]
170    Authorization {
171        /// Error message.
172        message:  String,
173        /// The action that was denied.
174        action:   Option<String>,
175        /// The resource that was being accessed.
176        resource: Option<String>,
177    },
178
179    /// Authentication error.
180    #[error("Authentication error: {message}")]
181    Authentication {
182        /// Error message.
183        message: String,
184    },
185
186    /// Rate limiting error.
187    #[error("Rate limit exceeded: {message}")]
188    RateLimited {
189        /// Error message.
190        message:          String,
191        /// Number of seconds to wait before retrying.
192        retry_after_secs: u64,
193    },
194
195    /// Operation cost budget exceeded (#379).
196    ///
197    /// Distinct from [`RateLimited`](Self::RateLimited): a per-request cost
198    /// ceiling (`retry_after_secs: None`) is permanent for the operation —
199    /// retrying the same query cannot succeed — while an exhausted rolling
200    /// budget window (`Some`) is retryable once the window resets.
201    #[error("Operation cost exceeded: {message}")]
202    CostExceeded {
203        /// Error message.
204        message:          String,
205        /// Estimated cost of the rejected operation.
206        cost:             u64,
207        /// The budget the cost exceeded.
208        limit:            u64,
209        /// Seconds until the budget window resets; `None` for a per-request
210        /// ceiling.
211        retry_after_secs: Option<u64>,
212    },
213
214    /// The bytes a read would deliver exceed `[validation] max_response_bytes`.
215    ///
216    /// Distinct from [`CostExceeded`](Self::CostExceeded), which scores what a
217    /// request *asks for* before the database runs. This one measures what came
218    /// back. A materialised read costs what its documents weigh, and that is not
219    /// recoverable from the request: the same query against the same view returns
220    /// a kilobyte for one tenant and a gigabyte for another.
221    ///
222    /// Permanent for the request as issued — retrying it cannot succeed — so it is
223    /// a 4xx with no `retry_after`. The caller narrows `?select=` or lowers its
224    /// page size.
225    #[error(
226        "Response exceeds the maximum of {limit} bytes: {bytes} bytes delivered ([validation] \
227         max_response_bytes)"
228    )]
229    ResponseTooLarge {
230        /// Bytes measured when the ceiling was crossed. This is the running total
231        /// at the moment of refusal, not the full size of the untruncated
232        /// response — a stream is cut as soon as it goes over rather than being
233        /// read to the end to find out how far over it was.
234        bytes: u64,
235        /// The configured ceiling.
236        limit: u64,
237    },
238
239    // ========================================================================
240    // Resource Errors
241    // ========================================================================
242    /// Resource not found error.
243    #[error("{resource_type} not found: {identifier}")]
244    NotFound {
245        /// Type of resource (e.g., "User", "Post").
246        resource_type: String,
247        /// Identifier that was looked up.
248        identifier:    String,
249    },
250
251    /// Conflict error.
252    #[error("Conflict: {message}")]
253    Conflict {
254        /// Error message.
255        message: String,
256    },
257
258    // ========================================================================
259    // Configuration Errors
260    // ========================================================================
261    /// Configuration error.
262    #[error("Configuration error: {message}")]
263    Configuration {
264        /// Error message.
265        message: String,
266    },
267
268    /// Unsupported operation error.
269    #[error("Unsupported operation: {message}")]
270    Unsupported {
271        /// Error message describing what is not supported.
272        message: String,
273    },
274
275    /// The service is temporarily unavailable (e.g. tenant suspended).
276    ///
277    /// Maps to HTTP 503. `retry_after` is the number of seconds to wait, if known.
278    #[error("Service unavailable: {message}")]
279    ServiceUnavailable {
280        /// Human-readable reason for the unavailability.
281        message:     String,
282        /// Number of seconds to wait before retrying, if known.
283        retry_after: Option<u64>,
284    },
285
286    // ========================================================================
287    // Domain Subsystem Errors (composed via `From` impls in subsystem crates)
288    // ========================================================================
289    /// An authentication or authorisation error originating from the auth subsystem.
290    ///
291    /// The boxed source is the subsystem-specific error type (e.g.
292    /// `fraiseql_auth::AuthError`). To preserve subsystem vocabulary while
293    /// keeping `fraiseql-error` a leaf crate, the boxed payload is
294    /// type-erased here; subsystem crates provide their own
295    /// `impl From<SubsystemError> for FraiseQLError` (the sqlx pattern).
296    ///
297    /// `#[source]` is explicit: `thiserror` 2.x does not auto-detect a single
298    /// tuple field as the source, and downstream chain-walkers (`tracing`,
299    /// `miette`, `anyhow`) rely on `Error::source()` returning the underlying
300    /// subsystem error rather than `None`.
301    ///
302    /// # Pattern-matching on the inner error
303    ///
304    /// Because the payload is boxed and `dyn`-erased, downstream `match`
305    /// statements cannot bind on subsystem variants directly. Recover the
306    /// concrete type via [`std::error::Error::source`] + `downcast_ref`:
307    ///
308    /// ```ignore
309    /// use std::error::Error;
310    /// use fraiseql_error::FraiseQLError;
311    /// use fraiseql_auth::AuthError;
312    ///
313    /// if let FraiseQLError::Auth(_) = &err {
314    ///     if let Some(inner) = err.source().and_then(|s| s.downcast_ref::<AuthError>()) {
315    ///         match inner {
316    ///             AuthError::TokenExpired => {/* handle */},
317    ///             _ => {},
318    ///         }
319    ///     }
320    /// }
321    /// ```
322    #[error("Auth error: {0}")]
323    Auth(#[source] Box<dyn std::error::Error + Send + Sync>),
324
325    /// A webhook-processing error originating from the webhook subsystem.
326    ///
327    /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
328    /// that variant for the `downcast_ref` recovery pattern on the boxed
329    /// `fraiseql_webhooks::WebhookError`.
330    #[error("Webhook error: {0}")]
331    Webhook(#[source] Box<dyn std::error::Error + Send + Sync>),
332
333    /// An observer subsystem error (event dispatch, action execution, retry exhaustion).
334    ///
335    /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
336    /// that variant for the `downcast_ref` recovery pattern on the boxed
337    /// `fraiseql_observers::ObserverError`.
338    #[error("Observer error: {0}")]
339    Observer(#[source] Box<dyn std::error::Error + Send + Sync>),
340
341    /// A file-handling error (size limit, unsupported type, virus scan, quota).
342    ///
343    /// Unlike `Auth`, `Webhook`, and `Observer`, the file-domain vocabulary
344    /// lives inside `fraiseql-error` itself ([`crate::FileError`]) because no
345    /// subsystem crate owns it — file operations are spread across
346    /// `fraiseql-storage` and `fraiseql-server/storage`.
347    #[error("File error: {0}")]
348    File(#[from] crate::FileError),
349
350    // ========================================================================
351    // Internal Errors
352    // ========================================================================
353    /// Internal error.
354    #[error("Internal error: {message}")]
355    Internal {
356        /// Error message.
357        message: String,
358        /// Optional source error for debugging.
359        #[source]
360        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
361    },
362}
363
364impl FraiseQLError {
365    /// Create a parse error.
366    #[must_use]
367    pub fn parse(message: impl Into<String>) -> Self {
368        Self::Parse {
369            message:  message.into(),
370            location: "unknown".to_string(),
371        }
372    }
373
374    /// Create a parse error with location.
375    #[must_use]
376    pub fn parse_at(message: impl Into<String>, location: impl Into<String>) -> Self {
377        Self::Parse {
378            message:  message.into(),
379            location: location.into(),
380        }
381    }
382
383    /// Create a validation error.
384    #[must_use]
385    pub fn validation(message: impl Into<String>) -> Self {
386        Self::Validation {
387            message: message.into(),
388            path:    None,
389        }
390    }
391
392    /// Create a validation error with path.
393    #[must_use]
394    pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
395        Self::Validation {
396            message: message.into(),
397            path:    Some(path.into()),
398        }
399    }
400
401    /// Create a database error.
402    #[must_use]
403    pub fn database(message: impl Into<String>) -> Self {
404        Self::Database {
405            message:   message.into(),
406            sql_state: None,
407        }
408    }
409
410    /// Create an authorization error.
411    #[must_use]
412    pub fn unauthorized(message: impl Into<String>) -> Self {
413        Self::Authorization {
414            message:  message.into(),
415            action:   None,
416            resource: None,
417        }
418    }
419
420    /// Create a not found error.
421    #[must_use]
422    pub fn not_found(resource_type: impl Into<String>, identifier: impl Into<String>) -> Self {
423        Self::NotFound {
424            resource_type: resource_type.into(),
425            identifier:    identifier.into(),
426        }
427    }
428
429    /// Create a configuration error.
430    #[must_use]
431    pub fn config(message: impl Into<String>) -> Self {
432        Self::Configuration {
433            message: message.into(),
434        }
435    }
436
437    /// Create an internal error.
438    #[must_use]
439    pub fn internal(message: impl Into<String>) -> Self {
440        Self::Internal {
441            message: message.into(),
442            source:  None,
443        }
444    }
445
446    /// Create a cancellation error.
447    #[must_use]
448    pub fn cancelled(query_id: impl Into<String>, reason: impl Into<String>) -> Self {
449        Self::Cancelled {
450            query_id: query_id.into(),
451            reason:   reason.into(),
452        }
453    }
454
455    /// Check if this is a client error (4xx equivalent).
456    ///
457    /// Derived from [`Self::status_code`] so the answer stays consistent
458    /// with the variant's actual HTTP routing (notably for `File(_)`, which
459    /// straddles 4xx and 5xx after F050).
460    #[must_use]
461    pub const fn is_client_error(&self) -> bool {
462        let code = self.status_code();
463        code >= 400 && code < 500
464    }
465
466    /// Check if this is a server error (5xx equivalent).
467    ///
468    /// Derived from [`Self::status_code`] for the same consistency reason
469    /// as [`Self::is_client_error`].
470    #[must_use]
471    pub const fn is_server_error(&self) -> bool {
472        let code = self.status_code();
473        code >= 500 && code < 600
474    }
475
476    /// Check if this error is retryable.
477    #[must_use]
478    pub const fn is_retryable(&self) -> bool {
479        matches!(
480            self,
481            Self::ConnectionPool { .. }
482                | Self::Timeout { .. }
483                | Self::Cancelled { .. }
484                | Self::ServiceUnavailable { .. }
485        )
486    }
487
488    /// Get HTTP status code equivalent.
489    ///
490    /// # Future variants
491    ///
492    /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_ => 500` arm
493    /// is a deliberate safety net so a new variant added without updating
494    /// this match still gets a defined (and *safe*) HTTP status — generic
495    /// 500 Internal Server Error — rather than leaking implementation
496    /// details to the client by returning the wrong code.
497    // Reason: the trailing wildcard arm intentionally duplicates the 500
498    // server-error arm above (silencing `match_same_arms`), and is currently
499    // unreachable within this crate because the match enumerates every
500    // existing variant (silencing `unreachable_patterns`). The duplication is
501    // the security guarantee: when a future variant is added to this
502    // `#[non_exhaustive]` enum, the wildcard becomes reachable and prevents a
503    // wrong-status leak. See docs/history/IMPROVEMENTS.md F055.
504    // Reason: deliberate duplicate arms — non_exhaustive forward-compat guard (F055), see above
505    #[allow(clippy::match_same_arms, unreachable_patterns)]
506    #[must_use]
507    pub const fn status_code(&self) -> u16 {
508        match self {
509            Self::Parse { .. }
510            | Self::Validation { .. }
511            | Self::UnknownField { .. }
512            | Self::UnknownType { .. }
513            | Self::Webhook(_) => 400,
514            // File is per-variant: validation failures stay 400, backend
515            // failures escalate to 5xx, NotFound → 404, PermissionDenied →
516            // 403 (matches the legacy `storage_error_response` routing of
517            // `FraiseQLError::Storage` which F050 replaces).
518            Self::File(e) => e.status_code(),
519            Self::Authentication { .. } | Self::Auth(_) => 401,
520            Self::Authorization { .. } => 403,
521            Self::NotFound { .. } => 404,
522            Self::Conflict { .. } => 409,
523            Self::RateLimited { .. } => 429,
524            // #1201: a cost ceiling is the caller asking for too much, never a
525            // server fault. Which 4xx depends on whether asking again can ever
526            // work — the distinction this variant's own doc draws: an exhausted
527            // rolling window resets (429, retryable), a per-request ceiling does
528            // not (400, permanent). Before this it matched no arm and fell to the
529            // `_ => 500` default, so every over-budget request was reported as a
530            // server error and every retry policy treated it as retryable.
531            Self::CostExceeded {
532                retry_after_secs: Some(_),
533                ..
534            } => 429,
535            Self::CostExceeded {
536                retry_after_secs: None,
537                ..
538            } => 400,
539            // The response the caller asked for is larger than the operator
540            // allows. 413 rather than 400 to match how this codebase already
541            // spells "what you asked for is too big": `sse_max_replay_events`
542            // answers `413 RESUME_TOO_FAR_BEHIND` for the same shape of refusal.
543            Self::ResponseTooLarge { .. } => 413,
544            Self::Timeout { .. } | Self::Cancelled { .. } => 408,
545            Self::Database { .. }
546            | Self::ConnectionPool { .. }
547            | Self::Configuration { .. }
548            | Self::Internal { .. }
549            | Self::Observer(_) => 500,
550            Self::Unsupported { .. } => 501,
551            Self::ServiceUnavailable { .. } => 503,
552            // SECURITY: any future variant defaults to 500 Internal Server
553            // Error until explicitly mapped, so we never accidentally return
554            // an inappropriate status (e.g. 200, 401) to clients.
555            _ => 500,
556        }
557    }
558
559    /// Get error code for GraphQL response.
560    ///
561    /// # Future variants
562    ///
563    /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_` arm returns
564    /// `"INTERNAL_SERVER_ERROR"` so a new variant added without updating
565    /// this match still receives a stable (if generic) error code.
566    // Reason: see `status_code` — same security-defence rationale; same two
567    // lints (`match_same_arms` for the duplicated arm body, `unreachable_patterns`
568    // because the in-crate match currently covers every variant).
569    #[allow(clippy::match_same_arms, unreachable_patterns)]
570    #[must_use]
571    pub const fn error_code(&self) -> &'static str {
572        match self {
573            Self::Parse { .. } => "GRAPHQL_PARSE_FAILED",
574            Self::Validation { .. } => "GRAPHQL_VALIDATION_FAILED",
575            Self::UnknownField { .. } => "UNKNOWN_FIELD",
576            Self::UnknownType { .. } => "UNKNOWN_TYPE",
577            Self::Database { .. } => "DATABASE_ERROR",
578            Self::ConnectionPool { .. } => "CONNECTION_POOL_ERROR",
579            Self::Timeout { .. } => "TIMEOUT",
580            Self::Cancelled { .. } => "CANCELLED",
581            Self::Authorization { .. } => "FORBIDDEN",
582            Self::Authentication { .. } => "UNAUTHENTICATED",
583            Self::Auth(_) => "AUTH_ERROR",
584            Self::Webhook(_) => "WEBHOOK_ERROR",
585            Self::Observer(_) => "OBSERVER_ERROR",
586            Self::File(_) => "FILE_ERROR",
587            Self::RateLimited { .. } => "RATE_LIMITED",
588            Self::NotFound { .. } => "NOT_FOUND",
589            Self::Conflict { .. } => "CONFLICT",
590            Self::Configuration { .. } => "CONFIGURATION_ERROR",
591            Self::Unsupported { .. } => "UNSUPPORTED_OPERATION",
592            Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
593            Self::Internal { .. } => "INTERNAL_SERVER_ERROR",
594            // SECURITY: see `status_code` — fallback to the safe generic
595            // category until the new variant is explicitly classified.
596            _ => "INTERNAL_SERVER_ERROR",
597        }
598    }
599
600    /// Create an unknown field error with helpful suggestions.
601    #[must_use]
602    pub fn unknown_field_with_suggestion(
603        field: impl Into<String>,
604        type_name: impl Into<String>,
605        available_fields: &[&str],
606    ) -> Self {
607        let field = field.into();
608        let type_name = type_name.into();
609
610        let suggestion = available_fields
611            .iter()
612            .map(|f| (*f, Self::levenshtein_distance(&field, f)))
613            .filter(|(_, distance)| *distance <= 2)
614            .min_by_key(|(_, distance)| *distance)
615            .map(|(f, _)| f);
616
617        if let Some(suggested_field) = suggestion {
618            Self::UnknownField {
619                field: format!("{field} (did you mean '{suggested_field}'?)"),
620                type_name,
621            }
622        } else {
623            Self::UnknownField { field, type_name }
624        }
625    }
626
627    fn levenshtein_distance(s1: &str, s2: &str) -> usize {
628        let chars2: Vec<char> = s2.chars().collect();
629        let len2 = chars2.len();
630
631        // Two-row rolling buffer eliminates the nested `Vec<Vec<_>>` indexing.
632        // `prev[j]` = distance(s1[..i],   s2[..j])
633        // `curr[j]` = distance(s1[..i+1], s2[..j])
634        // Both rows have `len2 + 1` entries; iteration is bounded by their
635        // length, so every access uses `.get()?` with an `unreachable_or_zero`
636        // saturating fallback (provably unreachable in this control flow).
637        let mut prev: Vec<usize> = (0..=len2).collect();
638        let mut curr: Vec<usize> = vec![0; len2 + 1];
639
640        for (i, c1) in s1.chars().enumerate() {
641            // Initialise the leftmost column for this row.
642            // Reason: `curr` is sized `len2 + 1 >= 1`, so index 0 is always valid.
643            if let Some(slot) = curr.get_mut(0) {
644                *slot = i + 1;
645            }
646
647            for (j, &c2) in chars2.iter().enumerate() {
648                let cost = usize::from(c1 != c2);
649                // All four lookups read positions in `[0, len2]`, which are
650                // valid by construction (`prev`/`curr` both have len `len2+1`,
651                // and `j` ranges over `0..len2`). The `.get()` + `unwrap_or(0)`
652                // pattern keeps the function panic-free without changing the
653                // computed result.
654                let deletion = prev.get(j + 1).copied().unwrap_or(0).saturating_add(1);
655                let insertion = curr.get(j).copied().unwrap_or(0).saturating_add(1);
656                let substitution = prev.get(j).copied().unwrap_or(0).saturating_add(cost);
657                let value = deletion.min(insertion).min(substitution);
658                if let Some(slot) = curr.get_mut(j + 1) {
659                    *slot = value;
660                }
661            }
662
663            std::mem::swap(&mut prev, &mut curr);
664        }
665
666        // Final answer sits at `prev[len2]` after the last swap.
667        // Reason: `prev` is always sized `len2 + 1`, so this index is valid.
668        prev.get(len2).copied().unwrap_or(0)
669    }
670
671    /// Create a database error from PostgreSQL error code.
672    #[must_use]
673    pub fn from_postgres_code(code: &str, message: impl Into<String>) -> Self {
674        let message = message.into();
675        match code {
676            "42P01" => Self::Database {
677                message: "The table or view you're querying doesn't exist. \
678                          Check that the schema is compiled and the database is initialized."
679                    .to_string(),
680                sql_state: Some(code.to_string()),
681            },
682            "42703" => Self::Database {
683                message: "A column referenced in the query doesn't exist in the table. \
684                          This may indicate the database schema is out of sync with the compiled schema."
685                    .to_string(),
686                sql_state: Some(code.to_string()),
687            },
688            "23505" => Self::Conflict {
689                message: "A unique constraint was violated. This value already exists in the database.".to_string(),
690            },
691            "23503" => Self::Conflict {
692                message: "A foreign key constraint was violated. The referenced record doesn't exist."
693                    .to_string(),
694            },
695            "23502" => Self::Conflict {
696                message: "A NOT NULL constraint was violated. The field cannot be empty.".to_string(),
697            },
698            "22P02" => Self::Validation {
699                message: "Invalid input value. The provided value doesn't match the expected data type.".to_string(),
700                path: None,
701            },
702            _ => Self::Database {
703                message,
704                sql_state: Some(code.to_string()),
705            },
706        }
707    }
708
709    /// Create a rate limit error with retry information.
710    #[must_use]
711    pub fn rate_limited_with_retry(retry_after_secs: u64) -> Self {
712        Self::RateLimited {
713            message: format!(
714                "Rate limit exceeded. Please try again in {retry_after_secs} seconds. \
715                 For permanent increases, contact support."
716            ),
717            retry_after_secs,
718        }
719    }
720
721    /// Create an authentication error with context.
722    #[must_use]
723    pub fn auth_error(reason: impl Into<String>) -> Self {
724        Self::Authentication {
725            message: reason.into(),
726        }
727    }
728}
729
730impl From<serde_json::Error> for FraiseQLError {
731    fn from(e: serde_json::Error) -> Self {
732        Self::Parse {
733            message:  e.to_string(),
734            location: format!("line {}, column {}", e.line(), e.column()),
735        }
736    }
737}
738
739impl From<std::io::Error> for FraiseQLError {
740    fn from(e: std::io::Error) -> Self {
741        Self::Internal {
742            message: format!("I/O error: {e}"),
743            source:  Some(Box::new(e)),
744        }
745    }
746}
747
748impl From<std::env::VarError> for FraiseQLError {
749    fn from(e: std::env::VarError) -> Self {
750        Self::Configuration {
751            message: format!("Environment variable error: {e}"),
752        }
753    }
754}
755
756/// Extension trait for adding context to errors.
757pub trait ErrorContext<T> {
758    /// Add context to an error.
759    ///
760    /// # Errors
761    ///
762    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
763    /// given message.
764    fn context(self, message: impl Into<String>) -> Result<T>;
765
766    /// Add context lazily (only computed on error).
767    ///
768    /// # Errors
769    ///
770    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
771    /// context message.
772    fn with_context<F, M>(self, f: F) -> Result<T>
773    where
774        F: FnOnce() -> M,
775        M: Into<String>;
776}
777
778impl<T, E: Into<FraiseQLError>> ErrorContext<T> for std::result::Result<T, E> {
779    fn context(self, message: impl Into<String>) -> Result<T> {
780        self.map_err(|e| {
781            let inner = e.into();
782            FraiseQLError::Internal {
783                message: format!("{}: {inner}", message.into()),
784                source:  None,
785            }
786        })
787    }
788
789    fn with_context<F, M>(self, f: F) -> Result<T>
790    where
791        F: FnOnce() -> M,
792        M: Into<String>,
793    {
794        self.map_err(|e| {
795            let inner = e.into();
796            FraiseQLError::Internal {
797                message: format!("{}: {inner}", f().into()),
798                source:  None,
799            }
800        })
801    }
802}
803
804/// A validation error for a specific field in an input object.
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct ValidationFieldError {
807    /// Path to the field that failed validation.
808    pub field:     String,
809    /// Type of validation rule that failed.
810    pub rule_type: String,
811    /// Human-readable error message.
812    pub message:   String,
813}
814
815impl ValidationFieldError {
816    /// Create a new validation field error.
817    #[must_use]
818    pub fn new(
819        field: impl Into<String>,
820        rule_type: impl Into<String>,
821        message: impl Into<String>,
822    ) -> Self {
823        Self {
824            field:     field.into(),
825            rule_type: rule_type.into(),
826            message:   message.into(),
827        }
828    }
829}
830
831impl std::fmt::Display for ValidationFieldError {
832    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833        write!(f, "{} ({}): {}", self.field, self.rule_type, self.message)
834    }
835}
836
837#[cfg(test)]
838mod tests;