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///     .execute(&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 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(Box::from(std::str::from_utf8(&code).unwrap_or("?????"))),
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    /// Create a decode error for a column count mismatch.
406    ///
407    /// Used by generated code to detect schema drift between compile-time
408    /// and runtime (e.g. a column was added/removed after the binary was built).
409    pub fn column_count(expected: usize, actual: usize) -> BsqlError {
410        BsqlError::Decode(DecodeError {
411            column: Cow::Borrowed("*"),
412            expected: "matching column count",
413            actual: Cow::Owned(format!(
414                "expected {} columns but row has {}",
415                expected, actual
416            )),
417            source: None,
418        })
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::error::Error as _;
426
427    #[test]
428    fn pool_error_display() {
429        let e = PoolError::exhausted();
430        assert_eq!(
431            e.to_string(),
432            "pool error: pool exhausted: all connections in use"
433        );
434    }
435
436    #[test]
437    fn query_error_with_code_display() {
438        let e = BsqlError::Query(QueryError {
439            message: Cow::Borrowed("duplicate key"),
440            pg_code: Some(Box::from("23505")),
441            source: None,
442        });
443        assert_eq!(e.to_string(), "query error: [23505] duplicate key");
444    }
445
446    #[test]
447    fn query_error_without_code_display() {
448        let e = QueryError::row_count("exactly 1 row", 0);
449        assert_eq!(
450            e.to_string(),
451            "query error: expected exactly 1 row, got 0 rows"
452        );
453    }
454
455    #[test]
456    fn decode_error_display() {
457        let e = BsqlError::Decode(DecodeError {
458            column: Cow::Borrowed("age"),
459            expected: "i32",
460            actual: Cow::Borrowed("text"),
461            source: None,
462        });
463        assert_eq!(
464            e.to_string(),
465            "decode error: column \"age\": expected i32, got text"
466        );
467    }
468
469    #[test]
470    fn connect_error_display() {
471        let e = ConnectError::create("connection refused");
472        assert_eq!(e.to_string(), "connect error: connection refused");
473    }
474
475    #[test]
476    fn pool_exhausted_uses_borrowed_cow() {
477        let e = PoolError::exhausted();
478        match e {
479            BsqlError::Pool(ref pe) => {
480                assert!(
481                    matches!(pe.message, Cow::Borrowed(_)),
482                    "exhausted() should use Cow::Borrowed for zero-alloc"
483                );
484            }
485            _ => panic!("expected Pool variant"),
486        }
487    }
488
489    #[test]
490    fn connect_error_uses_owned_cow() {
491        let e = ConnectError::create("dynamic message");
492        match e {
493            BsqlError::Connect(ref ce) => {
494                assert!(
495                    matches!(ce.message, Cow::Owned(_)),
496                    "create() with dynamic msg should use Cow::Owned"
497                );
498            }
499            _ => panic!("expected Connect variant"),
500        }
501    }
502
503    #[test]
504    fn query_row_count_uses_owned_cow() {
505        let e = QueryError::row_count("exactly 1 row", 5);
506        match e {
507            BsqlError::Query(ref qe) => {
508                assert!(
509                    matches!(qe.message, Cow::Owned(_)),
510                    "row_count() with formatted msg should use Cow::Owned"
511                );
512            }
513            _ => panic!("expected Query variant"),
514        }
515    }
516
517    #[test]
518    fn pool_error_source_chain() {
519        let e = PoolError::exhausted();
520        // exhausted() has no source
521        assert!(e.source().is_none());
522    }
523
524    #[test]
525    fn connect_error_with_source_chain() {
526        let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
527        let e = ConnectError::with_source("connection failed", inner);
528        assert!(e.source().is_some());
529    }
530
531    #[test]
532    fn server_error_preserves_detail_and_hint() {
533        let driver_err = bsql_driver_postgres::DriverError::Server {
534            code: *b"23505",
535            message: "duplicate key".into(),
536            detail: Some("Key (login)=(alice) already exists.".into()),
537            hint: Some("Use ON CONFLICT to handle duplicates.".into()),
538            position: None,
539        };
540        let e = BsqlError::from(driver_err);
541        let display = e.to_string();
542        assert!(
543            display.contains("duplicate key"),
544            "missing message: {display}"
545        );
546        assert!(
547            display.contains("detail: Key (login)=(alice) already exists."),
548            "missing detail: {display}"
549        );
550        assert!(
551            display.contains("hint: Use ON CONFLICT to handle duplicates."),
552            "missing hint: {display}"
553        );
554        // pg_code should be preserved
555        match &e {
556            BsqlError::Query(qe) => assert_eq!(qe.pg_code.as_deref(), Some("23505")),
557            other => panic!("expected Query, got: {other:?}"),
558        }
559    }
560
561    #[test]
562    fn server_error_without_detail_hint() {
563        let driver_err = bsql_driver_postgres::DriverError::Server {
564            code: *b"42P01",
565            message: "relation does not exist".into(),
566            detail: None,
567            hint: None,
568            position: None,
569        };
570        let e = BsqlError::from(driver_err);
571        let display = e.to_string();
572        assert!(
573            display.contains("relation does not exist"),
574            "missing message: {display}"
575        );
576        assert!(
577            !display.contains("detail:"),
578            "should not contain detail: {display}"
579        );
580        assert!(
581            !display.contains("hint:"),
582            "should not contain hint: {display}"
583        );
584    }
585
586    #[test]
587    fn decode_error_has_no_source() {
588        let e = BsqlError::Decode(DecodeError {
589            column: Cow::Borrowed("col"),
590            expected: "i32",
591            actual: Cow::Borrowed("text"),
592            source: None,
593        });
594        assert!(e.source().is_none());
595    }
596
597    #[test]
598    fn decode_error_with_source_chain() {
599        let inner = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad utf-8");
600        let e = DecodeError::with_source("name", "String", "invalid bytes", inner);
601        assert!(e.source().is_some());
602        match &e {
603            BsqlError::Decode(d) => {
604                assert_eq!(d.column, "name");
605                assert_eq!(d.expected, "String");
606            }
607            other => panic!("expected Decode, got: {other:?}"),
608        }
609    }
610
611    #[test]
612    fn is_timeout_true_for_57014() {
613        let e = BsqlError::Query(QueryError {
614            message: Cow::Borrowed("canceling statement due to statement timeout"),
615            pg_code: Some(Box::from("57014")),
616            source: None,
617        });
618        assert!(e.is_timeout());
619    }
620
621    #[test]
622    fn is_timeout_false_for_other_codes() {
623        let e = BsqlError::Query(QueryError {
624            message: Cow::Borrowed("unique violation"),
625            pg_code: Some(Box::from("23505")),
626            source: None,
627        });
628        assert!(!e.is_timeout());
629    }
630
631    #[test]
632    fn is_timeout_false_for_non_query() {
633        let e = PoolError::exhausted();
634        assert!(!e.is_timeout());
635    }
636
637    #[test]
638    fn is_serialization_failure_true_for_40001() {
639        let e = BsqlError::Query(QueryError {
640            message: Cow::Borrowed("could not serialize access"),
641            pg_code: Some(Box::from("40001")),
642            source: None,
643        });
644        assert!(e.is_serialization_failure());
645    }
646
647    #[test]
648    fn is_serialization_failure_false_for_other_codes() {
649        let e = BsqlError::Query(QueryError {
650            message: Cow::Borrowed("timeout"),
651            pg_code: Some(Box::from("57014")),
652            source: None,
653        });
654        assert!(!e.is_serialization_failure());
655    }
656
657    #[test]
658    fn from_driver_query_maps_io_to_query() {
659        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broke");
660        let e = BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Io(io_err));
661        match &e {
662            BsqlError::Query(q) => {
663                assert!(q.message.contains("I/O error during query"));
664                assert!(q.source.is_some());
665            }
666            other => panic!("expected Query, got: {other:?}"),
667        }
668    }
669
670    #[test]
671    fn from_driver_query_non_io_delegates_to_from() {
672        let e =
673            BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Pool("test".into()));
674        assert!(matches!(e, BsqlError::Pool(_)));
675    }
676
677    // --- is_unique_violation ---
678
679    #[test]
680    fn is_unique_violation_true_for_23505() {
681        let e = BsqlError::Query(QueryError {
682            message: Cow::Borrowed("duplicate key value violates unique constraint"),
683            pg_code: Some(Box::from("23505")),
684            source: None,
685        });
686        assert!(e.is_unique_violation());
687    }
688
689    #[test]
690    fn is_unique_violation_false_for_other_codes() {
691        let e = BsqlError::Query(QueryError {
692            message: Cow::Borrowed("timeout"),
693            pg_code: Some(Box::from("57014")),
694            source: None,
695        });
696        assert!(!e.is_unique_violation());
697    }
698
699    #[test]
700    fn is_unique_violation_false_for_non_query() {
701        let e = PoolError::exhausted();
702        assert!(!e.is_unique_violation());
703    }
704
705    // --- is_foreign_key_violation ---
706
707    #[test]
708    fn is_foreign_key_violation_true_for_23503() {
709        let e = BsqlError::Query(QueryError {
710            message: Cow::Borrowed("insert or update violates foreign key constraint"),
711            pg_code: Some(Box::from("23503")),
712            source: None,
713        });
714        assert!(e.is_foreign_key_violation());
715    }
716
717    #[test]
718    fn is_foreign_key_violation_false_for_other_codes() {
719        let e = BsqlError::Query(QueryError {
720            message: Cow::Borrowed("unique"),
721            pg_code: Some(Box::from("23505")),
722            source: None,
723        });
724        assert!(!e.is_foreign_key_violation());
725    }
726
727    #[test]
728    fn is_foreign_key_violation_false_for_non_query() {
729        let e = ConnectError::create("down");
730        assert!(!e.is_foreign_key_violation());
731    }
732
733    // --- is_not_null_violation ---
734
735    #[test]
736    fn is_not_null_violation_true_for_23502() {
737        let e = BsqlError::Query(QueryError {
738            message: Cow::Borrowed("null value in column \"name\" violates not-null constraint"),
739            pg_code: Some(Box::from("23502")),
740            source: None,
741        });
742        assert!(e.is_not_null_violation());
743    }
744
745    #[test]
746    fn is_not_null_violation_false_for_other_codes() {
747        let e = BsqlError::Query(QueryError {
748            message: Cow::Borrowed("unique"),
749            pg_code: Some(Box::from("23505")),
750            source: None,
751        });
752        assert!(!e.is_not_null_violation());
753    }
754
755    // --- is_check_violation ---
756
757    #[test]
758    fn is_check_violation_true_for_23514() {
759        let e = BsqlError::Query(QueryError {
760            message: Cow::Borrowed("new row violates check constraint"),
761            pg_code: Some(Box::from("23514")),
762            source: None,
763        });
764        assert!(e.is_check_violation());
765    }
766
767    #[test]
768    fn is_check_violation_false_for_other_codes() {
769        let e = BsqlError::Query(QueryError {
770            message: Cow::Borrowed("unique"),
771            pg_code: Some(Box::from("23505")),
772            source: None,
773        });
774        assert!(!e.is_check_violation());
775    }
776
777    // --- is_deadlock ---
778
779    #[test]
780    fn is_deadlock_true_for_40p01() {
781        let e = BsqlError::Query(QueryError {
782            message: Cow::Borrowed("deadlock detected"),
783            pg_code: Some(Box::from("40P01")),
784            source: None,
785        });
786        assert!(e.is_deadlock());
787    }
788
789    #[test]
790    fn is_deadlock_false_for_other_codes() {
791        let e = BsqlError::Query(QueryError {
792            message: Cow::Borrowed("serialization"),
793            pg_code: Some(Box::from("40001")),
794            source: None,
795        });
796        assert!(!e.is_deadlock());
797    }
798
799    #[test]
800    fn is_deadlock_false_for_non_query() {
801        let e = PoolError::exhausted();
802        assert!(!e.is_deadlock());
803    }
804
805    // --- pg_code ---
806
807    #[test]
808    fn pg_code_returns_code_for_query_error() {
809        let e = BsqlError::Query(QueryError {
810            message: Cow::Borrowed("duplicate key"),
811            pg_code: Some(Box::from("23505")),
812            source: None,
813        });
814        assert_eq!(e.pg_code(), Some("23505"));
815    }
816
817    #[test]
818    fn pg_code_returns_none_for_query_without_code() {
819        let e = BsqlError::Query(QueryError {
820            message: Cow::Borrowed("I/O error"),
821            pg_code: None,
822            source: None,
823        });
824        assert_eq!(e.pg_code(), None);
825    }
826
827    #[test]
828    fn pg_code_returns_none_for_pool_error() {
829        let e = PoolError::exhausted();
830        assert_eq!(e.pg_code(), None);
831    }
832
833    #[test]
834    fn pg_code_returns_none_for_connect_error() {
835        let e = ConnectError::create("refused");
836        assert_eq!(e.pg_code(), None);
837    }
838
839    #[test]
840    fn pg_code_returns_none_for_decode_error() {
841        let e = BsqlError::Decode(DecodeError {
842            column: Cow::Borrowed("col"),
843            expected: "i32",
844            actual: Cow::Borrowed("text"),
845            source: None,
846        });
847        assert_eq!(e.pg_code(), None);
848    }
849
850    // --- Server error with position ---
851
852    #[test]
853    fn server_error_with_position_display() {
854        let driver_err = bsql_driver_postgres::DriverError::Server {
855            code: *b"42601",
856            message: "syntax error".into(),
857            detail: None,
858            hint: None,
859            position: Some(8),
860        };
861        let e = BsqlError::from(driver_err);
862        let display = e.to_string();
863        assert!(
864            display.contains("at position 8"),
865            "should contain position: {display}"
866        );
867        assert!(
868            display.contains("syntax error"),
869            "should contain message: {display}"
870        );
871    }
872
873    #[test]
874    fn server_error_with_all_fields() {
875        let driver_err = bsql_driver_postgres::DriverError::Server {
876            code: *b"42P01",
877            message: "relation does not exist".into(),
878            detail: Some("table was dropped".into()),
879            hint: Some("recreate the table".into()),
880            position: Some(42),
881        };
882        let e = BsqlError::from(driver_err);
883        let display = e.to_string();
884        assert!(display.contains("at position 42"));
885        assert!(display.contains("detail: table was dropped"));
886        assert!(display.contains("hint: recreate the table"));
887        assert!(display.contains("relation does not exist"));
888    }
889
890    // --- from_driver_query with Server variant delegates to From ---
891
892    #[test]
893    fn from_driver_query_server_error_delegates() {
894        let e = BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Server {
895            code: *b"23505",
896            message: "duplicate key".into(),
897            detail: None,
898            hint: None,
899            position: None,
900        });
901        assert!(matches!(e, BsqlError::Query(_)));
902        assert_eq!(e.pg_code(), Some("23505"));
903    }
904
905    // --- From<DriverError> for all variants ---
906
907    #[test]
908    fn from_driver_io_maps_to_connect() {
909        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
910        let e = BsqlError::from(bsql_driver_postgres::DriverError::Io(io_err));
911        assert!(matches!(e, BsqlError::Connect(_)));
912        assert!(e.source().is_some());
913    }
914
915    #[test]
916    fn from_driver_auth_maps_to_connect() {
917        let e = BsqlError::from(bsql_driver_postgres::DriverError::Auth(
918            "wrong password".into(),
919        ));
920        match &e {
921            BsqlError::Connect(ce) => {
922                assert!(ce.message.contains("wrong password"));
923            }
924            other => panic!("expected Connect, got: {other:?}"),
925        }
926    }
927
928    #[test]
929    fn from_driver_protocol_maps_to_query() {
930        let e = BsqlError::from(bsql_driver_postgres::DriverError::Protocol(
931            "unexpected message".into(),
932        ));
933        match &e {
934            BsqlError::Query(qe) => {
935                assert!(qe.message.contains("unexpected message"));
936                assert!(qe.pg_code.is_none());
937            }
938            other => panic!("expected Query, got: {other:?}"),
939        }
940    }
941
942    // --- SQLite error conversion ---
943
944    #[cfg(feature = "sqlite")]
945    mod sqlite_tests {
946        use super::*;
947
948        #[test]
949        fn from_sqlite_sqlite_error() {
950            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Sqlite {
951                code: 19,
952                message: "UNIQUE constraint failed".into(),
953            });
954            match &e {
955                BsqlError::Query(qe) => {
956                    assert!(qe.message.contains("SQLite error [19]"));
957                    assert!(qe.message.contains("UNIQUE constraint failed"));
958                    assert!(qe.pg_code.is_none());
959                }
960                other => panic!("expected Query, got: {other:?}"),
961            }
962        }
963
964        #[test]
965        fn from_sqlite_io_error() {
966            let io_err =
967                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "read-only filesystem");
968            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Io(io_err));
969            match &e {
970                BsqlError::Connect(ce) => {
971                    assert!(ce.message.contains("SQLite I/O error"));
972                    assert!(ce.message.contains("read-only filesystem"));
973                    assert!(ce.source.is_some());
974                }
975                other => panic!("expected Connect, got: {other:?}"),
976            }
977        }
978
979        #[test]
980        fn from_sqlite_internal_error() {
981            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Internal(
982                "corrupted database".into(),
983            ));
984            match &e {
985                BsqlError::Query(qe) => {
986                    assert!(qe.message.contains("SQLite internal error"));
987                    assert!(qe.message.contains("corrupted database"));
988                }
989                other => panic!("expected Query, got: {other:?}"),
990            }
991        }
992
993        #[test]
994        fn from_sqlite_pool_error() {
995            let e = BsqlError::from_sqlite(bsql_driver_sqlite::SqliteError::Pool(
996                "no readers available".into(),
997            ));
998            match &e {
999                BsqlError::Pool(pe) => {
1000                    assert!(pe.message.contains("SQLite pool error"));
1001                    assert!(pe.message.contains("no readers available"));
1002                }
1003                other => panic!("expected Pool, got: {other:?}"),
1004            }
1005        }
1006    }
1007
1008    // --- Send + Sync assertions ---
1009
1010    fn _assert_send<T: Send>() {}
1011    fn _assert_sync<T: Sync>() {}
1012
1013    #[test]
1014    fn bsql_error_is_send() {
1015        _assert_send::<BsqlError>();
1016    }
1017
1018    #[test]
1019    fn bsql_error_is_sync() {
1020        _assert_sync::<BsqlError>();
1021    }
1022
1023    // --- Gap: BsqlError Display for Connect variant includes message ---
1024
1025    #[test]
1026    fn bsql_error_display_connect_includes_message() {
1027        let e = ConnectError::create("tcp connection refused at 127.0.0.1:5432");
1028        let display = e.to_string();
1029        assert!(
1030            display.contains("connect error:"),
1031            "should start with 'connect error:': {display}"
1032        );
1033        assert!(
1034            display.contains("tcp connection refused at 127.0.0.1:5432"),
1035            "should include the message: {display}"
1036        );
1037    }
1038
1039    // --- Gap: QueryError Display with pg_code ---
1040
1041    #[test]
1042    fn bsql_error_display_query_includes_pg_code() {
1043        let e = BsqlError::Query(QueryError {
1044            message: Cow::Borrowed("relation \"users\" does not exist"),
1045            pg_code: Some(Box::from("42P01")),
1046            source: None,
1047        });
1048        let display = e.to_string();
1049        assert!(
1050            display.contains("42P01"),
1051            "should include pg_code: {display}"
1052        );
1053        assert!(
1054            display.contains("relation \"users\" does not exist"),
1055            "should include message: {display}"
1056        );
1057    }
1058
1059    // --- Gap: QueryError Display without pg_code ---
1060
1061    #[test]
1062    fn bsql_error_display_query_no_code() {
1063        let e = BsqlError::Query(QueryError {
1064            message: Cow::Borrowed("I/O error during query"),
1065            pg_code: None,
1066            source: None,
1067        });
1068        let display = e.to_string();
1069        assert!(
1070            display.contains("I/O error during query"),
1071            "should include message: {display}"
1072        );
1073        assert!(
1074            !display.contains('['),
1075            "should not contain brackets without code: {display}"
1076        );
1077    }
1078
1079    // --- Gap: QueryError::row_count produces expected message ---
1080
1081    #[test]
1082    fn query_error_row_count_message() {
1083        let e = QueryError::row_count("exactly 1 row", 5);
1084        let display = e.to_string();
1085        assert!(
1086            display.contains("expected exactly 1 row, got 5 rows"),
1087            "row_count message: {display}"
1088        );
1089    }
1090
1091    #[test]
1092    fn query_error_row_count_zero() {
1093        let e = QueryError::row_count("at least 1 row", 0);
1094        let display = e.to_string();
1095        assert!(
1096            display.contains("expected at least 1 row, got 0 rows"),
1097            "row_count zero: {display}"
1098        );
1099    }
1100
1101    // --- Gap: DecodeError::with_source preserves all fields ---
1102
1103    #[test]
1104    fn decode_error_with_source_preserves_fields() {
1105        let inner = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad utf-8");
1106        let e = DecodeError::with_source("email", "String", "bytes", inner);
1107        let display = e.to_string();
1108        assert!(
1109            display.contains("email"),
1110            "should contain column: {display}"
1111        );
1112        assert!(
1113            display.contains("String"),
1114            "should contain expected type: {display}"
1115        );
1116        assert!(
1117            display.contains("bytes"),
1118            "should contain actual type: {display}"
1119        );
1120    }
1121
1122    // --- Gap: PoolError Display ---
1123
1124    #[test]
1125    fn pool_error_display_custom_message() {
1126        let e = BsqlError::Pool(PoolError {
1127            message: Cow::Owned("all 10 connections in use".to_owned()),
1128            source: None,
1129        });
1130        let display = e.to_string();
1131        assert!(
1132            display.contains("pool error: all 10 connections in use"),
1133            "pool error display: {display}"
1134        );
1135    }
1136
1137    // --- Gap: ConnectError::with_source preserves source ---
1138
1139    #[test]
1140    fn connect_error_with_source_display() {
1141        let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1142        let e = ConnectError::with_source("failed to connect to PG", inner);
1143        let display = e.to_string();
1144        assert!(
1145            display.contains("failed to connect to PG"),
1146            "should include msg: {display}"
1147        );
1148        assert!(e.source().is_some());
1149    }
1150
1151    #[test]
1152    fn decode_error_column_count_mismatch() {
1153        // DecodeError::column_count returns a BsqlError::Decode
1154        let e: BsqlError = DecodeError::column_count(3, 2);
1155        let display = e.to_string();
1156        assert!(
1157            display.contains("expected 3 columns but row has 2"),
1158            "should describe column count mismatch: {display}"
1159        );
1160        // Should be a Decode variant
1161        assert!(matches!(e, BsqlError::Decode(_)));
1162        // No source
1163        assert!(e.source().is_none());
1164    }
1165
1166    #[test]
1167    fn decode_error_column_count_5_vs_3() {
1168        let e: BsqlError = DecodeError::column_count(5, 3);
1169        let display = e.to_string();
1170        assert!(
1171            display.contains("expected 5 columns but row has 3"),
1172            "should describe 5 vs 3 mismatch: {display}"
1173        );
1174        // Verify the column field is "*" (wildcard — not a specific column)
1175        match &e {
1176            BsqlError::Decode(d) => {
1177                assert_eq!(d.column, "*", "column_count should use '*' for column");
1178                assert_eq!(d.expected, "matching column count");
1179            }
1180            other => panic!("expected Decode, got: {other:?}"),
1181        }
1182    }
1183
1184    #[test]
1185    fn decode_error_column_count_zero_zero() {
1186        // Edge case: both expected and actual are 0
1187        let e: BsqlError = DecodeError::column_count(0, 0);
1188        let display = e.to_string();
1189        assert!(
1190            display.contains("expected 0 columns but row has 0"),
1191            "should handle 0/0 edge case: {display}"
1192        );
1193        assert!(matches!(e, BsqlError::Decode(_)));
1194        assert!(e.source().is_none());
1195    }
1196
1197    // --- is_timeout false for every non-Query variant ---
1198
1199    #[test]
1200    fn is_timeout_false_for_connect() {
1201        let e = ConnectError::create("refused");
1202        assert!(!e.is_timeout());
1203    }
1204
1205    #[test]
1206    fn is_timeout_false_for_decode() {
1207        let e = BsqlError::Decode(DecodeError {
1208            column: Cow::Borrowed("x"),
1209            expected: "i32",
1210            actual: Cow::Borrowed("text"),
1211            source: None,
1212        });
1213        assert!(!e.is_timeout());
1214    }
1215
1216    #[test]
1217    fn is_timeout_false_for_query_without_code() {
1218        let e = BsqlError::Query(QueryError {
1219            message: Cow::Borrowed("some error"),
1220            pg_code: None,
1221            source: None,
1222        });
1223        assert!(!e.is_timeout());
1224    }
1225
1226    // --- is_serialization_failure false for every non-Query variant ---
1227
1228    #[test]
1229    fn is_serialization_failure_false_for_non_query_pool() {
1230        let e = PoolError::exhausted();
1231        assert!(!e.is_serialization_failure());
1232    }
1233
1234    #[test]
1235    fn is_serialization_failure_false_for_connect() {
1236        let e = ConnectError::create("down");
1237        assert!(!e.is_serialization_failure());
1238    }
1239
1240    #[test]
1241    fn is_serialization_failure_false_for_decode() {
1242        let e = BsqlError::Decode(DecodeError {
1243            column: Cow::Borrowed("x"),
1244            expected: "i32",
1245            actual: Cow::Borrowed("text"),
1246            source: None,
1247        });
1248        assert!(!e.is_serialization_failure());
1249    }
1250
1251    // --- is_not_null_violation false for non-query ---
1252
1253    #[test]
1254    fn is_not_null_violation_false_for_pool() {
1255        let e = PoolError::exhausted();
1256        assert!(!e.is_not_null_violation());
1257    }
1258
1259    #[test]
1260    fn is_not_null_violation_false_for_connect() {
1261        let e = ConnectError::create("down");
1262        assert!(!e.is_not_null_violation());
1263    }
1264
1265    #[test]
1266    fn is_not_null_violation_false_for_decode() {
1267        let e = BsqlError::Decode(DecodeError {
1268            column: Cow::Borrowed("x"),
1269            expected: "i32",
1270            actual: Cow::Borrowed("text"),
1271            source: None,
1272        });
1273        assert!(!e.is_not_null_violation());
1274    }
1275
1276    // --- is_check_violation false for non-query ---
1277
1278    #[test]
1279    fn is_check_violation_false_for_pool() {
1280        let e = PoolError::exhausted();
1281        assert!(!e.is_check_violation());
1282    }
1283
1284    #[test]
1285    fn is_check_violation_false_for_connect() {
1286        let e = ConnectError::create("down");
1287        assert!(!e.is_check_violation());
1288    }
1289
1290    #[test]
1291    fn is_check_violation_false_for_decode() {
1292        let e = BsqlError::Decode(DecodeError {
1293            column: Cow::Borrowed("x"),
1294            expected: "i32",
1295            actual: Cow::Borrowed("text"),
1296            source: None,
1297        });
1298        assert!(!e.is_check_violation());
1299    }
1300
1301    // --- is_foreign_key_violation false for decode ---
1302
1303    #[test]
1304    fn is_foreign_key_violation_false_for_decode() {
1305        let e = BsqlError::Decode(DecodeError {
1306            column: Cow::Borrowed("x"),
1307            expected: "i32",
1308            actual: Cow::Borrowed("text"),
1309            source: None,
1310        });
1311        assert!(!e.is_foreign_key_violation());
1312    }
1313
1314    // --- is_deadlock false for connect and decode ---
1315
1316    #[test]
1317    fn is_deadlock_false_for_connect() {
1318        let e = ConnectError::create("down");
1319        assert!(!e.is_deadlock());
1320    }
1321
1322    #[test]
1323    fn is_deadlock_false_for_decode() {
1324        let e = BsqlError::Decode(DecodeError {
1325            column: Cow::Borrowed("x"),
1326            expected: "i32",
1327            actual: Cow::Borrowed("text"),
1328            source: None,
1329        });
1330        assert!(!e.is_deadlock());
1331    }
1332
1333    // --- is_unique_violation false for connect and decode ---
1334
1335    #[test]
1336    fn is_unique_violation_false_for_connect() {
1337        let e = ConnectError::create("down");
1338        assert!(!e.is_unique_violation());
1339    }
1340
1341    #[test]
1342    fn is_unique_violation_false_for_decode() {
1343        let e = BsqlError::Decode(DecodeError {
1344            column: Cow::Borrowed("x"),
1345            expected: "i32",
1346            actual: Cow::Borrowed("text"),
1347            source: None,
1348        });
1349        assert!(!e.is_unique_violation());
1350    }
1351
1352    // --- pg_code returns None for query error without code ---
1353
1354    #[test]
1355    fn pg_code_none_when_query_has_no_code() {
1356        let e = BsqlError::Query(QueryError {
1357            message: Cow::Borrowed("io error"),
1358            pg_code: None,
1359            source: None,
1360        });
1361        assert_eq!(e.pg_code(), None);
1362    }
1363
1364    // --- BsqlError Debug impl ---
1365
1366    #[test]
1367    fn bsql_error_debug_pool() {
1368        let e = PoolError::exhausted();
1369        let dbg = format!("{e:?}");
1370        assert!(dbg.contains("Pool"), "Pool variant in debug: {dbg}");
1371    }
1372
1373    #[test]
1374    fn bsql_error_debug_query() {
1375        let e = BsqlError::Query(QueryError {
1376            message: Cow::Borrowed("test"),
1377            pg_code: Some(Box::from("23505")),
1378            source: None,
1379        });
1380        let dbg = format!("{e:?}");
1381        assert!(dbg.contains("Query"), "Query variant in debug: {dbg}");
1382        assert!(dbg.contains("23505"), "pg_code in debug: {dbg}");
1383    }
1384
1385    #[test]
1386    fn bsql_error_debug_decode() {
1387        let e = BsqlError::Decode(DecodeError {
1388            column: Cow::Borrowed("col"),
1389            expected: "i32",
1390            actual: Cow::Borrowed("text"),
1391            source: None,
1392        });
1393        let dbg = format!("{e:?}");
1394        assert!(dbg.contains("Decode"), "Decode variant in debug: {dbg}");
1395    }
1396
1397    #[test]
1398    fn bsql_error_debug_connect() {
1399        let e = ConnectError::create("refused");
1400        let dbg = format!("{e:?}");
1401        assert!(dbg.contains("Connect"), "Connect variant in debug: {dbg}");
1402    }
1403
1404    // --- Error Display stability ---
1405
1406    #[test]
1407    fn pool_error_display_starts_with_prefix() {
1408        let e = PoolError::exhausted();
1409        assert!(e.to_string().starts_with("pool error:"));
1410    }
1411
1412    #[test]
1413    fn query_error_display_starts_with_prefix() {
1414        let e = QueryError::row_count("1 row", 0);
1415        assert!(e.to_string().starts_with("query error:"));
1416    }
1417
1418    #[test]
1419    fn decode_error_display_starts_with_prefix() {
1420        let e = BsqlError::Decode(DecodeError {
1421            column: Cow::Borrowed("x"),
1422            expected: "i32",
1423            actual: Cow::Borrowed("text"),
1424            source: None,
1425        });
1426        assert!(e.to_string().starts_with("decode error:"));
1427    }
1428
1429    #[test]
1430    fn connect_error_display_starts_with_prefix() {
1431        let e = ConnectError::create("refused");
1432        assert!(e.to_string().starts_with("connect error:"));
1433    }
1434
1435    // --- from_driver_query with Auth variant maps through From ---
1436
1437    #[test]
1438    fn from_driver_query_auth_delegates_to_from() {
1439        let e = BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Auth("bad".into()));
1440        assert!(matches!(e, BsqlError::Connect(_)));
1441    }
1442
1443    // --- from_driver_query with Protocol variant maps through From ---
1444
1445    #[test]
1446    fn from_driver_query_protocol_delegates_to_from() {
1447        let e = BsqlError::from_driver_query(bsql_driver_postgres::DriverError::Protocol(
1448            "bad msg".into(),
1449        ));
1450        assert!(matches!(e, BsqlError::Query(_)));
1451    }
1452
1453    // --- Error trait: source chain on various error types ---
1454
1455    #[test]
1456    fn query_error_source_is_none_without_source() {
1457        let e = BsqlError::Query(QueryError {
1458            message: Cow::Borrowed("test"),
1459            pg_code: None,
1460            source: None,
1461        });
1462        assert!(e.source().is_none());
1463    }
1464
1465    #[test]
1466    fn connect_error_source_is_none_without_source() {
1467        let e = ConnectError::create("test");
1468        assert!(e.source().is_none());
1469    }
1470
1471    #[test]
1472    fn pool_error_source_is_none_without_source() {
1473        let e = PoolError::exhausted();
1474        assert!(e.source().is_none());
1475    }
1476}