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    /// Configuration error (invalid endpoint, missing env var, bad
149    /// option combination).
150    #[error("configuration error: {0}")]
151    Config(String),
152
153    /// Feature is not supported on this connection or transport.
154    #[error("feature not supported: {0}")]
155    FeatureNotSupported(String),
156
157    // ---- Catalog / validation ------------------------------------------
158    /// Database identifier is invalid (empty, exceeds the `PostgreSQL`
159    /// 63-byte limit, or violates other naming rules).
160    #[error("invalid name: {0}")]
161    InvalidName(String),
162
163    /// Table definition is invalid (zero columns, conflicting
164    /// attributes).
165    #[error("invalid table definition: {0}")]
166    InvalidTableDefinition(String),
167
168    /// Database object (schema, table, etc.) was not found.
169    #[error("not found: {0}")]
170    NotFound(String),
171
172    /// Database object already exists.
173    #[error("already exists: {0}")]
174    AlreadyExists(String),
175
176    /// Caller-API misuse: a method was called in an invalid sequence
177    /// or combination (e.g. mixing two mutually exclusive insertion
178    /// modes on a single inserter, calling a method after the resource
179    /// has been finalized). Distinct from [`Self::Internal`], which is
180    /// reserved for true library invariant violations the caller could
181    /// not have triggered. Construct via [`Self::invalid_operation`].
182    #[error("invalid operation: {0}")]
183    InvalidOperation(String),
184
185    // ---- Column / row mapping ------------------------------------------
186    /// Structured error for named-column access in row decoding. Used
187    /// by `FromRow` impls and `Row::try_get` / `Row::get_by_name` to
188    /// signal which column failed and why.
189    #[error("column {name}: {kind}")]
190    Column {
191        /// The column name.
192        name: String,
193        /// The structured cause of the column-access failure.
194        #[source]
195        kind: ColumnErrorKind,
196    },
197
198    /// Column index was out of bounds for the row. Used for positional
199    /// access; named access uses [`Self::Column`] with
200    /// [`ColumnErrorKind::Missing`].
201    #[error("column index {idx} out of bounds (row has {column_count} columns)")]
202    ColumnIndexOutOfBounds {
203        /// The requested 0-based column index.
204        idx: usize,
205        /// The actual column count of the row.
206        column_count: usize,
207    },
208
209    // ---- Internal ------------------------------------------------------
210    /// Internal invariant violation — a state the library believes
211    /// should be unreachable. Callers cannot trigger this from the
212    /// public API in well-formed code; reaching it indicates a bug
213    /// inside `hyperdb-api`. Recovery is generally impossible beyond
214    /// logging and bailing.
215    ///
216    /// For caller-API misuse (e.g. mixing two mutually exclusive
217    /// methods, using a finalized resource), prefer
218    /// [`Self::InvalidOperation`].
219    ///
220    /// Construct via [`Self::internal`].
221    #[error("internal error: {message}")]
222    Internal {
223        /// Human-readable description of what invariant was violated.
224        message: String,
225    },
226}
227
228/// The structured cause of an [`Error::Column`].
229#[derive(Debug, ThisError)]
230#[non_exhaustive]
231pub enum ColumnErrorKind {
232    /// Column name was not found in the result schema.
233    #[error("column not found")]
234    Missing,
235
236    /// Column was SQL `NULL` but the target type was not `Option<T>`.
237    #[error("unexpected NULL")]
238    Null,
239
240    /// Column value could not be decoded as the target type.
241    #[error("type mismatch: expected {expected}, got {actual}")]
242    TypeMismatch {
243        /// Rust type name the caller asked for.
244        expected: String,
245        /// Hyper SQL type name (or descriptive label) of the column.
246        actual: String,
247    },
248}
249
250impl Error {
251    /// Constructs an [`Self::Internal`] error. Prefer this over
252    /// struct-expression syntax to remain source-compatible if new
253    /// fields are added in a minor release.
254    pub fn internal(message: impl Into<String>) -> Self {
255        Error::Internal {
256            message: message.into(),
257        }
258    }
259
260    /// Constructs an [`Self::Connection`] error with no underlying I/O
261    /// source and no SQLSTATE. Prefer this over struct-expression
262    /// syntax to remain source-compatible if new fields are added in a
263    /// minor release.
264    pub fn connection(message: impl Into<String>) -> Self {
265        Error::Connection {
266            message: message.into(),
267            source: None,
268            sqlstate: None,
269        }
270    }
271
272    /// Constructs an [`Self::Connection`] error wrapping an underlying
273    /// [`std::io::Error`]. Prefer this over struct-expression syntax
274    /// to remain source-compatible if new fields are added in a minor
275    /// release.
276    pub fn connection_with_io(message: impl Into<String>, source: std::io::Error) -> Self {
277        Error::Connection {
278            message: message.into(),
279            source: Some(source),
280            sqlstate: None,
281        }
282    }
283
284    /// Constructs an [`Self::Connection`] error carrying a SQLSTATE
285    /// code (typically `08*` connection-class) and no I/O source.
286    pub fn connection_with_sqlstate(
287        message: impl Into<String>,
288        sqlstate: impl Into<String>,
289    ) -> Self {
290        Error::Connection {
291            message: message.into(),
292            source: None,
293            sqlstate: Some(sqlstate.into()),
294        }
295    }
296
297    /// Constructs an [`Self::Server`] error. Prefer this over
298    /// struct-expression syntax to remain source-compatible if new
299    /// fields are added in a minor release.
300    pub fn server(
301        sqlstate: Option<String>,
302        message: impl Into<String>,
303        detail: Option<String>,
304        hint: Option<String>,
305    ) -> Self {
306        Error::Server {
307            sqlstate,
308            message: message.into(),
309            detail,
310            hint,
311        }
312    }
313
314    /// Constructs an [`Self::Column`] error. Prefer this over
315    /// struct-expression syntax to remain source-compatible if new
316    /// fields are added in a minor release.
317    pub fn column(name: impl Into<String>, kind: ColumnErrorKind) -> Self {
318        Error::Column {
319            name: name.into(),
320            kind,
321        }
322    }
323
324    /// Constructs an [`Self::ColumnIndexOutOfBounds`] error. Prefer
325    /// this over struct-expression syntax to remain source-compatible
326    /// if new fields are added in a minor release.
327    pub fn column_index_out_of_bounds(idx: usize, column_count: usize) -> Self {
328        Error::ColumnIndexOutOfBounds { idx, column_count }
329    }
330
331    // ---- Tuple-variant constructors ------------------------------------
332    //
333    // These accept `impl Into<String>` so callers can pass either `&str`,
334    // `String`, or `format!(...)` without the `.to_string()` / `.into()`
335    // ceremony every direct construction would otherwise require.
336
337    /// Constructs an [`Self::Authentication`] error.
338    pub fn authentication(message: impl Into<String>) -> Self {
339        Error::Authentication(message.into())
340    }
341
342    /// Constructs an [`Self::Tls`] error.
343    pub fn tls(message: impl Into<String>) -> Self {
344        Error::Tls(message.into())
345    }
346
347    /// Constructs an [`Self::Protocol`] error.
348    pub fn protocol(message: impl Into<String>) -> Self {
349        Error::Protocol(message.into())
350    }
351
352    /// Constructs an [`Self::Closed`] error with no SQLSTATE.
353    pub fn closed(message: impl Into<String>) -> Self {
354        Error::Closed {
355            message: message.into(),
356            sqlstate: None,
357        }
358    }
359
360    /// Constructs an [`Self::Closed`] error carrying a SQLSTATE code
361    /// (typically `57P01` admin shutdown or `57P02` crash shutdown).
362    pub fn closed_with_sqlstate(message: impl Into<String>, sqlstate: impl Into<String>) -> Self {
363        Error::Closed {
364            message: message.into(),
365            sqlstate: Some(sqlstate.into()),
366        }
367    }
368
369    /// Constructs an [`Self::Timeout`] error.
370    pub fn timeout(message: impl Into<String>) -> Self {
371        Error::Timeout(message.into())
372    }
373
374    /// Constructs an [`Self::Cancelled`] error with no SQLSTATE.
375    pub fn cancelled(message: impl Into<String>) -> Self {
376        Error::Cancelled {
377            message: message.into(),
378            sqlstate: None,
379        }
380    }
381
382    /// Constructs an [`Self::Cancelled`] error carrying a SQLSTATE
383    /// code (typically `57014` `query_canceled`).
384    pub fn cancelled_with_sqlstate(
385        message: impl Into<String>,
386        sqlstate: impl Into<String>,
387    ) -> Self {
388        Error::Cancelled {
389            message: message.into(),
390            sqlstate: Some(sqlstate.into()),
391        }
392    }
393
394    /// Constructs an [`Self::Conversion`] error.
395    pub fn conversion(message: impl Into<String>) -> Self {
396        Error::Conversion(message.into())
397    }
398
399    /// Constructs an [`Self::Config`] error.
400    pub fn config(message: impl Into<String>) -> Self {
401        Error::Config(message.into())
402    }
403
404    /// Constructs an [`Self::FeatureNotSupported`] error.
405    pub fn feature_not_supported(message: impl Into<String>) -> Self {
406        Error::FeatureNotSupported(message.into())
407    }
408
409    /// Constructs an [`Self::InvalidName`] error.
410    pub fn invalid_name(message: impl Into<String>) -> Self {
411        Error::InvalidName(message.into())
412    }
413
414    /// Constructs an [`Self::InvalidTableDefinition`] error.
415    pub fn invalid_table_definition(message: impl Into<String>) -> Self {
416        Error::InvalidTableDefinition(message.into())
417    }
418
419    /// Constructs an [`Self::NotFound`] error.
420    pub fn not_found(message: impl Into<String>) -> Self {
421        Error::NotFound(message.into())
422    }
423
424    /// Constructs an [`Self::AlreadyExists`] error.
425    pub fn already_exists(message: impl Into<String>) -> Self {
426        Error::AlreadyExists(message.into())
427    }
428
429    /// Constructs an [`Self::InvalidOperation`] error.
430    pub fn invalid_operation(message: impl Into<String>) -> Self {
431        Error::InvalidOperation(message.into())
432    }
433
434    /// Returns the error message in human-readable form. Equivalent to
435    /// `self.to_string()`.
436    #[must_use]
437    pub fn message(&self) -> String {
438        self.to_string()
439    }
440
441    /// Returns the `PostgreSQL` SQLSTATE code if this error carries
442    /// one, otherwise `None`.
443    ///
444    /// Returns `Some(...)` for [`Self::Server`] (Query-class codes),
445    /// [`Self::Connection`] (typically `08*`), [`Self::Closed`]
446    /// (typically `57P0*` shutdown codes), and [`Self::Cancelled`]
447    /// (typically `57014` `query_canceled`) when the underlying server
448    /// provided a code.
449    ///
450    /// SQLSTATE codes are 5-character strings — see the [`PostgreSQL`
451    /// errcodes appendix][1].
452    ///
453    /// [1]: https://www.postgresql.org/docs/current/errcodes-appendix.html
454    #[must_use]
455    pub fn sqlstate(&self) -> Option<&str> {
456        match self {
457            Error::Server { sqlstate, .. }
458            | Error::Connection { sqlstate, .. }
459            | Error::Closed { sqlstate, .. }
460            | Error::Cancelled { sqlstate, .. } => sqlstate.as_deref(),
461            _ => None,
462        }
463    }
464}
465
466// Internal mapping: `client::Error` → public `Error`. The mapping is
467// exhaustive over `client::ErrorKind` (verified to NOT be
468// `#[non_exhaustive]`); adding a kind in `hyperdb-api-core` will break
469// this build until the mapping is updated, which is intended.
470//
471// `chain = err.to_string()` walks the inner error's full Display chain
472// (message + cause + detail). We use it for tuple variants whose
473// `Display` is just `"<prefix>: {0}"`, where embedding the chain into
474// the single string field gives the caller the full picture.
475//
476// For the `Server` variant we use the *un-chained* `message` and pass
477// `detail`/`hint` separately; the `Server` `Display` impl re-appends
478// "DETAIL: ..." and "HINT: ..." lines from those fields, so using
479// `chain` would duplicate the detail text.
480//
481// SQLSTATE: `client::Error::sqlstate()` may return `Some` for any
482// kind. After Follow-up C, the flat enum carries `sqlstate` on
483// `Server`, `Connection`, `Closed`, and `Cancelled` so callers can
484// match on it programmatically (e.g. SQLSTATE 57014 `query_canceled`
485// arrives via Cancelled and is now exposed structurally). Other
486// variants still drop SQLSTATE — folded into the message via `chain`.
487impl From<hyperdb_api_core::client::Error> for Error {
488    fn from(err: hyperdb_api_core::client::Error) -> Self {
489        use hyperdb_api_core::client::ErrorKind as CoreKind;
490
491        let chain = err.to_string();
492        let kind = err.kind();
493        let sqlstate = err.sqlstate().map(str::to_string);
494        let detail = err.detail().map(str::to_string);
495        let hint = err.hint().map(str::to_string);
496        let message = err.message().to_string();
497
498        match kind {
499            CoreKind::Connection => Error::Connection {
500                message: chain,
501                source: None,
502                sqlstate,
503            },
504            CoreKind::Authentication => Error::Authentication(chain),
505            // Use unchained `message` here: detail/hint are passed as
506            // separate fields and the `Server` Display impl re-renders
507            // them. Using `chain` would duplicate detail text.
508            CoreKind::Query => Error::Server {
509                sqlstate,
510                message,
511                detail,
512                hint,
513            },
514            CoreKind::Protocol => Error::Protocol(chain),
515            // Wire-level I/O failures are reported as Connection errors
516            // (the underlying io::Error is type-erased in core, so we
517            // cannot recover it as a typed `source` here).
518            CoreKind::Io => Error::Connection {
519                message: chain,
520                source: None,
521                sqlstate,
522            },
523            CoreKind::Config => Error::Config(chain),
524            CoreKind::Timeout => Error::Timeout(chain),
525            CoreKind::Cancelled => Error::Cancelled {
526                message: chain,
527                sqlstate,
528            },
529            CoreKind::Closed => Error::Closed {
530                message: chain,
531                sqlstate,
532            },
533            CoreKind::Conversion => Error::Conversion(chain),
534            CoreKind::FeatureNotSupported => Error::FeatureNotSupported(chain),
535            CoreKind::Other => Error::Internal { message: chain },
536        }
537    }
538}
539
540// `Infallible` is the error type for identity `TryFrom`/`TryInto`
541// conversions. Generic APIs that take `T: TryInto<U>` and bound
542// `Error: From<T::Error>` (e.g. `TableDefinition::from_table_name`)
543// require this impl to compile when callers pass a value that is
544// already the target type. The body is unreachable because
545// `Infallible` has no values.
546impl From<std::convert::Infallible> for Error {
547    fn from(_: std::convert::Infallible) -> Self {
548        unreachable!("Infallible has no values")
549    }
550}
551
552/// Result type for Hyper API operations.
553pub type Result<T> = std::result::Result<T, Error>;
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use hyperdb_api_core::client::{Error as CoreError, ErrorKind as CoreKind};
559
560    #[test]
561    fn server_display_includes_sqlstate_detail_and_hint() {
562        let err = Error::server(
563            Some("23505".to_string()),
564            "duplicate key value violates unique constraint",
565            Some("Key (id)=(42) already exists.".to_string()),
566            Some("Choose a different key.".to_string()),
567        );
568        let s = err.to_string();
569        assert!(s.contains("server error (23505)"), "got: {s}");
570        assert!(
571            s.contains("duplicate key value violates unique constraint"),
572            "got: {s}"
573        );
574        assert!(
575            s.contains("\nDETAIL: Key (id)=(42) already exists."),
576            "got: {s}"
577        );
578        assert!(s.contains("\nHINT: Choose a different key."), "got: {s}");
579    }
580
581    #[test]
582    fn server_display_omits_missing_optional_fields() {
583        let err = Error::server(None, "syntax error at end of input", None, None);
584        let s = err.to_string();
585        assert_eq!(s, "server error: syntax error at end of input");
586    }
587
588    #[test]
589    fn from_client_error_query_does_not_duplicate_detail() {
590        // Build a client::Error with detail; client::Error::Display
591        // appends ": {detail}" inline. The flat-Error mapping must
592        // not also add "\nDETAIL: {detail}" — that would duplicate the
593        // text. We verify by counting occurrences.
594        let core = CoreError::new_with_details(
595            CoreKind::Query,
596            "duplicate key value",
597            Some("Key (id)=(42) already exists.".to_string()),
598            Some("Choose a different key.".to_string()),
599            Some("23505".to_string()),
600        );
601        let public: Error = core.into();
602        let s = public.to_string();
603        // The detail text should appear exactly once in the rendered
604        // string. (Once on the DETAIL line; not also inline in message.)
605        let count = s.matches("Key (id)=(42) already exists.").count();
606        assert_eq!(count, 1, "detail must appear exactly once; got: {s}");
607        let hint_count = s.matches("Choose a different key.").count();
608        assert_eq!(hint_count, 1, "hint must appear exactly once; got: {s}");
609        // Verify SQLSTATE is preserved.
610        assert_eq!(public.sqlstate(), Some("23505"));
611    }
612
613    #[test]
614    fn from_client_error_exhaustive_over_kinds() {
615        // Smoke test: every ErrorKind maps cleanly with no panic.
616        // (Compilation already enforces exhaustiveness.)
617        for kind in [
618            CoreKind::Connection,
619            CoreKind::Authentication,
620            CoreKind::Query,
621            CoreKind::Protocol,
622            CoreKind::Io,
623            CoreKind::Config,
624            CoreKind::Timeout,
625            CoreKind::Cancelled,
626            CoreKind::Closed,
627            CoreKind::Conversion,
628            CoreKind::FeatureNotSupported,
629            CoreKind::Other,
630        ] {
631            let core = CoreError::new(kind, "test message");
632            let public: Error = core.into();
633            // Each variant's Display must include the message text.
634            assert!(
635                public.to_string().contains("test message"),
636                "{kind:?} mapping lost the message: {public}",
637            );
638        }
639    }
640
641    #[test]
642    fn sqlstate_returns_some_for_server_connection_closed_cancelled() {
643        // Server still surfaces SQLSTATE.
644        let server = Error::server(Some("42P04".to_string()), "db exists", None, None);
645        assert_eq!(server.sqlstate(), Some("42P04"));
646
647        // Connection / Closed / Cancelled now surface SQLSTATE
648        // structurally (Follow-up C).
649        let conn = Error::connection_with_sqlstate("connect failed", "08006");
650        assert_eq!(conn.sqlstate(), Some("08006"));
651
652        let closed = Error::closed_with_sqlstate("admin shutdown", "57P01");
653        assert_eq!(closed.sqlstate(), Some("57P01"));
654
655        let cancelled = Error::cancelled_with_sqlstate("user cancel", "57014");
656        assert_eq!(cancelled.sqlstate(), Some("57014"));
657
658        // Variants without sqlstate field return None.
659        assert_eq!(Error::Conversion("...".into()).sqlstate(), None);
660        assert_eq!(
661            Error::Internal {
662                message: "...".into()
663            }
664            .sqlstate(),
665            None
666        );
667
668        // Cancelled with no SQLSTATE returns None too.
669        assert_eq!(Error::cancelled("user cancel").sqlstate(), None);
670    }
671
672    #[test]
673    fn column_display_formats_name_and_kind() {
674        let err = Error::column("user_id", ColumnErrorKind::Missing);
675        assert_eq!(err.to_string(), "column user_id: column not found");
676
677        let err = Error::column("score", ColumnErrorKind::Null);
678        assert_eq!(err.to_string(), "column score: unexpected NULL");
679
680        let err = Error::column(
681            "count",
682            ColumnErrorKind::TypeMismatch {
683                expected: "i32".into(),
684                actual: "TEXT".into(),
685            },
686        );
687        assert_eq!(
688            err.to_string(),
689            "column count: type mismatch: expected i32, got TEXT"
690        );
691    }
692
693    #[test]
694    fn column_index_out_of_bounds_display() {
695        let err = Error::column_index_out_of_bounds(5, 3);
696        assert_eq!(
697            err.to_string(),
698            "column index 5 out of bounds (row has 3 columns)"
699        );
700    }
701
702    #[test]
703    fn connection_display_with_typed_io_source() {
704        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
705        let err = Error::connection_with_io("connecting to hyperd", io_err);
706        let s = err.to_string();
707        // Top-level message is the prefixed form.
708        assert!(
709            s.contains("connection error: connecting to hyperd"),
710            "got: {s}"
711        );
712        // The typed source is recoverable via std::error::Error::source().
713        use std::error::Error as StdError;
714        let src = err.source().expect("connection_with_io must expose source");
715        let io_src: &std::io::Error = src
716            .downcast_ref::<std::io::Error>()
717            .expect("source must downcast to io::Error");
718        assert_eq!(io_src.kind(), std::io::ErrorKind::ConnectionRefused);
719    }
720
721    #[test]
722    fn internal_constructor_round_trip() {
723        let err = Error::internal("invariant violated");
724        assert_eq!(err.to_string(), "internal error: invariant violated");
725    }
726
727    #[test]
728    fn invalid_operation_constructor_round_trip() {
729        let err = Error::invalid_operation("cannot mix insert_data with insert_batch");
730        assert_eq!(
731            err.to_string(),
732            "invalid operation: cannot mix insert_data with insert_batch"
733        );
734        assert!(matches!(err, Error::InvalidOperation(_)));
735    }
736}