Skip to main content

hyperdb_api/
error.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Error types for the pure Rust Hyper API.
5//!
6//! Callers match directly on [`Error`] variants. There is no `kind()`
7//! indirection, no `Other` catch-all, and no `Box<dyn StdError>`
8//! cause channel — see the [Microsoft Pragmatic Rust Guidelines][1]
9//! M-ERRORS-CANONICAL-STRUCTS and M-ERRORS-AVOID-WRAPPING-AND-AS-DYN.
10//!
11//! Internal errors from [`hyperdb_api_core::client::Error`] are mapped
12//! into this flat enum at the crate boundary via the `From` impl below.
13//!
14//! [1]: https://microsoft.github.io/rust-guidelines/
15
16use thiserror::Error as ThisError;
17
18/// The error type for Hyper API operations.
19///
20/// This enum is `#[non_exhaustive]`: new variants may be added in minor
21/// releases, so match arms must include a wildcard `_ =>` pattern.
22///
23/// Struct variants (`Connection`, `Server`, `Column`,
24/// `ColumnIndexOutOfBounds`, `Internal`) cannot use Rust's
25/// `#[non_exhaustive]` (E0639), so forward-compatibility for new fields
26/// relies on construction via the provided constructors:
27///
28/// - [`Self::internal`] for [`Self::Internal`]
29/// - [`Self::connection`] / [`Self::connection_with_io`] for [`Self::Connection`]
30/// - [`Self::server`] for [`Self::Server`]
31/// - [`Self::column`] for [`Self::Column`]
32/// - [`Self::column_index_out_of_bounds`] for [`Self::ColumnIndexOutOfBounds`]
33///
34/// Downstream code that uses struct-expression syntax for these
35/// variants will fail to compile if a new field is added in a minor
36/// release; using the constructors keeps callers source-compatible.
37#[derive(Debug, ThisError)]
38#[non_exhaustive]
39pub enum Error {
40    // ---- Connection / transport ----------------------------------------
41    /// Connection-level failure (network, handshake, lifecycle, socket
42    /// I/O). Carries the underlying [`std::io::Error`] when one is
43    /// available; the type is erased at the wire-protocol boundary in
44    /// `hyperdb-api-core`, so `source` is `None` for errors that
45    /// originated there. `sqlstate` is set when the server provided a
46    /// connection-class SQLSTATE (e.g. `08001`, `08006`, `57P03`).
47    ///
48    /// Construct via [`Self::connection`], [`Self::connection_with_io`],
49    /// or [`Self::connection_with_sqlstate`].
50    #[error(
51        "connection error{}: {message}",
52        sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
53    )]
54    Connection {
55        /// Human-readable description.
56        message: String,
57        /// Underlying I/O error, if available.
58        #[source]
59        source: Option<std::io::Error>,
60        /// `PostgreSQL` SQLSTATE code, if the server provided one
61        /// (typically `08*` connection-class codes).
62        sqlstate: Option<String>,
63    },
64
65    /// Authentication failed.
66    #[error("authentication failed: {0}")]
67    Authentication(String),
68
69    /// TLS handshake or configuration failure.
70    #[error("TLS error: {0}")]
71    Tls(String),
72
73    // ---- Server-side ---------------------------------------------------
74    /// Server-side error (a SQL query or DDL command failed at the
75    /// server). `sqlstate` is the 5-character `PostgreSQL` SQLSTATE
76    /// code when the server reported one. `detail` and `hint` mirror
77    /// the structured fields the server may include in its error
78    /// response and are appended to the `Display` output when present.
79    #[error(
80        "server error{}: {message}{}{}",
81        sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
82        detail.as_ref().map(|d| format!("\nDETAIL: {d}")).unwrap_or_default(),
83        hint.as_ref().map(|h| format!("\nHINT: {h}")).unwrap_or_default(),
84    )]
85    Server {
86        /// The 5-character `PostgreSQL` SQLSTATE code, if reported.
87        sqlstate: Option<String>,
88        /// The primary error message from the server.
89        message: String,
90        /// Additional detail line from the server's error response.
91        detail: Option<String>,
92        /// Resolution hint from the server's error response.
93        hint: Option<String>,
94    },
95
96    /// Wire-protocol or framing error.
97    #[error("protocol error: {0}")]
98    Protocol(String),
99
100    // ---- I/O -----------------------------------------------------------
101    /// Direct I/O error (file system, non-network sockets) at the SDK
102    /// boundary. Network I/O during connection lifecycle is reported as
103    /// [`Self::Connection`] instead.
104    #[error("I/O error: {0}")]
105    Io(#[from] std::io::Error),
106
107    // ---- Lifecycle -----------------------------------------------------
108    /// Operation attempted on a closed connection. `sqlstate` is set
109    /// when the server provided one (typically `57P01` admin shutdown
110    /// or `57P02` crash shutdown). Construct via [`Self::closed`] or
111    /// [`Self::closed_with_sqlstate`].
112    #[error(
113        "connection closed{}: {message}",
114        sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
115    )]
116    Closed {
117        /// Human-readable description.
118        message: String,
119        /// `PostgreSQL` SQLSTATE code, if the server provided one.
120        sqlstate: Option<String>,
121    },
122
123    /// Operation timed out.
124    #[error("operation timed out: {0}")]
125    Timeout(String),
126
127    /// Operation was cancelled. `sqlstate` is set when the server
128    /// provided one (typically `57014` `query_canceled`). Construct via
129    /// [`Self::cancelled`] or [`Self::cancelled_with_sqlstate`].
130    #[error(
131        "operation cancelled{}: {message}",
132        sqlstate.as_ref().map(|s| format!(" ({s})")).unwrap_or_default(),
133    )]
134    Cancelled {
135        /// Human-readable description.
136        message: String,
137        /// `PostgreSQL` SQLSTATE code, if the server provided one.
138        sqlstate: Option<String>,
139    },
140
141    // ---- Type / value --------------------------------------------------
142    /// Type or value conversion failed (out-of-range numeric, malformed
143    /// binary value, scalar query returned no rows, etc.). For
144    /// column-specific decoding errors, prefer [`Self::Column`].
145    #[error("conversion error: {0}")]
146    Conversion(String),
147
148    /// Serialization or deserialization of a value failed (e.g. a
149    /// `get_as`/`set_as` JSON conversion). Distinct from
150    /// [`Self::Conversion`], which covers SQL type/binary decoding.
151    #[error("serialization error: {0}")]
152    Serialization(String),
153
154    /// Configuration error (invalid endpoint, missing env var, bad
155    /// option combination).
156    #[error("configuration error: {0}")]
157    Config(String),
158
159    /// Feature is not supported on this connection or transport.
160    #[error("feature not supported: {0}")]
161    FeatureNotSupported(String),
162
163    // ---- Catalog / validation ------------------------------------------
164    /// Database identifier is invalid (empty, exceeds the `PostgreSQL`
165    /// 63-byte limit, or violates other naming rules).
166    #[error("invalid name: {0}")]
167    InvalidName(String),
168
169    /// Table definition is invalid (zero columns, conflicting
170    /// attributes).
171    #[error("invalid table definition: {0}")]
172    InvalidTableDefinition(String),
173
174    /// Database object (schema, table, etc.) was not found.
175    #[error("not found: {0}")]
176    NotFound(String),
177
178    /// Database object already exists.
179    #[error("already exists: {0}")]
180    AlreadyExists(String),
181
182    /// Caller-API misuse: a method was called in an invalid sequence
183    /// or combination (e.g. mixing two mutually exclusive insertion
184    /// modes on a single inserter, calling a method after the resource
185    /// has been finalized). Distinct from [`Self::Internal`], which is
186    /// reserved for true library invariant violations the caller could
187    /// not have triggered. Construct via [`Self::invalid_operation`].
188    #[error("invalid operation: {0}")]
189    InvalidOperation(String),
190
191    // ---- Column / row mapping ------------------------------------------
192    /// Structured error for named-column access in row decoding. Used
193    /// by `FromRow` impls and `Row::try_get` / `Row::get_by_name` to
194    /// signal which column failed and why.
195    #[error("column {name}: {kind}")]
196    Column {
197        /// The column name.
198        name: String,
199        /// The structured cause of the column-access failure.
200        #[source]
201        kind: ColumnErrorKind,
202    },
203
204    /// Column index was out of bounds for the row. Used for positional
205    /// access; named access uses [`Self::Column`] with
206    /// [`ColumnErrorKind::Missing`].
207    #[error("column index {idx} out of bounds (row has {column_count} columns)")]
208    ColumnIndexOutOfBounds {
209        /// The requested 0-based column index.
210        idx: usize,
211        /// The actual column count of the row.
212        column_count: usize,
213    },
214
215    // ---- Internal ------------------------------------------------------
216    /// Internal invariant violation — a state the library believes
217    /// should be unreachable. Callers cannot trigger this from the
218    /// public API in well-formed code; reaching it indicates a bug
219    /// inside `hyperdb-api`. Recovery is generally impossible beyond
220    /// logging and bailing.
221    ///
222    /// For caller-API misuse (e.g. mixing two mutually exclusive
223    /// methods, using a finalized resource), prefer
224    /// [`Self::InvalidOperation`].
225    ///
226    /// Construct via [`Self::internal`].
227    #[error("internal error: {message}")]
228    Internal {
229        /// Human-readable description of what invariant was violated.
230        message: String,
231    },
232}
233
234/// The structured cause of an [`Error::Column`].
235#[derive(Debug, ThisError)]
236#[non_exhaustive]
237pub enum ColumnErrorKind {
238    /// Column name was not found in the result schema.
239    #[error("column not found")]
240    Missing,
241
242    /// Column was SQL `NULL` but the target type was not `Option<T>`.
243    #[error("unexpected NULL")]
244    Null,
245
246    /// Column value could not be decoded as the target type.
247    #[error("type mismatch: expected {expected}, got {actual}")]
248    TypeMismatch {
249        /// Rust type name the caller asked for.
250        expected: String,
251        /// Hyper SQL type name (or descriptive label) of the column.
252        actual: String,
253    },
254}
255
256impl Error {
257    /// Constructs an [`Self::Internal`] error. Prefer this over
258    /// struct-expression syntax to remain source-compatible if new
259    /// fields are added in a minor release.
260    pub fn internal(message: impl Into<String>) -> Self {
261        Error::Internal {
262            message: message.into(),
263        }
264    }
265
266    /// Constructs an [`Self::Connection`] error with no underlying I/O
267    /// source and no SQLSTATE. Prefer this over struct-expression
268    /// syntax to remain source-compatible if new fields are added in a
269    /// minor release.
270    pub fn connection(message: impl Into<String>) -> Self {
271        Error::Connection {
272            message: message.into(),
273            source: None,
274            sqlstate: None,
275        }
276    }
277
278    /// Constructs an [`Self::Connection`] error wrapping an underlying
279    /// [`std::io::Error`]. Prefer this over struct-expression syntax
280    /// to remain source-compatible if new fields are added in a minor
281    /// release.
282    pub fn connection_with_io(message: impl Into<String>, source: std::io::Error) -> Self {
283        Error::Connection {
284            message: message.into(),
285            source: Some(source),
286            sqlstate: None,
287        }
288    }
289
290    /// Constructs an [`Self::Connection`] error carrying a SQLSTATE
291    /// code (typically `08*` connection-class) and no I/O source.
292    pub fn connection_with_sqlstate(
293        message: impl Into<String>,
294        sqlstate: impl Into<String>,
295    ) -> Self {
296        Error::Connection {
297            message: message.into(),
298            source: None,
299            sqlstate: Some(sqlstate.into()),
300        }
301    }
302
303    /// Constructs an [`Self::Server`] error. Prefer this over
304    /// struct-expression syntax to remain source-compatible if new
305    /// fields are added in a minor release.
306    pub fn server(
307        sqlstate: Option<String>,
308        message: impl Into<String>,
309        detail: Option<String>,
310        hint: Option<String>,
311    ) -> Self {
312        Error::Server {
313            sqlstate,
314            message: message.into(),
315            detail,
316            hint,
317        }
318    }
319
320    /// Constructs an [`Self::Column`] error. Prefer this over
321    /// struct-expression syntax to remain source-compatible if new
322    /// fields are added in a minor release.
323    pub fn column(name: impl Into<String>, kind: ColumnErrorKind) -> Self {
324        Error::Column {
325            name: name.into(),
326            kind,
327        }
328    }
329
330    /// Constructs an [`Self::ColumnIndexOutOfBounds`] error. Prefer
331    /// this over struct-expression syntax to remain source-compatible
332    /// if new fields are added in a minor release.
333    #[must_use]
334    pub fn column_index_out_of_bounds(idx: usize, column_count: usize) -> Self {
335        Error::ColumnIndexOutOfBounds { idx, column_count }
336    }
337
338    // ---- Tuple-variant constructors ------------------------------------
339    //
340    // These accept `impl Into<String>` so callers can pass either `&str`,
341    // `String`, or `format!(...)` without the `.to_string()` / `.into()`
342    // ceremony every direct construction would otherwise require.
343
344    /// Constructs an [`Self::Authentication`] error.
345    pub fn authentication(message: impl Into<String>) -> Self {
346        Error::Authentication(message.into())
347    }
348
349    /// Constructs an [`Self::Tls`] error.
350    pub fn tls(message: impl Into<String>) -> Self {
351        Error::Tls(message.into())
352    }
353
354    /// Constructs an [`Self::Protocol`] error.
355    pub fn protocol(message: impl Into<String>) -> Self {
356        Error::Protocol(message.into())
357    }
358
359    /// Constructs an [`Self::Closed`] error with no SQLSTATE.
360    pub fn closed(message: impl Into<String>) -> Self {
361        Error::Closed {
362            message: message.into(),
363            sqlstate: None,
364        }
365    }
366
367    /// Constructs an [`Self::Closed`] error carrying a SQLSTATE code
368    /// (typically `57P01` admin shutdown or `57P02` crash shutdown).
369    pub fn closed_with_sqlstate(message: impl Into<String>, sqlstate: impl Into<String>) -> Self {
370        Error::Closed {
371            message: message.into(),
372            sqlstate: Some(sqlstate.into()),
373        }
374    }
375
376    /// Constructs an [`Self::Timeout`] error.
377    pub fn timeout(message: impl Into<String>) -> Self {
378        Error::Timeout(message.into())
379    }
380
381    /// Constructs an [`Self::Cancelled`] error with no SQLSTATE.
382    pub fn cancelled(message: impl Into<String>) -> Self {
383        Error::Cancelled {
384            message: message.into(),
385            sqlstate: None,
386        }
387    }
388
389    /// Constructs an [`Self::Cancelled`] error carrying a SQLSTATE
390    /// code (typically `57014` `query_canceled`).
391    pub fn cancelled_with_sqlstate(
392        message: impl Into<String>,
393        sqlstate: impl Into<String>,
394    ) -> Self {
395        Error::Cancelled {
396            message: message.into(),
397            sqlstate: Some(sqlstate.into()),
398        }
399    }
400
401    /// Constructs an [`Self::Conversion`] error.
402    pub fn conversion(message: impl Into<String>) -> Self {
403        Error::Conversion(message.into())
404    }
405
406    /// Constructs an [`Self::Serialization`] error.
407    pub fn serialization(message: impl Into<String>) -> Self {
408        Error::Serialization(message.into())
409    }
410
411    /// Constructs an [`Self::Config`] error.
412    pub fn config(message: impl Into<String>) -> Self {
413        Error::Config(message.into())
414    }
415
416    /// Constructs an [`Self::FeatureNotSupported`] error.
417    pub fn feature_not_supported(message: impl Into<String>) -> Self {
418        Error::FeatureNotSupported(message.into())
419    }
420
421    /// Constructs an [`Self::InvalidName`] error.
422    pub fn invalid_name(message: impl Into<String>) -> Self {
423        Error::InvalidName(message.into())
424    }
425
426    /// Constructs an [`Self::InvalidTableDefinition`] error.
427    pub fn invalid_table_definition(message: impl Into<String>) -> Self {
428        Error::InvalidTableDefinition(message.into())
429    }
430
431    /// Constructs an [`Self::NotFound`] error.
432    pub fn not_found(message: impl Into<String>) -> Self {
433        Error::NotFound(message.into())
434    }
435
436    /// Constructs an [`Self::AlreadyExists`] error.
437    pub fn already_exists(message: impl Into<String>) -> Self {
438        Error::AlreadyExists(message.into())
439    }
440
441    /// Constructs an [`Self::InvalidOperation`] error.
442    pub fn invalid_operation(message: impl Into<String>) -> Self {
443        Error::InvalidOperation(message.into())
444    }
445
446    /// Returns the error message in human-readable form. Equivalent to
447    /// `self.to_string()`.
448    #[must_use]
449    pub fn message(&self) -> String {
450        self.to_string()
451    }
452
453    /// Returns the `PostgreSQL` SQLSTATE code if this error carries
454    /// one, otherwise `None`.
455    ///
456    /// Returns `Some(...)` for [`Self::Server`] (Query-class codes),
457    /// [`Self::Connection`] (typically `08*`), [`Self::Closed`]
458    /// (typically `57P0*` shutdown codes), and [`Self::Cancelled`]
459    /// (typically `57014` `query_canceled`) when the underlying server
460    /// provided a code.
461    ///
462    /// SQLSTATE codes are 5-character strings — see the [`PostgreSQL`
463    /// errcodes appendix][1].
464    ///
465    /// [1]: https://www.postgresql.org/docs/current/errcodes-appendix.html
466    #[must_use]
467    pub fn sqlstate(&self) -> Option<&str> {
468        match self {
469            Error::Server { sqlstate, .. }
470            | Error::Connection { sqlstate, .. }
471            | Error::Closed { sqlstate, .. }
472            | Error::Cancelled { sqlstate, .. } => sqlstate.as_deref(),
473            _ => None,
474        }
475    }
476}
477
478// Internal mapping: `client::Error` → public `Error`. Both types are now
479// flat enums, so this is a variant-to-variant match. `client::Error` is
480// `#[non_exhaustive]`, so the wildcard arm is required; it routes any
481// future variant to `Internal` rather than failing the build.
482//
483// `chain = err.to_string()` renders the inner error's `Display`, which
484// folds in the `DETAIL` suffix where one applies. We use it for tuple
485// variants whose `Display` is just `"<prefix>: {0}"`, where embedding the
486// whole rendering into the single string field gives the caller the full
487// picture.
488//
489// For the `Server` variant we use the *un-chained* `message` and pass
490// `detail`/`hint` separately; the `Server` `Display` impl re-appends
491// "DETAIL: ..." and "HINT: ..." lines from those fields, so using
492// `chain` would duplicate the detail text.
493//
494// SQLSTATE: the flat enum carries `sqlstate` on `Server`, `Connection`,
495// `Closed`, and `Cancelled` so callers can match on it programmatically
496// (e.g. SQLSTATE 57014 `query_canceled` arrives via Cancelled and is
497// exposed structurally). Those are exactly the `client::Error` variants
498// that carry one, so nothing is dropped in transit.
499impl From<hyperdb_api_core::client::Error> for Error {
500    fn from(err: hyperdb_api_core::client::Error) -> Self {
501        use hyperdb_api_core::client::Error as CoreError;
502
503        let chain = err.to_string();
504
505        match err {
506            CoreError::Connection { sqlstate, .. } => Error::Connection {
507                message: chain,
508                source: None,
509                sqlstate,
510            },
511            CoreError::Authentication(_) => Error::Authentication(chain),
512            // Use the unchained `message` here: detail/hint are passed as
513            // separate fields and the `Server` Display impl re-renders
514            // them. Using `chain` would duplicate detail text.
515            CoreError::Query {
516                message,
517                sqlstate,
518                detail,
519                hint,
520            } => Error::Server {
521                sqlstate,
522                message,
523                detail,
524                hint,
525            },
526            CoreError::Protocol(_) => Error::Protocol(chain),
527            // Wire-level I/O failures are reported as Connection errors
528            // (the underlying io::Error is type-erased in core, so we
529            // cannot recover it as a typed `source` here).
530            CoreError::Io(_) => Error::Connection {
531                message: chain,
532                source: None,
533                sqlstate: None,
534            },
535            CoreError::Config(_) => Error::Config(chain),
536            CoreError::Timeout(_) => Error::Timeout(chain),
537            CoreError::Cancelled { sqlstate, .. } => Error::Cancelled {
538                message: chain,
539                sqlstate,
540            },
541            CoreError::Closed { sqlstate, .. } => Error::Closed {
542                message: chain,
543                sqlstate,
544            },
545            CoreError::Conversion(_) => Error::Conversion(chain),
546            CoreError::FeatureNotSupported(_) => Error::FeatureNotSupported(chain),
547            CoreError::Other(_) => Error::Internal { message: chain },
548            // No wildcard arm on purpose: `client::Error` is not
549            // `#[non_exhaustive]`, so this match is exhaustive and a
550            // variant added upstream fails to compile here until it is
551            // given a deliberate public mapping.
552        }
553    }
554}
555
556// `Infallible` is the error type for identity `TryFrom`/`TryInto`
557// conversions. Generic APIs that take `T: TryInto<U>` and bound
558// `Error: From<T::Error>` (e.g. `TableDefinition::from_table_name`)
559// require this impl to compile when callers pass a value that is
560// already the target type. The body is unreachable because
561// `Infallible` has no values.
562impl From<std::convert::Infallible> for Error {
563    fn from(_: std::convert::Infallible) -> Self {
564        unreachable!("Infallible has no values")
565    }
566}
567
568/// Result type for Hyper API operations.
569pub type Result<T> = std::result::Result<T, Error>;
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use hyperdb_api_core::client::Error as CoreError;
575
576    #[test]
577    fn server_display_includes_sqlstate_detail_and_hint() {
578        let err = Error::server(
579            Some("23505".to_string()),
580            "duplicate key value violates unique constraint",
581            Some("Key (id)=(42) already exists.".to_string()),
582            Some("Choose a different key.".to_string()),
583        );
584        let s = err.to_string();
585        assert!(s.contains("server error (23505)"), "got: {s}");
586        assert!(
587            s.contains("duplicate key value violates unique constraint"),
588            "got: {s}"
589        );
590        assert!(
591            s.contains("\nDETAIL: Key (id)=(42) already exists."),
592            "got: {s}"
593        );
594        assert!(s.contains("\nHINT: Choose a different key."), "got: {s}");
595    }
596
597    #[test]
598    fn server_display_omits_missing_optional_fields() {
599        let err = Error::server(None, "syntax error at end of input", None, None);
600        let s = err.to_string();
601        assert_eq!(s, "server error: syntax error at end of input");
602    }
603
604    #[test]
605    fn from_client_error_query_does_not_duplicate_detail() {
606        // Build a client::Error with detail; client::Error::Display
607        // appends ": {detail}" inline. The flat-Error mapping must
608        // not also add "\nDETAIL: {detail}" — that would duplicate the
609        // text. We verify by counting occurrences.
610        let core = CoreError::Query {
611            message: "duplicate key value".to_string(),
612            sqlstate: Some("23505".to_string()),
613            detail: Some("Key (id)=(42) already exists.".to_string()),
614            hint: Some("Choose a different key.".to_string()),
615        };
616        let public: Error = core.into();
617        let s = public.to_string();
618        // The detail text should appear exactly once in the rendered
619        // string. (Once on the DETAIL line; not also inline in message.)
620        let count = s.matches("Key (id)=(42) already exists.").count();
621        assert_eq!(count, 1, "detail must appear exactly once; got: {s}");
622        let hint_count = s.matches("Choose a different key.").count();
623        assert_eq!(hint_count, 1, "hint must appear exactly once; got: {s}");
624        // Verify SQLSTATE is preserved.
625        assert_eq!(public.sqlstate(), Some("23505"));
626    }
627
628    #[test]
629    fn from_client_error_maps_every_variant() {
630        // Smoke test: every `client::Error` variant maps cleanly with no
631        // panic and without losing the message.
632        //
633        // This list is hand-written and nothing forces it to stay
634        // complete. The guarantee that a new variant gets a deliberate
635        // mapping comes from the `From` impl instead: `client::Error` is
636        // not `#[non_exhaustive]` and that match has no wildcard, so
637        // adding a variant upstream breaks the build there. Extend this
638        // list when that happens.
639        for core in [
640            CoreError::connection("test message"),
641            CoreError::authentication("test message"),
642            CoreError::query("test message"),
643            CoreError::protocol("test message"),
644            CoreError::io("test message"),
645            CoreError::config("test message"),
646            CoreError::timeout("test message"),
647            CoreError::cancelled("test message"),
648            CoreError::closed("test message"),
649            CoreError::conversion("test message"),
650            CoreError::feature_not_supported("test message"),
651            CoreError::other("test message"),
652        ] {
653            let rendered = format!("{core:?}");
654            let public: Error = core.into();
655            // Each variant's Display must include the message text.
656            assert!(
657                public.to_string().contains("test message"),
658                "{rendered} mapping lost the message: {public}",
659            );
660        }
661    }
662
663    #[test]
664    fn sqlstate_returns_some_for_server_connection_closed_cancelled() {
665        // Server still surfaces SQLSTATE.
666        let server = Error::server(Some("42P04".to_string()), "db exists", None, None);
667        assert_eq!(server.sqlstate(), Some("42P04"));
668
669        // Connection / Closed / Cancelled now surface SQLSTATE
670        // structurally (Follow-up C).
671        let conn = Error::connection_with_sqlstate("connect failed", "08006");
672        assert_eq!(conn.sqlstate(), Some("08006"));
673
674        let closed = Error::closed_with_sqlstate("admin shutdown", "57P01");
675        assert_eq!(closed.sqlstate(), Some("57P01"));
676
677        let cancelled = Error::cancelled_with_sqlstate("user cancel", "57014");
678        assert_eq!(cancelled.sqlstate(), Some("57014"));
679
680        // Variants without sqlstate field return None.
681        assert_eq!(Error::Conversion("...".into()).sqlstate(), None);
682        assert_eq!(
683            Error::Internal {
684                message: "...".into()
685            }
686            .sqlstate(),
687            None
688        );
689
690        // Cancelled with no SQLSTATE returns None too.
691        assert_eq!(Error::cancelled("user cancel").sqlstate(), None);
692    }
693
694    #[test]
695    fn column_display_formats_name_and_kind() {
696        let err = Error::column("user_id", ColumnErrorKind::Missing);
697        assert_eq!(err.to_string(), "column user_id: column not found");
698
699        let err = Error::column("score", ColumnErrorKind::Null);
700        assert_eq!(err.to_string(), "column score: unexpected NULL");
701
702        let err = Error::column(
703            "count",
704            ColumnErrorKind::TypeMismatch {
705                expected: "i32".into(),
706                actual: "TEXT".into(),
707            },
708        );
709        assert_eq!(
710            err.to_string(),
711            "column count: type mismatch: expected i32, got TEXT"
712        );
713    }
714
715    #[test]
716    fn column_index_out_of_bounds_display() {
717        let err = Error::column_index_out_of_bounds(5, 3);
718        assert_eq!(
719            err.to_string(),
720            "column index 5 out of bounds (row has 3 columns)"
721        );
722    }
723
724    #[test]
725    fn connection_display_with_typed_io_source() {
726        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
727        let err = Error::connection_with_io("connecting to hyperd", io_err);
728        let s = err.to_string();
729        // Top-level message is the prefixed form.
730        assert!(
731            s.contains("connection error: connecting to hyperd"),
732            "got: {s}"
733        );
734        // The typed source is recoverable via std::error::Error::source().
735        use std::error::Error as StdError;
736        let src = err.source().expect("connection_with_io must expose source");
737        let io_src: &std::io::Error = src
738            .downcast_ref::<std::io::Error>()
739            .expect("source must downcast to io::Error");
740        assert_eq!(io_src.kind(), std::io::ErrorKind::ConnectionRefused);
741    }
742
743    #[test]
744    fn internal_constructor_round_trip() {
745        let err = Error::internal("invariant violated");
746        assert_eq!(err.to_string(), "internal error: invariant violated");
747    }
748
749    #[test]
750    fn invalid_operation_constructor_round_trip() {
751        let err = Error::invalid_operation("cannot mix insert_data with insert_batch");
752        assert_eq!(
753            err.to_string(),
754            "invalid operation: cannot mix insert_data with insert_batch"
755        );
756        assert!(matches!(err, Error::InvalidOperation(_)));
757    }
758
759    #[test]
760    fn serialization_constructor_round_trip() {
761        let err = Error::serialization("expected value at line 1 column 1");
762        assert_eq!(
763            err.to_string(),
764            "serialization error: expected value at line 1 column 1"
765        );
766        assert!(matches!(err, Error::Serialization(_)));
767    }
768}