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    // ========================================================================
196    // Resource Errors
197    // ========================================================================
198    /// Resource not found error.
199    #[error("{resource_type} not found: {identifier}")]
200    NotFound {
201        /// Type of resource (e.g., "User", "Post").
202        resource_type: String,
203        /// Identifier that was looked up.
204        identifier:    String,
205    },
206
207    /// Conflict error.
208    #[error("Conflict: {message}")]
209    Conflict {
210        /// Error message.
211        message: String,
212    },
213
214    // ========================================================================
215    // Configuration Errors
216    // ========================================================================
217    /// Configuration error.
218    #[error("Configuration error: {message}")]
219    Configuration {
220        /// Error message.
221        message: String,
222    },
223
224    /// Unsupported operation error.
225    #[error("Unsupported operation: {message}")]
226    Unsupported {
227        /// Error message describing what is not supported.
228        message: String,
229    },
230
231    /// The service is temporarily unavailable (e.g. tenant suspended).
232    ///
233    /// Maps to HTTP 503. `retry_after` is the number of seconds to wait, if known.
234    #[error("Service unavailable: {message}")]
235    ServiceUnavailable {
236        /// Human-readable reason for the unavailability.
237        message:     String,
238        /// Number of seconds to wait before retrying, if known.
239        retry_after: Option<u64>,
240    },
241
242    // ========================================================================
243    // Domain Subsystem Errors (composed via `From` impls in subsystem crates)
244    // ========================================================================
245    /// An authentication or authorisation error originating from the auth subsystem.
246    ///
247    /// The boxed source is the subsystem-specific error type (e.g.
248    /// `fraiseql_auth::AuthError`). To preserve subsystem vocabulary while
249    /// keeping `fraiseql-error` a leaf crate, the boxed payload is
250    /// type-erased here; subsystem crates provide their own
251    /// `impl From<SubsystemError> for FraiseQLError` (the sqlx pattern).
252    ///
253    /// `#[source]` is explicit: `thiserror` 2.x does not auto-detect a single
254    /// tuple field as the source, and downstream chain-walkers (`tracing`,
255    /// `miette`, `anyhow`) rely on `Error::source()` returning the underlying
256    /// subsystem error rather than `None`.
257    ///
258    /// # Pattern-matching on the inner error
259    ///
260    /// Because the payload is boxed and `dyn`-erased, downstream `match`
261    /// statements cannot bind on subsystem variants directly. Recover the
262    /// concrete type via [`std::error::Error::source`] + `downcast_ref`:
263    ///
264    /// ```ignore
265    /// use std::error::Error;
266    /// use fraiseql_error::FraiseQLError;
267    /// use fraiseql_auth::AuthError;
268    ///
269    /// if let FraiseQLError::Auth(_) = &err {
270    ///     if let Some(inner) = err.source().and_then(|s| s.downcast_ref::<AuthError>()) {
271    ///         match inner {
272    ///             AuthError::TokenExpired => {/* handle */},
273    ///             _ => {},
274    ///         }
275    ///     }
276    /// }
277    /// ```
278    #[error("Auth error: {0}")]
279    Auth(#[source] Box<dyn std::error::Error + Send + Sync>),
280
281    /// A webhook-processing error originating from the webhook subsystem.
282    ///
283    /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
284    /// that variant for the `downcast_ref` recovery pattern on the boxed
285    /// `fraiseql_webhooks::WebhookError`.
286    #[error("Webhook error: {0}")]
287    Webhook(#[source] Box<dyn std::error::Error + Send + Sync>),
288
289    /// An observer subsystem error (event dispatch, action execution, retry exhaustion).
290    ///
291    /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
292    /// that variant for the `downcast_ref` recovery pattern on the boxed
293    /// `fraiseql_observers::ObserverError`.
294    #[error("Observer error: {0}")]
295    Observer(#[source] Box<dyn std::error::Error + Send + Sync>),
296
297    /// A file-handling error (size limit, unsupported type, virus scan, quota).
298    ///
299    /// Unlike `Auth`, `Webhook`, and `Observer`, the file-domain vocabulary
300    /// lives inside `fraiseql-error` itself ([`crate::FileError`]) because no
301    /// subsystem crate owns it — file operations are spread across
302    /// `fraiseql-storage` and `fraiseql-server/storage`.
303    #[error("File error: {0}")]
304    File(#[from] crate::FileError),
305
306    // ========================================================================
307    // Internal Errors
308    // ========================================================================
309    /// Internal error.
310    #[error("Internal error: {message}")]
311    Internal {
312        /// Error message.
313        message: String,
314        /// Optional source error for debugging.
315        #[source]
316        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
317    },
318}
319
320impl FraiseQLError {
321    /// Create a parse error.
322    #[must_use]
323    pub fn parse(message: impl Into<String>) -> Self {
324        Self::Parse {
325            message:  message.into(),
326            location: "unknown".to_string(),
327        }
328    }
329
330    /// Create a parse error with location.
331    #[must_use]
332    pub fn parse_at(message: impl Into<String>, location: impl Into<String>) -> Self {
333        Self::Parse {
334            message:  message.into(),
335            location: location.into(),
336        }
337    }
338
339    /// Create a validation error.
340    #[must_use]
341    pub fn validation(message: impl Into<String>) -> Self {
342        Self::Validation {
343            message: message.into(),
344            path:    None,
345        }
346    }
347
348    /// Create a validation error with path.
349    #[must_use]
350    pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
351        Self::Validation {
352            message: message.into(),
353            path:    Some(path.into()),
354        }
355    }
356
357    /// Create a database error.
358    #[must_use]
359    pub fn database(message: impl Into<String>) -> Self {
360        Self::Database {
361            message:   message.into(),
362            sql_state: None,
363        }
364    }
365
366    /// Create an authorization error.
367    #[must_use]
368    pub fn unauthorized(message: impl Into<String>) -> Self {
369        Self::Authorization {
370            message:  message.into(),
371            action:   None,
372            resource: None,
373        }
374    }
375
376    /// Create a not found error.
377    #[must_use]
378    pub fn not_found(resource_type: impl Into<String>, identifier: impl Into<String>) -> Self {
379        Self::NotFound {
380            resource_type: resource_type.into(),
381            identifier:    identifier.into(),
382        }
383    }
384
385    /// Create a configuration error.
386    #[must_use]
387    pub fn config(message: impl Into<String>) -> Self {
388        Self::Configuration {
389            message: message.into(),
390        }
391    }
392
393    /// Create an internal error.
394    #[must_use]
395    pub fn internal(message: impl Into<String>) -> Self {
396        Self::Internal {
397            message: message.into(),
398            source:  None,
399        }
400    }
401
402    /// Create a cancellation error.
403    #[must_use]
404    pub fn cancelled(query_id: impl Into<String>, reason: impl Into<String>) -> Self {
405        Self::Cancelled {
406            query_id: query_id.into(),
407            reason:   reason.into(),
408        }
409    }
410
411    /// Check if this is a client error (4xx equivalent).
412    ///
413    /// Derived from [`Self::status_code`] so the answer stays consistent
414    /// with the variant's actual HTTP routing (notably for `File(_)`, which
415    /// straddles 4xx and 5xx after F050).
416    #[must_use]
417    pub const fn is_client_error(&self) -> bool {
418        let code = self.status_code();
419        code >= 400 && code < 500
420    }
421
422    /// Check if this is a server error (5xx equivalent).
423    ///
424    /// Derived from [`Self::status_code`] for the same consistency reason
425    /// as [`Self::is_client_error`].
426    #[must_use]
427    pub const fn is_server_error(&self) -> bool {
428        let code = self.status_code();
429        code >= 500 && code < 600
430    }
431
432    /// Check if this error is retryable.
433    #[must_use]
434    pub const fn is_retryable(&self) -> bool {
435        matches!(
436            self,
437            Self::ConnectionPool { .. }
438                | Self::Timeout { .. }
439                | Self::Cancelled { .. }
440                | Self::ServiceUnavailable { .. }
441        )
442    }
443
444    /// Get HTTP status code equivalent.
445    ///
446    /// # Future variants
447    ///
448    /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_ => 500` arm
449    /// is a deliberate safety net so a new variant added without updating
450    /// this match still gets a defined (and *safe*) HTTP status — generic
451    /// 500 Internal Server Error — rather than leaking implementation
452    /// details to the client by returning the wrong code.
453    // Reason: the trailing wildcard arm intentionally duplicates the 500
454    // server-error arm above (silencing `match_same_arms`), and is currently
455    // unreachable within this crate because the match enumerates every
456    // existing variant (silencing `unreachable_patterns`). The duplication is
457    // the security guarantee: when a future variant is added to this
458    // `#[non_exhaustive]` enum, the wildcard becomes reachable and prevents a
459    // wrong-status leak. See IMPROVEMENTS.md F055.
460    #[allow(clippy::match_same_arms, unreachable_patterns)]
461    #[must_use]
462    pub const fn status_code(&self) -> u16 {
463        match self {
464            Self::Parse { .. }
465            | Self::Validation { .. }
466            | Self::UnknownField { .. }
467            | Self::UnknownType { .. }
468            | Self::Webhook(_) => 400,
469            // File is per-variant: validation failures stay 400, backend
470            // failures escalate to 5xx, NotFound → 404, PermissionDenied →
471            // 403 (matches the legacy `storage_error_response` routing of
472            // `FraiseQLError::Storage` which F050 replaces).
473            Self::File(e) => e.status_code(),
474            Self::Authentication { .. } | Self::Auth(_) => 401,
475            Self::Authorization { .. } => 403,
476            Self::NotFound { .. } => 404,
477            Self::Conflict { .. } => 409,
478            Self::RateLimited { .. } => 429,
479            Self::Timeout { .. } | Self::Cancelled { .. } => 408,
480            Self::Database { .. }
481            | Self::ConnectionPool { .. }
482            | Self::Configuration { .. }
483            | Self::Internal { .. }
484            | Self::Observer(_) => 500,
485            Self::Unsupported { .. } => 501,
486            Self::ServiceUnavailable { .. } => 503,
487            // SECURITY: any future variant defaults to 500 Internal Server
488            // Error until explicitly mapped, so we never accidentally return
489            // an inappropriate status (e.g. 200, 401) to clients.
490            _ => 500,
491        }
492    }
493
494    /// Get error code for GraphQL response.
495    ///
496    /// # Future variants
497    ///
498    /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_` arm returns
499    /// `"INTERNAL_SERVER_ERROR"` so a new variant added without updating
500    /// this match still receives a stable (if generic) error code.
501    // Reason: see `status_code` — same security-defence rationale; same two
502    // lints (`match_same_arms` for the duplicated arm body, `unreachable_patterns`
503    // because the in-crate match currently covers every variant).
504    #[allow(clippy::match_same_arms, unreachable_patterns)]
505    #[must_use]
506    pub const fn error_code(&self) -> &'static str {
507        match self {
508            Self::Parse { .. } => "GRAPHQL_PARSE_FAILED",
509            Self::Validation { .. } => "GRAPHQL_VALIDATION_FAILED",
510            Self::UnknownField { .. } => "UNKNOWN_FIELD",
511            Self::UnknownType { .. } => "UNKNOWN_TYPE",
512            Self::Database { .. } => "DATABASE_ERROR",
513            Self::ConnectionPool { .. } => "CONNECTION_POOL_ERROR",
514            Self::Timeout { .. } => "TIMEOUT",
515            Self::Cancelled { .. } => "CANCELLED",
516            Self::Authorization { .. } => "FORBIDDEN",
517            Self::Authentication { .. } => "UNAUTHENTICATED",
518            Self::Auth(_) => "AUTH_ERROR",
519            Self::Webhook(_) => "WEBHOOK_ERROR",
520            Self::Observer(_) => "OBSERVER_ERROR",
521            Self::File(_) => "FILE_ERROR",
522            Self::RateLimited { .. } => "RATE_LIMITED",
523            Self::NotFound { .. } => "NOT_FOUND",
524            Self::Conflict { .. } => "CONFLICT",
525            Self::Configuration { .. } => "CONFIGURATION_ERROR",
526            Self::Unsupported { .. } => "UNSUPPORTED_OPERATION",
527            Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
528            Self::Internal { .. } => "INTERNAL_SERVER_ERROR",
529            // SECURITY: see `status_code` — fallback to the safe generic
530            // category until the new variant is explicitly classified.
531            _ => "INTERNAL_SERVER_ERROR",
532        }
533    }
534
535    /// Create an unknown field error with helpful suggestions.
536    #[must_use]
537    pub fn unknown_field_with_suggestion(
538        field: impl Into<String>,
539        type_name: impl Into<String>,
540        available_fields: &[&str],
541    ) -> Self {
542        let field = field.into();
543        let type_name = type_name.into();
544
545        let suggestion = available_fields
546            .iter()
547            .map(|f| (*f, Self::levenshtein_distance(&field, f)))
548            .filter(|(_, distance)| *distance <= 2)
549            .min_by_key(|(_, distance)| *distance)
550            .map(|(f, _)| f);
551
552        if let Some(suggested_field) = suggestion {
553            Self::UnknownField {
554                field: format!("{field} (did you mean '{suggested_field}'?)"),
555                type_name,
556            }
557        } else {
558            Self::UnknownField { field, type_name }
559        }
560    }
561
562    fn levenshtein_distance(s1: &str, s2: &str) -> usize {
563        let chars2: Vec<char> = s2.chars().collect();
564        let len2 = chars2.len();
565
566        // Two-row rolling buffer eliminates the nested `Vec<Vec<_>>` indexing.
567        // `prev[j]` = distance(s1[..i],   s2[..j])
568        // `curr[j]` = distance(s1[..i+1], s2[..j])
569        // Both rows have `len2 + 1` entries; iteration is bounded by their
570        // length, so every access uses `.get()?` with an `unreachable_or_zero`
571        // saturating fallback (provably unreachable in this control flow).
572        let mut prev: Vec<usize> = (0..=len2).collect();
573        let mut curr: Vec<usize> = vec![0; len2 + 1];
574
575        for (i, c1) in s1.chars().enumerate() {
576            // Initialise the leftmost column for this row.
577            // Reason: `curr` is sized `len2 + 1 >= 1`, so index 0 is always valid.
578            if let Some(slot) = curr.get_mut(0) {
579                *slot = i + 1;
580            }
581
582            for (j, &c2) in chars2.iter().enumerate() {
583                let cost = usize::from(c1 != c2);
584                // All four lookups read positions in `[0, len2]`, which are
585                // valid by construction (`prev`/`curr` both have len `len2+1`,
586                // and `j` ranges over `0..len2`). The `.get()` + `unwrap_or(0)`
587                // pattern keeps the function panic-free without changing the
588                // computed result.
589                let deletion = prev.get(j + 1).copied().unwrap_or(0).saturating_add(1);
590                let insertion = curr.get(j).copied().unwrap_or(0).saturating_add(1);
591                let substitution = prev.get(j).copied().unwrap_or(0).saturating_add(cost);
592                let value = deletion.min(insertion).min(substitution);
593                if let Some(slot) = curr.get_mut(j + 1) {
594                    *slot = value;
595                }
596            }
597
598            std::mem::swap(&mut prev, &mut curr);
599        }
600
601        // Final answer sits at `prev[len2]` after the last swap.
602        // Reason: `prev` is always sized `len2 + 1`, so this index is valid.
603        prev.get(len2).copied().unwrap_or(0)
604    }
605
606    /// Create a database error from PostgreSQL error code.
607    #[must_use]
608    pub fn from_postgres_code(code: &str, message: impl Into<String>) -> Self {
609        let message = message.into();
610        match code {
611            "42P01" => Self::Database {
612                message: "The table or view you're querying doesn't exist. \
613                          Check that the schema is compiled and the database is initialized."
614                    .to_string(),
615                sql_state: Some(code.to_string()),
616            },
617            "42703" => Self::Database {
618                message: "A column referenced in the query doesn't exist in the table. \
619                          This may indicate the database schema is out of sync with the compiled schema."
620                    .to_string(),
621                sql_state: Some(code.to_string()),
622            },
623            "23505" => Self::Conflict {
624                message: "A unique constraint was violated. This value already exists in the database.".to_string(),
625            },
626            "23503" => Self::Conflict {
627                message: "A foreign key constraint was violated. The referenced record doesn't exist."
628                    .to_string(),
629            },
630            "23502" => Self::Conflict {
631                message: "A NOT NULL constraint was violated. The field cannot be empty.".to_string(),
632            },
633            "22P02" => Self::Validation {
634                message: "Invalid input value. The provided value doesn't match the expected data type.".to_string(),
635                path: None,
636            },
637            _ => Self::Database {
638                message,
639                sql_state: Some(code.to_string()),
640            },
641        }
642    }
643
644    /// Create a rate limit error with retry information.
645    #[must_use]
646    pub fn rate_limited_with_retry(retry_after_secs: u64) -> Self {
647        Self::RateLimited {
648            message: format!(
649                "Rate limit exceeded. Please try again in {retry_after_secs} seconds. \
650                 For permanent increases, contact support."
651            ),
652            retry_after_secs,
653        }
654    }
655
656    /// Create an authentication error with context.
657    #[must_use]
658    pub fn auth_error(reason: impl Into<String>) -> Self {
659        Self::Authentication {
660            message: reason.into(),
661        }
662    }
663}
664
665impl From<serde_json::Error> for FraiseQLError {
666    fn from(e: serde_json::Error) -> Self {
667        Self::Parse {
668            message:  e.to_string(),
669            location: format!("line {}, column {}", e.line(), e.column()),
670        }
671    }
672}
673
674impl From<std::io::Error> for FraiseQLError {
675    fn from(e: std::io::Error) -> Self {
676        Self::Internal {
677            message: format!("I/O error: {e}"),
678            source:  Some(Box::new(e)),
679        }
680    }
681}
682
683impl From<std::env::VarError> for FraiseQLError {
684    fn from(e: std::env::VarError) -> Self {
685        Self::Configuration {
686            message: format!("Environment variable error: {e}"),
687        }
688    }
689}
690
691/// Extension trait for adding context to errors.
692pub trait ErrorContext<T> {
693    /// Add context to an error.
694    ///
695    /// # Errors
696    ///
697    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
698    /// given message.
699    fn context(self, message: impl Into<String>) -> Result<T>;
700
701    /// Add context lazily (only computed on error).
702    ///
703    /// # Errors
704    ///
705    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
706    /// context message.
707    fn with_context<F, M>(self, f: F) -> Result<T>
708    where
709        F: FnOnce() -> M,
710        M: Into<String>;
711}
712
713impl<T, E: Into<FraiseQLError>> ErrorContext<T> for std::result::Result<T, E> {
714    fn context(self, message: impl Into<String>) -> Result<T> {
715        self.map_err(|e| {
716            let inner = e.into();
717            FraiseQLError::Internal {
718                message: format!("{}: {inner}", message.into()),
719                source:  None,
720            }
721        })
722    }
723
724    fn with_context<F, M>(self, f: F) -> Result<T>
725    where
726        F: FnOnce() -> M,
727        M: Into<String>,
728    {
729        self.map_err(|e| {
730            let inner = e.into();
731            FraiseQLError::Internal {
732                message: format!("{}: {inner}", f().into()),
733                source:  None,
734            }
735        })
736    }
737}
738
739/// A validation error for a specific field in an input object.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct ValidationFieldError {
742    /// Path to the field that failed validation.
743    pub field:     String,
744    /// Type of validation rule that failed.
745    pub rule_type: String,
746    /// Human-readable error message.
747    pub message:   String,
748}
749
750impl ValidationFieldError {
751    /// Create a new validation field error.
752    #[must_use]
753    pub fn new(
754        field: impl Into<String>,
755        rule_type: impl Into<String>,
756        message: impl Into<String>,
757    ) -> Self {
758        Self {
759            field:     field.into(),
760            rule_type: rule_type.into(),
761            message:   message.into(),
762        }
763    }
764}
765
766impl std::fmt::Display for ValidationFieldError {
767    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
768        write!(f, "{} ({}): {}", self.field, self.rule_type, self.message)
769    }
770}
771
772#[cfg(test)]
773mod tests;