Skip to main content

bsql_core/
error.rs

1//! Error types for bsql.
2//!
3//! [`BsqlError`] is the single error type returned by all bsql operations.
4//! It has four variants matching the four failure modes of a database operation:
5//! pool, query execution, data decoding, and initial connection.
6
7use std::borrow::Cow;
8use std::fmt;
9
10/// The error type for all bsql operations.
11///
12/// # Variants
13///
14/// - [`Pool`](BsqlError::Pool) — connection pool exhausted or misconfigured.
15/// - [`Query`](BsqlError::Query) — PostgreSQL rejected the query at runtime
16///   (triggers, RLS policies, constraint violations).
17/// - [`Decode`](BsqlError::Decode) — a column value could not be converted to
18///   the expected Rust type.
19/// - [`Connect`](BsqlError::Connect) — initial connection to PostgreSQL failed.
20///
21/// # Example
22///
23/// ```rust,ignore
24/// use bsql::{Pool, BsqlError};
25///
26/// let pool = Pool::connect("postgres://user:pass@localhost/mydb").await?;
27///
28/// // Match on error variants for fine-grained handling
29/// let result = bsql::query!("INSERT INTO users (name) VALUES ($n: &str)")
30///     .run(&pool).await;
31/// match result {
32///     Ok(affected) => println!("inserted {affected}"),
33///     Err(e) if e.is_unique_violation() => println!("already exists"),
34///     Err(e) => return Err(e),
35/// }
36/// ```
37#[derive(Debug)]
38pub enum BsqlError {
39    Pool(PoolError),
40    Query(QueryError),
41    Decode(DecodeError),
42    Connect(ConnectError),
43}
44
45/// Connection pool failure.
46#[derive(Debug)]
47pub struct PoolError {
48    pub message: Cow<'static, str>,
49    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
50}
51
52/// Query execution failure. Contains the PostgreSQL error code when available.
53#[derive(Debug)]
54pub struct QueryError {
55    pub message: Cow<'static, str>,
56    /// The five-character SQLSTATE code (e.g. `"23505"` for unique violation).
57    pub pg_code: Option<Box<str>>,
58    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
59}
60
61/// Row/column decoding failure.
62#[derive(Debug)]
63pub struct DecodeError {
64    pub column: Cow<'static, str>,
65    pub expected: &'static str,
66    pub actual: Cow<'static, str>,
67    /// Optional underlying error that caused the decode failure.
68    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
69}
70
71/// Initial connection failure.
72#[derive(Debug)]
73pub struct ConnectError {
74    pub message: Cow<'static, str>,
75    pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
76}
77
78/// Convenience alias used throughout bsql.
79pub type BsqlResult<T> = Result<T, BsqlError>;
80
81// --- Display ---
82
83impl fmt::Display for BsqlError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Pool(e) => write!(f, "pool error: {e}"),
87            Self::Query(e) => write!(f, "query error: {e}"),
88            Self::Decode(e) => write!(f, "decode error: {e}"),
89            Self::Connect(e) => write!(f, "connect error: {e}"),
90        }
91    }
92}
93
94impl fmt::Display for PoolError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(&self.message)
97    }
98}
99
100impl fmt::Display for QueryError {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match &self.pg_code {
103            Some(code) => write!(f, "[{}] {}", &**code, self.message),
104            None => f.write_str(&self.message),
105        }
106    }
107}
108
109impl fmt::Display for DecodeError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(
112            f,
113            "column \"{}\": expected {}, got {}",
114            self.column, self.expected, self.actual
115        )
116    }
117}
118
119impl fmt::Display for ConnectError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(&self.message)
122    }
123}
124
125impl std::error::Error for BsqlError {
126    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
127        match self {
128            Self::Pool(e) => e.source(),
129            Self::Query(e) => e.source(),
130            Self::Decode(e) => e.source(),
131            Self::Connect(e) => e.source(),
132        }
133    }
134}
135
136impl std::error::Error for PoolError {
137    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
138        boxed_source(&self.source)
139    }
140}
141
142impl std::error::Error for QueryError {
143    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
144        boxed_source(&self.source)
145    }
146}
147
148impl std::error::Error for DecodeError {
149    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
150        boxed_source(&self.source)
151    }
152}
153
154impl std::error::Error for ConnectError {
155    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156        boxed_source(&self.source)
157    }
158}
159
160fn boxed_source(
161    src: &Option<Box<dyn std::error::Error + Send + Sync>>,
162) -> Option<&(dyn std::error::Error + 'static)> {
163    src.as_ref()
164        .map(|e| &**e as &(dyn std::error::Error + 'static))
165}
166
167// --- Query helpers ---
168
169impl BsqlError {
170    /// Whether this error is a PostgreSQL query cancellation / statement timeout
171    /// (SQLSTATE 57014).
172    pub fn is_timeout(&self) -> bool {
173        matches!(self, BsqlError::Query(q) if q.pg_code.as_deref() == Some("57014"))
174    }
175
176    /// Whether this error is a serialization failure (SQLSTATE 40001).
177    ///
178    /// When using `SERIALIZABLE` isolation, PostgreSQL may abort a transaction
179    /// with this code. The correct response is to retry the entire transaction.
180    pub fn is_serialization_failure(&self) -> bool {
181        matches!(self, BsqlError::Query(q) if q.pg_code.as_deref() == Some("40001"))
182    }
183
184    /// Whether this error is a unique constraint violation (SQLSTATE 23505).
185    ///
186    /// Common when inserting a row that would duplicate a unique index key.
187    /// The error message typically includes which constraint was violated.
188    pub fn is_unique_violation(&self) -> bool {
189        matches!(self, BsqlError::Query(q) if q.pg_code.as_deref() == Some("23505"))
190    }
191
192    /// Whether this error is a foreign key violation (SQLSTATE 23503).
193    ///
194    /// Raised when an INSERT or UPDATE references a row that does not exist
195    /// in the referenced table, or a DELETE would leave dangling references.
196    pub fn is_foreign_key_violation(&self) -> bool {
197        matches!(self, BsqlError::Query(q) if q.pg_code.as_deref() == Some("23503"))
198    }
199
200    /// Whether this error is a NOT NULL violation (SQLSTATE 23502).
201    ///
202    /// Raised when an INSERT or UPDATE sets a NOT NULL column to NULL.
203    pub fn is_not_null_violation(&self) -> bool {
204        matches!(self, BsqlError::Query(q) if q.pg_code.as_deref() == Some("23502"))
205    }
206
207    /// Whether this error is a check constraint violation (SQLSTATE 23514).
208    pub fn is_check_violation(&self) -> bool {
209        matches!(self, BsqlError::Query(q) if q.pg_code.as_deref() == Some("23514"))
210    }
211
212    /// Whether this error is a deadlock (SQLSTATE 40P01).
213    ///
214    /// PostgreSQL detected a deadlock between two or more transactions and
215    /// chose this one as the victim. The correct response is to retry.
216    pub fn is_deadlock(&self) -> bool {
217        matches!(self, BsqlError::Query(q) if q.pg_code.as_deref() == Some("40P01"))
218    }
219
220    /// The PostgreSQL SQLSTATE code, if this is a query error with a code.
221    ///
222    /// Returns `None` for non-query errors or query errors without a code
223    /// (e.g., I/O errors during query execution).
224    ///
225    /// # Example
226    ///
227    /// ```rust,ignore
228    /// match err.pg_code() {
229    ///     Some("23505") => println!("unique violation"),
230    ///     Some("23503") => println!("foreign key violation"),
231    ///     _ => {}
232    /// }
233    /// ```
234    pub fn pg_code(&self) -> Option<&str> {
235        match self {
236            BsqlError::Query(q) => q.pg_code.as_deref(),
237            _ => None,
238        }
239    }
240
241    /// Convert a `DriverError` that occurred during query execution.
242    ///
243    /// Unlike the blanket `From<DriverError>` impl (which maps `Io` to `Connect`),
244    /// this maps `Io` errors to `Query` — because a network failure mid-query is
245    /// a query error, not a connection error.
246    pub fn from_driver_query(e: bsql_driver_postgres::DriverError) -> Self {
247        match e {
248            bsql_driver_postgres::DriverError::Io(io_err) => BsqlError::Query(QueryError {
249                message: Cow::Owned(format!("I/O error during query: {io_err}")),
250                pg_code: None,
251                source: Some(Box::new(io_err)),
252            }),
253            other => BsqlError::from(other),
254        }
255    }
256}
257
258// --- From conversions ---
259
260impl From<bsql_driver_postgres::DriverError> for BsqlError {
261    fn from(e: bsql_driver_postgres::DriverError) -> Self {
262        match e {
263            bsql_driver_postgres::DriverError::Io(io_err) => BsqlError::Connect(ConnectError {
264                message: Cow::Owned(io_err.to_string()),
265                source: Some(Box::new(io_err)),
266            }),
267            bsql_driver_postgres::DriverError::Auth(msg) => BsqlError::Connect(ConnectError {
268                message: Cow::Owned(msg),
269                source: None,
270            }),
271            bsql_driver_postgres::DriverError::Protocol(msg) => BsqlError::Query(QueryError {
272                message: Cow::Owned(msg),
273                pg_code: None,
274                source: None,
275            }),
276            bsql_driver_postgres::DriverError::Server {
277                code,
278                message,
279                detail,
280                hint,
281                position,
282            } => {
283                let msg = {
284                    let has_extras = position.is_some() || detail.is_some() || hint.is_some();
285                    if has_extras {
286                        let mut s = String::from(&*message);
287                        if let Some(pos) = position {
288                            use std::fmt::Write;
289                            let _ = write!(s, " (at position {pos})");
290                        }
291                        if let Some(d) = &detail {
292                            s.push_str("\n  detail: ");
293                            s.push_str(d);
294                        }
295                        if let Some(h) = &hint {
296                            s.push_str("\n  hint: ");
297                            s.push_str(h);
298                        }
299                        Cow::Owned(s)
300                    } else {
301                        Cow::Owned(String::from(message))
302                    }
303                };
304                BsqlError::Query(QueryError {
305                    message: msg,
306                    pg_code: Some(code),
307                    source: None,
308                })
309            }
310            bsql_driver_postgres::DriverError::Pool(msg) => BsqlError::Pool(PoolError {
311                message: Cow::Owned(msg),
312                source: None,
313            }),
314        }
315    }
316}
317
318// --- SQLite error conversion ---
319
320#[cfg(feature = "sqlite")]
321impl BsqlError {
322    /// Convert a SQLite driver error into a `BsqlError`.
323    pub fn from_sqlite(e: bsql_driver_sqlite::SqliteError) -> Self {
324        match e {
325            bsql_driver_sqlite::SqliteError::Sqlite { code, message } => {
326                BsqlError::Query(QueryError {
327                    message: Cow::Owned(format!("SQLite error [{code}]: {message}")),
328                    pg_code: None,
329                    source: None,
330                })
331            }
332            bsql_driver_sqlite::SqliteError::Io(io_err) => BsqlError::Connect(ConnectError {
333                message: Cow::Owned(format!("SQLite I/O error: {io_err}")),
334                source: Some(Box::new(io_err)),
335            }),
336            bsql_driver_sqlite::SqliteError::Internal(msg) => BsqlError::Query(QueryError {
337                message: Cow::Owned(format!("SQLite internal error: {msg}")),
338                pg_code: None,
339                source: None,
340            }),
341            bsql_driver_sqlite::SqliteError::Pool(msg) => BsqlError::Pool(PoolError {
342                message: Cow::Owned(format!("SQLite pool error: {msg}")),
343                source: None,
344            }),
345        }
346    }
347}
348
349// --- Constructor helpers ---
350
351impl PoolError {
352    pub fn exhausted() -> BsqlError {
353        BsqlError::Pool(PoolError {
354            message: Cow::Borrowed("pool exhausted: all connections in use"),
355            source: None,
356        })
357    }
358}
359
360impl ConnectError {
361    pub fn create(msg: impl Into<String>) -> BsqlError {
362        BsqlError::Connect(ConnectError {
363            message: Cow::Owned(msg.into()),
364            source: None,
365        })
366    }
367
368    pub fn with_source(
369        msg: impl Into<String>,
370        source: impl std::error::Error + Send + Sync + 'static,
371    ) -> BsqlError {
372        BsqlError::Connect(ConnectError {
373            message: Cow::Owned(msg.into()),
374            source: Some(Box::new(source)),
375        })
376    }
377}
378
379impl QueryError {
380    pub fn row_count(expected: &str, actual: u64) -> BsqlError {
381        BsqlError::Query(QueryError {
382            message: Cow::Owned(format!("expected {expected}, got {actual} rows")),
383            pg_code: None,
384            source: None,
385        })
386    }
387}
388
389impl DecodeError {
390    /// Create a decode error with an underlying cause.
391    pub fn with_source(
392        column: impl Into<Cow<'static, str>>,
393        expected: &'static str,
394        actual: impl Into<Cow<'static, str>>,
395        source: impl std::error::Error + Send + Sync + 'static,
396    ) -> BsqlError {
397        BsqlError::Decode(DecodeError {
398            column: column.into(),
399            expected,
400            actual: actual.into(),
401            source: Some(Box::new(source)),
402        })
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use std::error::Error as _;
410
411    #[test]
412    fn pool_error_display() {
413        let e = PoolError::exhausted();
414        assert_eq!(
415            e.to_string(),
416            "pool error: pool exhausted: all connections in use"
417        );
418    }
419
420    #[test]
421    fn query_error_with_code_display() {
422        let e = BsqlError::Query(QueryError {
423            message: Cow::Borrowed("duplicate key"),
424            pg_code: Some(Box::from("23505")),
425            source: None,
426        });
427        assert_eq!(e.to_string(), "query error: [23505] duplicate key");
428    }
429
430    #[test]
431    fn query_error_without_code_display() {
432        let e = QueryError::row_count("exactly 1 row", 0);
433        assert_eq!(
434            e.to_string(),
435            "query error: expected exactly 1 row, got 0 rows"
436        );
437    }
438
439    #[test]
440    fn decode_error_display() {
441        let e = BsqlError::Decode(DecodeError {
442            column: Cow::Borrowed("age"),
443            expected: "i32",
444            actual: Cow::Borrowed("text"),
445            source: None,
446        });
447        assert_eq!(
448            e.to_string(),
449            "decode error: column \"age\": expected i32, got text"
450        );
451    }
452
453    #[test]
454    fn connect_error_display() {
455        let e = ConnectError::create("connection refused");
456        assert_eq!(e.to_string(), "connect error: connection refused");
457    }
458
459    #[test]
460    fn pool_exhausted_uses_borrowed_cow() {
461        let e = PoolError::exhausted();
462        match e {
463            BsqlError::Pool(ref pe) => {
464                assert!(
465                    matches!(pe.message, Cow::Borrowed(_)),
466                    "exhausted() should use Cow::Borrowed for zero-alloc"
467                );
468            }
469            _ => panic!("expected Pool variant"),
470        }
471    }
472
473    #[test]
474    fn connect_error_uses_owned_cow() {
475        let e = ConnectError::create("dynamic message");
476        match e {
477            BsqlError::Connect(ref ce) => {
478                assert!(
479                    matches!(ce.message, Cow::Owned(_)),
480                    "create() with dynamic msg should use Cow::Owned"
481                );
482            }
483            _ => panic!("expected Connect variant"),
484        }
485    }
486
487    #[test]
488    fn query_row_count_uses_owned_cow() {
489        let e = QueryError::row_count("exactly 1 row", 5);
490        match e {
491            BsqlError::Query(ref qe) => {
492                assert!(
493                    matches!(qe.message, Cow::Owned(_)),
494                    "row_count() with formatted msg should use Cow::Owned"
495                );
496            }
497            _ => panic!("expected Query variant"),
498        }
499    }
500
501    #[test]
502    fn pool_error_source_chain() {
503        let e = PoolError::exhausted();
504        // exhausted() has no source
505        assert!(e.source().is_none());
506    }
507
508    #[test]
509    fn connect_error_with_source_chain() {
510        let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
511        let e = ConnectError::with_source("connection failed", inner);
512        assert!(e.source().is_some());
513    }
514
515    #[test]
516    fn server_error_preserves_detail_and_hint() {
517        let driver_err = bsql_driver_postgres::DriverError::Server {
518            code: "23505".into(),
519            message: "duplicate key".into(),
520            detail: Some("Key (login)=(alice) already exists.".into()),
521            hint: Some("Use ON CONFLICT to handle duplicates.".into()),
522            position: None,
523        };
524        let e = BsqlError::from(driver_err);
525        let display = e.to_string();
526        assert!(
527            display.contains("duplicate key"),
528            "missing message: {display}"
529        );
530        assert!(
531            display.contains("detail: Key (login)=(alice) already exists."),
532            "missing detail: {display}"
533        );
534        assert!(
535            display.contains("hint: Use ON CONFLICT to handle duplicates."),
536            "missing hint: {display}"
537        );
538        // pg_code should be preserved
539        match &e {
540            BsqlError::Query(qe) => assert_eq!(qe.pg_code.as_deref(), Some("23505")),
541            other => panic!("expected Query, got: {other:?}"),
542        }
543    }
544
545    #[test]
546    fn server_error_without_detail_hint() {
547        let driver_err = bsql_driver_postgres::DriverError::Server {
548            code: "42P01".into(),
549            message: "relation does not exist".into(),
550            detail: None,
551            hint: None,
552            position: None,
553        };
554        let e = BsqlError::from(driver_err);
555        let display = e.to_string();
556        assert!(
557            display.contains("relation does not exist"),
558            "missing message: {display}"
559        );
560        assert!(
561            !display.contains("detail:"),
562            "should not contain detail: {display}"
563        );
564        assert!(
565            !display.contains("hint:"),
566            "should not contain hint: {display}"
567        );
568    }
569
570    #[test]
571    fn decode_error_has_no_source() {
572        let e = BsqlError::Decode(DecodeError {
573            column: Cow::Borrowed("col"),
574            expected: "i32",
575            actual: Cow::Borrowed("text"),
576            source: None,
577        });
578        assert!(e.source().is_none());
579    }
580
581    #[test]
582    fn decode_error_with_source_chain() {
583        let inner = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad utf-8");
584        let e = DecodeError::with_source("name", "String", "invalid bytes", inner);
585        assert!(e.source().is_some());
586        match &e {
587            BsqlError::Decode(d) => {
588                assert_eq!(d.column, "name");
589                assert_eq!(d.expected, "String");
590            }
591            other => panic!("expected Decode, got: {other:?}"),
592        }
593    }
594
595    #[test]
596    fn is_timeout_true_for_57014() {
597        let e = BsqlError::Query(QueryError {
598            message: Cow::Borrowed("canceling statement due to statement timeout"),
599            pg_code: Some(Box::from("57014")),
600            source: None,
601        });
602        assert!(e.is_timeout());
603    }
604
605    #[test]
606    fn is_timeout_false_for_other_codes() {
607        let e = BsqlError::Query(QueryError {
608            message: Cow::Borrowed("unique violation"),
609            pg_code: Some(Box::from("23505")),
610            source: None,
611        });
612        assert!(!e.is_timeout());
613    }
614
615    #[test]
616    fn is_timeout_false_for_non_query() {
617        let e = PoolError::exhausted();
618        assert!(!e.is_timeout());
619    }
620
621    #[test]
622    fn is_serialization_failure_true_for_40001() {
623        let e = BsqlError::Query(QueryError {
624            message: Cow::Borrowed("could not serialize access"),
625            pg_code: Some(Box::from("40001")),
626            source: None,
627        });
628        assert!(e.is_serialization_failure());
629    }
630
631    #[test]
632    fn is_serialization_failure_false_for_other_codes() {
633        let e = BsqlError::Query(QueryError {
634            message: Cow::Borrowed("timeout"),
635            pg_code: Some(Box::from("57014")),
636            source: None,
637        });
638        assert!(!e.is_serialization_failure());
639    }
640
641    #[test]
642    fn from_driver_query_maps_io_to_query() {
643        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broke");
644        let e = BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Io(io_err));
645        match &e {
646            BsqlError::Query(q) => {
647                assert!(q.message.contains("I/O error during query"));
648                assert!(q.source.is_some());
649            }
650            other => panic!("expected Query, got: {other:?}"),
651        }
652    }
653
654    #[test]
655    fn from_driver_query_non_io_delegates_to_from() {
656        let e =
657            BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Pool("test".into()));
658        assert!(matches!(e, BsqlError::Pool(_)));
659    }
660
661    // --- is_unique_violation ---
662
663    #[test]
664    fn is_unique_violation_true_for_23505() {
665        let e = BsqlError::Query(QueryError {
666            message: Cow::Borrowed("duplicate key value violates unique constraint"),
667            pg_code: Some(Box::from("23505")),
668            source: None,
669        });
670        assert!(e.is_unique_violation());
671    }
672
673    #[test]
674    fn is_unique_violation_false_for_other_codes() {
675        let e = BsqlError::Query(QueryError {
676            message: Cow::Borrowed("timeout"),
677            pg_code: Some(Box::from("57014")),
678            source: None,
679        });
680        assert!(!e.is_unique_violation());
681    }
682
683    #[test]
684    fn is_unique_violation_false_for_non_query() {
685        let e = PoolError::exhausted();
686        assert!(!e.is_unique_violation());
687    }
688
689    // --- is_foreign_key_violation ---
690
691    #[test]
692    fn is_foreign_key_violation_true_for_23503() {
693        let e = BsqlError::Query(QueryError {
694            message: Cow::Borrowed("insert or update violates foreign key constraint"),
695            pg_code: Some(Box::from("23503")),
696            source: None,
697        });
698        assert!(e.is_foreign_key_violation());
699    }
700
701    #[test]
702    fn is_foreign_key_violation_false_for_other_codes() {
703        let e = BsqlError::Query(QueryError {
704            message: Cow::Borrowed("unique"),
705            pg_code: Some(Box::from("23505")),
706            source: None,
707        });
708        assert!(!e.is_foreign_key_violation());
709    }
710
711    #[test]
712    fn is_foreign_key_violation_false_for_non_query() {
713        let e = ConnectError::create("down");
714        assert!(!e.is_foreign_key_violation());
715    }
716
717    // --- is_not_null_violation ---
718
719    #[test]
720    fn is_not_null_violation_true_for_23502() {
721        let e = BsqlError::Query(QueryError {
722            message: Cow::Borrowed("null value in column \"name\" violates not-null constraint"),
723            pg_code: Some(Box::from("23502")),
724            source: None,
725        });
726        assert!(e.is_not_null_violation());
727    }
728
729    #[test]
730    fn is_not_null_violation_false_for_other_codes() {
731        let e = BsqlError::Query(QueryError {
732            message: Cow::Borrowed("unique"),
733            pg_code: Some(Box::from("23505")),
734            source: None,
735        });
736        assert!(!e.is_not_null_violation());
737    }
738
739    // --- is_check_violation ---
740
741    #[test]
742    fn is_check_violation_true_for_23514() {
743        let e = BsqlError::Query(QueryError {
744            message: Cow::Borrowed("new row violates check constraint"),
745            pg_code: Some(Box::from("23514")),
746            source: None,
747        });
748        assert!(e.is_check_violation());
749    }
750
751    #[test]
752    fn is_check_violation_false_for_other_codes() {
753        let e = BsqlError::Query(QueryError {
754            message: Cow::Borrowed("unique"),
755            pg_code: Some(Box::from("23505")),
756            source: None,
757        });
758        assert!(!e.is_check_violation());
759    }
760
761    // --- is_deadlock ---
762
763    #[test]
764    fn is_deadlock_true_for_40p01() {
765        let e = BsqlError::Query(QueryError {
766            message: Cow::Borrowed("deadlock detected"),
767            pg_code: Some(Box::from("40P01")),
768            source: None,
769        });
770        assert!(e.is_deadlock());
771    }
772
773    #[test]
774    fn is_deadlock_false_for_other_codes() {
775        let e = BsqlError::Query(QueryError {
776            message: Cow::Borrowed("serialization"),
777            pg_code: Some(Box::from("40001")),
778            source: None,
779        });
780        assert!(!e.is_deadlock());
781    }
782
783    #[test]
784    fn is_deadlock_false_for_non_query() {
785        let e = PoolError::exhausted();
786        assert!(!e.is_deadlock());
787    }
788
789    // --- pg_code ---
790
791    #[test]
792    fn pg_code_returns_code_for_query_error() {
793        let e = BsqlError::Query(QueryError {
794            message: Cow::Borrowed("duplicate key"),
795            pg_code: Some(Box::from("23505")),
796            source: None,
797        });
798        assert_eq!(e.pg_code(), Some("23505"));
799    }
800
801    #[test]
802    fn pg_code_returns_none_for_query_without_code() {
803        let e = BsqlError::Query(QueryError {
804            message: Cow::Borrowed("I/O error"),
805            pg_code: None,
806            source: None,
807        });
808        assert_eq!(e.pg_code(), None);
809    }
810
811    #[test]
812    fn pg_code_returns_none_for_pool_error() {
813        let e = PoolError::exhausted();
814        assert_eq!(e.pg_code(), None);
815    }
816
817    #[test]
818    fn pg_code_returns_none_for_connect_error() {
819        let e = ConnectError::create("refused");
820        assert_eq!(e.pg_code(), None);
821    }
822
823    #[test]
824    fn pg_code_returns_none_for_decode_error() {
825        let e = BsqlError::Decode(DecodeError {
826            column: Cow::Borrowed("col"),
827            expected: "i32",
828            actual: Cow::Borrowed("text"),
829            source: None,
830        });
831        assert_eq!(e.pg_code(), None);
832    }
833
834    // --- Server error with position ---
835
836    #[test]
837    fn server_error_with_position_display() {
838        let driver_err = bsql_driver_postgres::DriverError::Server {
839            code: "42601".into(),
840            message: "syntax error".into(),
841            detail: None,
842            hint: None,
843            position: Some(8),
844        };
845        let e = BsqlError::from(driver_err);
846        let display = e.to_string();
847        assert!(
848            display.contains("at position 8"),
849            "should contain position: {display}"
850        );
851        assert!(
852            display.contains("syntax error"),
853            "should contain message: {display}"
854        );
855    }
856
857    #[test]
858    fn server_error_with_all_fields() {
859        let driver_err = bsql_driver_postgres::DriverError::Server {
860            code: "42P01".into(),
861            message: "relation does not exist".into(),
862            detail: Some("table was dropped".into()),
863            hint: Some("recreate the table".into()),
864            position: Some(42),
865        };
866        let e = BsqlError::from(driver_err);
867        let display = e.to_string();
868        assert!(display.contains("at position 42"));
869        assert!(display.contains("detail: table was dropped"));
870        assert!(display.contains("hint: recreate the table"));
871        assert!(display.contains("relation does not exist"));
872    }
873
874    // --- from_driver_query with Server variant delegates to From ---
875
876    #[test]
877    fn from_driver_query_server_error_delegates() {
878        let e = BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Server {
879            code: "23505".into(),
880            message: "duplicate key".into(),
881            detail: None,
882            hint: None,
883            position: None,
884        });
885        assert!(matches!(e, BsqlError::Query(_)));
886        assert_eq!(e.pg_code(), Some("23505"));
887    }
888
889    // --- From<DriverError> for all variants ---
890
891    #[test]
892    fn from_driver_io_maps_to_connect() {
893        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
894        let e = BsqlError::from(bsql_driver_postgres::DriverError::Io(io_err));
895        assert!(matches!(e, BsqlError::Connect(_)));
896        assert!(e.source().is_some());
897    }
898
899    #[test]
900    fn from_driver_auth_maps_to_connect() {
901        let e = BsqlError::from(bsql_driver_postgres::DriverError::Auth(
902            "wrong password".into(),
903        ));
904        match &e {
905            BsqlError::Connect(ce) => {
906                assert!(ce.message.contains("wrong password"));
907            }
908            other => panic!("expected Connect, got: {other:?}"),
909        }
910    }
911
912    #[test]
913    fn from_driver_protocol_maps_to_query() {
914        let e = BsqlError::from(bsql_driver_postgres::DriverError::Protocol(
915            "unexpected message".into(),
916        ));
917        match &e {
918            BsqlError::Query(qe) => {
919                assert!(qe.message.contains("unexpected message"));
920                assert!(qe.pg_code.is_none());
921            }
922            other => panic!("expected Query, got: {other:?}"),
923        }
924    }
925
926    // --- SQLite error conversion ---
927
928    #[cfg(feature = "sqlite")]
929    mod sqlite_tests {
930        use super::*;
931
932        #[test]
933        fn from_sqlite_sqlite_error() {
934            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Sqlite {
935                code: 19,
936                message: "UNIQUE constraint failed".into(),
937            });
938            match &e {
939                BsqlError::Query(qe) => {
940                    assert!(qe.message.contains("SQLite error [19]"));
941                    assert!(qe.message.contains("UNIQUE constraint failed"));
942                    assert!(qe.pg_code.is_none());
943                }
944                other => panic!("expected Query, got: {other:?}"),
945            }
946        }
947
948        #[test]
949        fn from_sqlite_io_error() {
950            let io_err =
951                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "read-only filesystem");
952            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Io(io_err));
953            match &e {
954                BsqlError::Connect(ce) => {
955                    assert!(ce.message.contains("SQLite I/O error"));
956                    assert!(ce.message.contains("read-only filesystem"));
957                    assert!(ce.source.is_some());
958                }
959                other => panic!("expected Connect, got: {other:?}"),
960            }
961        }
962
963        #[test]
964        fn from_sqlite_internal_error() {
965            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Internal(
966                "corrupted database".into(),
967            ));
968            match &e {
969                BsqlError::Query(qe) => {
970                    assert!(qe.message.contains("SQLite internal error"));
971                    assert!(qe.message.contains("corrupted database"));
972                }
973                other => panic!("expected Query, got: {other:?}"),
974            }
975        }
976
977        #[test]
978        fn from_sqlite_pool_error() {
979            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Pool(
980                "no readers available".into(),
981            ));
982            match &e {
983                BsqlError::Pool(pe) => {
984                    assert!(pe.message.contains("SQLite pool error"));
985                    assert!(pe.message.contains("no readers available"));
986                }
987                other => panic!("expected Pool, got: {other:?}"),
988            }
989        }
990    }
991
992    // --- Send + Sync assertions ---
993
994    fn _assert_send<T: Send>() {}
995    fn _assert_sync<T: Sync>() {}
996
997    #[test]
998    fn bsql_error_is_send() {
999        _assert_send::<BsqlError>();
1000    }
1001
1002    #[test]
1003    fn bsql_error_is_sync() {
1004        _assert_sync::<BsqlError>();
1005    }
1006
1007    // --- Gap: BsqlError Display for Connect variant includes message ---
1008
1009    #[test]
1010    fn bsql_error_display_connect_includes_message() {
1011        let e = ConnectError::create("tcp connection refused at 127.0.0.1:5432");
1012        let display = e.to_string();
1013        assert!(
1014            display.contains("connect error:"),
1015            "should start with 'connect error:': {display}"
1016        );
1017        assert!(
1018            display.contains("tcp connection refused at 127.0.0.1:5432"),
1019            "should include the message: {display}"
1020        );
1021    }
1022
1023    // --- Gap: QueryError Display with pg_code ---
1024
1025    #[test]
1026    fn bsql_error_display_query_includes_pg_code() {
1027        let e = BsqlError::Query(QueryError {
1028            message: Cow::Borrowed("relation \"users\" does not exist"),
1029            pg_code: Some(Box::from("42P01")),
1030            source: None,
1031        });
1032        let display = e.to_string();
1033        assert!(
1034            display.contains("42P01"),
1035            "should include pg_code: {display}"
1036        );
1037        assert!(
1038            display.contains("relation \"users\" does not exist"),
1039            "should include message: {display}"
1040        );
1041    }
1042
1043    // --- Gap: QueryError Display without pg_code ---
1044
1045    #[test]
1046    fn bsql_error_display_query_no_code() {
1047        let e = BsqlError::Query(QueryError {
1048            message: Cow::Borrowed("I/O error during query"),
1049            pg_code: None,
1050            source: None,
1051        });
1052        let display = e.to_string();
1053        assert!(
1054            display.contains("I/O error during query"),
1055            "should include message: {display}"
1056        );
1057        assert!(
1058            !display.contains('['),
1059            "should not contain brackets without code: {display}"
1060        );
1061    }
1062
1063    // --- Gap: QueryError::row_count produces expected message ---
1064
1065    #[test]
1066    fn query_error_row_count_message() {
1067        let e = QueryError::row_count("exactly 1 row", 5);
1068        let display = e.to_string();
1069        assert!(
1070            display.contains("expected exactly 1 row, got 5 rows"),
1071            "row_count message: {display}"
1072        );
1073    }
1074
1075    #[test]
1076    fn query_error_row_count_zero() {
1077        let e = QueryError::row_count("at least 1 row", 0);
1078        let display = e.to_string();
1079        assert!(
1080            display.contains("expected at least 1 row, got 0 rows"),
1081            "row_count zero: {display}"
1082        );
1083    }
1084
1085    // --- Gap: DecodeError::with_source preserves all fields ---
1086
1087    #[test]
1088    fn decode_error_with_source_preserves_fields() {
1089        let inner = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad utf-8");
1090        let e = DecodeError::with_source("email", "String", "bytes", inner);
1091        let display = e.to_string();
1092        assert!(
1093            display.contains("email"),
1094            "should contain column: {display}"
1095        );
1096        assert!(
1097            display.contains("String"),
1098            "should contain expected type: {display}"
1099        );
1100        assert!(
1101            display.contains("bytes"),
1102            "should contain actual type: {display}"
1103        );
1104    }
1105
1106    // --- Gap: PoolError Display ---
1107
1108    #[test]
1109    fn pool_error_display_custom_message() {
1110        let e = BsqlError::Pool(PoolError {
1111            message: Cow::Owned("all 10 connections in use".to_owned()),
1112            source: None,
1113        });
1114        let display = e.to_string();
1115        assert!(
1116            display.contains("pool error: all 10 connections in use"),
1117            "pool error display: {display}"
1118        );
1119    }
1120
1121    // --- Gap: ConnectError::with_source preserves source ---
1122
1123    #[test]
1124    fn connect_error_with_source_display() {
1125        let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1126        let e = ConnectError::with_source("failed to connect to PG", inner);
1127        let display = e.to_string();
1128        assert!(
1129            display.contains("failed to connect to PG"),
1130            "should include msg: {display}"
1131        );
1132        assert!(e.source().is_some());
1133    }
1134}