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`. The mapping is
479// exhaustive over `client::ErrorKind` (verified to NOT be
480// `#[non_exhaustive]`); adding a kind in `hyperdb-api-core` will break
481// this build until the mapping is updated, which is intended.
482//
483// `chain = err.to_string()` walks the inner error's full Display chain
484// (message + cause + detail). We use it for tuple variants whose
485// `Display` is just `"<prefix>: {0}"`, where embedding the chain into
486// the single string field gives the caller the full picture.
487//
488// For the `Server` variant we use the *un-chained* `message` and pass
489// `detail`/`hint` separately; the `Server` `Display` impl re-appends
490// "DETAIL: ..." and "HINT: ..." lines from those fields, so using
491// `chain` would duplicate the detail text.
492//
493// SQLSTATE: `client::Error::sqlstate()` may return `Some` for any
494// kind. After Follow-up C, the flat enum carries `sqlstate` on
495// `Server`, `Connection`, `Closed`, and `Cancelled` so callers can
496// match on it programmatically (e.g. SQLSTATE 57014 `query_canceled`
497// arrives via Cancelled and is now exposed structurally). Other
498// variants still drop SQLSTATE — folded into the message via `chain`.
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::ErrorKind as CoreKind;
502
503 let chain = err.to_string();
504 let kind = err.kind();
505 let sqlstate = err.sqlstate().map(str::to_string);
506 let detail = err.detail().map(str::to_string);
507 let hint = err.hint().map(str::to_string);
508 let message = err.message().to_string();
509
510 match kind {
511 CoreKind::Connection => Error::Connection {
512 message: chain,
513 source: None,
514 sqlstate,
515 },
516 CoreKind::Authentication => Error::Authentication(chain),
517 // Use unchained `message` here: detail/hint are passed as
518 // separate fields and the `Server` Display impl re-renders
519 // them. Using `chain` would duplicate detail text.
520 CoreKind::Query => Error::Server {
521 sqlstate,
522 message,
523 detail,
524 hint,
525 },
526 CoreKind::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 CoreKind::Io => Error::Connection {
531 message: chain,
532 source: None,
533 sqlstate,
534 },
535 CoreKind::Config => Error::Config(chain),
536 CoreKind::Timeout => Error::Timeout(chain),
537 CoreKind::Cancelled => Error::Cancelled {
538 message: chain,
539 sqlstate,
540 },
541 CoreKind::Closed => Error::Closed {
542 message: chain,
543 sqlstate,
544 },
545 CoreKind::Conversion => Error::Conversion(chain),
546 CoreKind::FeatureNotSupported => Error::FeatureNotSupported(chain),
547 CoreKind::Other => Error::Internal { message: chain },
548 }
549 }
550}
551
552// `Infallible` is the error type for identity `TryFrom`/`TryInto`
553// conversions. Generic APIs that take `T: TryInto<U>` and bound
554// `Error: From<T::Error>` (e.g. `TableDefinition::from_table_name`)
555// require this impl to compile when callers pass a value that is
556// already the target type. The body is unreachable because
557// `Infallible` has no values.
558impl From<std::convert::Infallible> for Error {
559 fn from(_: std::convert::Infallible) -> Self {
560 unreachable!("Infallible has no values")
561 }
562}
563
564/// Result type for Hyper API operations.
565pub type Result<T> = std::result::Result<T, Error>;
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use hyperdb_api_core::client::{Error as CoreError, ErrorKind as CoreKind};
571
572 #[test]
573 fn server_display_includes_sqlstate_detail_and_hint() {
574 let err = Error::server(
575 Some("23505".to_string()),
576 "duplicate key value violates unique constraint",
577 Some("Key (id)=(42) already exists.".to_string()),
578 Some("Choose a different key.".to_string()),
579 );
580 let s = err.to_string();
581 assert!(s.contains("server error (23505)"), "got: {s}");
582 assert!(
583 s.contains("duplicate key value violates unique constraint"),
584 "got: {s}"
585 );
586 assert!(
587 s.contains("\nDETAIL: Key (id)=(42) already exists."),
588 "got: {s}"
589 );
590 assert!(s.contains("\nHINT: Choose a different key."), "got: {s}");
591 }
592
593 #[test]
594 fn server_display_omits_missing_optional_fields() {
595 let err = Error::server(None, "syntax error at end of input", None, None);
596 let s = err.to_string();
597 assert_eq!(s, "server error: syntax error at end of input");
598 }
599
600 #[test]
601 fn from_client_error_query_does_not_duplicate_detail() {
602 // Build a client::Error with detail; client::Error::Display
603 // appends ": {detail}" inline. The flat-Error mapping must
604 // not also add "\nDETAIL: {detail}" — that would duplicate the
605 // text. We verify by counting occurrences.
606 let core = CoreError::new_with_details(
607 CoreKind::Query,
608 "duplicate key value",
609 Some("Key (id)=(42) already exists.".to_string()),
610 Some("Choose a different key.".to_string()),
611 Some("23505".to_string()),
612 );
613 let public: Error = core.into();
614 let s = public.to_string();
615 // The detail text should appear exactly once in the rendered
616 // string. (Once on the DETAIL line; not also inline in message.)
617 let count = s.matches("Key (id)=(42) already exists.").count();
618 assert_eq!(count, 1, "detail must appear exactly once; got: {s}");
619 let hint_count = s.matches("Choose a different key.").count();
620 assert_eq!(hint_count, 1, "hint must appear exactly once; got: {s}");
621 // Verify SQLSTATE is preserved.
622 assert_eq!(public.sqlstate(), Some("23505"));
623 }
624
625 #[test]
626 fn from_client_error_exhaustive_over_kinds() {
627 // Smoke test: every ErrorKind maps cleanly with no panic.
628 // (Compilation already enforces exhaustiveness.)
629 for kind in [
630 CoreKind::Connection,
631 CoreKind::Authentication,
632 CoreKind::Query,
633 CoreKind::Protocol,
634 CoreKind::Io,
635 CoreKind::Config,
636 CoreKind::Timeout,
637 CoreKind::Cancelled,
638 CoreKind::Closed,
639 CoreKind::Conversion,
640 CoreKind::FeatureNotSupported,
641 CoreKind::Other,
642 ] {
643 let core = CoreError::new(kind, "test message");
644 let public: Error = core.into();
645 // Each variant's Display must include the message text.
646 assert!(
647 public.to_string().contains("test message"),
648 "{kind:?} mapping lost the message: {public}",
649 );
650 }
651 }
652
653 #[test]
654 fn sqlstate_returns_some_for_server_connection_closed_cancelled() {
655 // Server still surfaces SQLSTATE.
656 let server = Error::server(Some("42P04".to_string()), "db exists", None, None);
657 assert_eq!(server.sqlstate(), Some("42P04"));
658
659 // Connection / Closed / Cancelled now surface SQLSTATE
660 // structurally (Follow-up C).
661 let conn = Error::connection_with_sqlstate("connect failed", "08006");
662 assert_eq!(conn.sqlstate(), Some("08006"));
663
664 let closed = Error::closed_with_sqlstate("admin shutdown", "57P01");
665 assert_eq!(closed.sqlstate(), Some("57P01"));
666
667 let cancelled = Error::cancelled_with_sqlstate("user cancel", "57014");
668 assert_eq!(cancelled.sqlstate(), Some("57014"));
669
670 // Variants without sqlstate field return None.
671 assert_eq!(Error::Conversion("...".into()).sqlstate(), None);
672 assert_eq!(
673 Error::Internal {
674 message: "...".into()
675 }
676 .sqlstate(),
677 None
678 );
679
680 // Cancelled with no SQLSTATE returns None too.
681 assert_eq!(Error::cancelled("user cancel").sqlstate(), None);
682 }
683
684 #[test]
685 fn column_display_formats_name_and_kind() {
686 let err = Error::column("user_id", ColumnErrorKind::Missing);
687 assert_eq!(err.to_string(), "column user_id: column not found");
688
689 let err = Error::column("score", ColumnErrorKind::Null);
690 assert_eq!(err.to_string(), "column score: unexpected NULL");
691
692 let err = Error::column(
693 "count",
694 ColumnErrorKind::TypeMismatch {
695 expected: "i32".into(),
696 actual: "TEXT".into(),
697 },
698 );
699 assert_eq!(
700 err.to_string(),
701 "column count: type mismatch: expected i32, got TEXT"
702 );
703 }
704
705 #[test]
706 fn column_index_out_of_bounds_display() {
707 let err = Error::column_index_out_of_bounds(5, 3);
708 assert_eq!(
709 err.to_string(),
710 "column index 5 out of bounds (row has 3 columns)"
711 );
712 }
713
714 #[test]
715 fn connection_display_with_typed_io_source() {
716 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
717 let err = Error::connection_with_io("connecting to hyperd", io_err);
718 let s = err.to_string();
719 // Top-level message is the prefixed form.
720 assert!(
721 s.contains("connection error: connecting to hyperd"),
722 "got: {s}"
723 );
724 // The typed source is recoverable via std::error::Error::source().
725 use std::error::Error as StdError;
726 let src = err.source().expect("connection_with_io must expose source");
727 let io_src: &std::io::Error = src
728 .downcast_ref::<std::io::Error>()
729 .expect("source must downcast to io::Error");
730 assert_eq!(io_src.kind(), std::io::ErrorKind::ConnectionRefused);
731 }
732
733 #[test]
734 fn internal_constructor_round_trip() {
735 let err = Error::internal("invariant violated");
736 assert_eq!(err.to_string(), "internal error: invariant violated");
737 }
738
739 #[test]
740 fn invalid_operation_constructor_round_trip() {
741 let err = Error::invalid_operation("cannot mix insert_data with insert_batch");
742 assert_eq!(
743 err.to_string(),
744 "invalid operation: cannot mix insert_data with insert_batch"
745 );
746 assert!(matches!(err, Error::InvalidOperation(_)));
747 }
748
749 #[test]
750 fn serialization_constructor_round_trip() {
751 let err = Error::serialization("expected value at line 1 column 1");
752 assert_eq!(
753 err.to_string(),
754 "serialization error: expected value at line 1 column 1"
755 );
756 assert!(matches!(err, Error::Serialization(_)));
757 }
758}