use std::io;
use thiserror::Error as ThisError;
#[derive(Debug, ThisError)]
pub enum Error {
#[error("{message}")]
Connection {
message: String,
sqlstate: Option<String>,
},
#[error("{0}")]
Authentication(String),
#[error("{message}{}", render_detail(message, detail.as_deref()))]
Query {
message: String,
sqlstate: Option<String>,
detail: Option<String>,
hint: Option<String>,
},
#[error("{0}")]
Protocol(String),
#[error("{0}")]
Io(String),
#[error("{0}")]
Config(String),
#[error("{0}")]
Timeout(String),
#[error("{message}")]
Cancelled {
message: String,
sqlstate: Option<String>,
},
#[error("{message}")]
Closed {
message: String,
sqlstate: Option<String>,
},
#[error("{0}")]
Conversion(String),
#[error("{0}")]
FeatureNotSupported(String),
#[error("{0}")]
Other(String),
}
fn render_detail(message: &str, detail: Option<&str>) -> String {
match detail {
Some(detail) if !message.contains(detail) => format!(": {detail}"),
_ => String::new(),
}
}
impl Error {
pub fn connection(message: impl Into<String>) -> Self {
Error::Connection {
message: message.into(),
sqlstate: None,
}
}
pub fn authentication(message: impl Into<String>) -> Self {
Error::Authentication(message.into())
}
pub fn query(message: impl Into<String>) -> Self {
Error::Query {
message: message.into(),
sqlstate: None,
detail: None,
hint: None,
}
}
pub fn protocol(message: impl Into<String>) -> Self {
Error::Protocol(message.into())
}
pub fn io(message: impl Into<String>) -> Self {
Error::Io(message.into())
}
pub fn config(message: impl Into<String>) -> Self {
Error::Config(message.into())
}
pub fn timeout(message: impl Into<String>) -> Self {
Error::Timeout(message.into())
}
pub fn cancelled(message: impl Into<String>) -> Self {
Error::Cancelled {
message: message.into(),
sqlstate: None,
}
}
pub fn closed(message: impl Into<String>) -> Self {
Error::Closed {
message: message.into(),
sqlstate: None,
}
}
pub fn conversion(message: impl Into<String>) -> Self {
Error::Conversion(message.into())
}
pub fn feature_not_supported(message: impl Into<String>) -> Self {
Error::FeatureNotSupported(message.into())
}
pub fn other(message: impl Into<String>) -> Self {
Error::Other(message.into())
}
#[expect(
clippy::needless_pass_by_value,
reason = "call-site ergonomics: consumed as a `map_err` function reference"
)]
#[must_use]
pub fn from_io(err: io::Error) -> Self {
Error::Io(err.to_string())
}
#[must_use]
pub fn db(severity: &str, code: &str, message: &str) -> Self {
Error::Query {
message: format!("{severity}: {message} ({code})"),
sqlstate: Some(code.to_string()),
detail: None,
hint: None,
}
}
#[must_use]
pub fn message(&self) -> &str {
match self {
Error::Connection { message, .. }
| Error::Query { message, .. }
| Error::Cancelled { message, .. }
| Error::Closed { message, .. } => message,
Error::Authentication(message)
| Error::Protocol(message)
| Error::Io(message)
| Error::Config(message)
| Error::Timeout(message)
| Error::Conversion(message)
| Error::FeatureNotSupported(message)
| Error::Other(message) => message,
}
}
#[must_use]
pub fn detail(&self) -> Option<&str> {
match self {
Error::Query { detail, .. } => detail.as_deref(),
_ => None,
}
}
#[must_use]
pub fn hint(&self) -> Option<&str> {
match self {
Error::Query { hint, .. } => hint.as_deref(),
_ => None,
}
}
#[must_use]
pub fn sqlstate(&self) -> Option<&str> {
match self {
Error::Connection { sqlstate, .. }
| Error::Cancelled { sqlstate, .. }
| Error::Closed { sqlstate, .. } => sqlstate.as_deref(),
Error::Query {
sqlstate, message, ..
} => match sqlstate {
Some(code) => Some(code),
None => extract_sqlstate(message),
},
_ => None,
}
}
}
fn extract_sqlstate(message: &str) -> Option<&str> {
let start = message.rfind('(')?;
let end = message[start..].find(')')?;
let code = message[start + 1..start + end].trim();
if code.len() == 5 && code.chars().all(|c| c.is_ascii_alphanumeric()) {
Some(code)
} else {
None
}
}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Self {
Error::from_io(err)
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sqlstate_extraction() {
let err = Error::db("ERROR", "42P04", "database \"test\" already exists");
assert_eq!(err.sqlstate(), Some("42P04"));
let err = Error::db("ERROR", "42710", "duplicate object");
assert_eq!(err.sqlstate(), Some("42710"));
let err = Error::db("ERROR", "42P06", "schema \"public\" already exists");
assert_eq!(err.sqlstate(), Some("42P06"));
let err = Error::db("ERROR", "42P07", "table \"users\" already exists");
assert_eq!(err.sqlstate(), Some("42P07"));
}
#[test]
fn test_sqlstate_non_query_error() {
let err = Error::connection("connection failed");
assert_eq!(err.sqlstate(), None);
let err = Error::timeout("operation timed out");
assert_eq!(err.sqlstate(), None);
}
#[test]
fn test_sqlstate_on_non_query_variants() {
let err = Error::Cancelled {
message: "query canceled".to_string(),
sqlstate: Some("57014".to_string()),
};
assert_eq!(err.sqlstate(), Some("57014"));
let err = Error::Connection {
message: "connection failure".to_string(),
sqlstate: Some("08006".to_string()),
};
assert_eq!(err.sqlstate(), Some("08006"));
let err = Error::Closed {
message: "closed".to_string(),
sqlstate: Some("08003".to_string()),
};
assert_eq!(err.sqlstate(), Some("08003"));
}
#[test]
fn test_display_detail_suffix() {
let err = Error::Query {
message: "column not found".to_string(),
sqlstate: None,
detail: Some("column \"foo\" does not exist".to_string()),
hint: None,
};
assert_eq!(
err.to_string(),
"column not found: column \"foo\" does not exist"
);
let err = Error::Query {
message: "column not found: column \"foo\" does not exist".to_string(),
sqlstate: None,
detail: Some("column \"foo\" does not exist".to_string()),
hint: None,
};
assert_eq!(
err.to_string(),
"column not found: column \"foo\" does not exist",
"detail already present in the message must not be repeated"
);
}
#[test]
fn test_io_error_renders_once() {
let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
let err = Error::from(io_err);
assert_eq!(err.to_string(), "refused");
}
#[test]
fn test_extract_sqlstate_edge_cases() {
assert_eq!(extract_sqlstate("ERROR: message (42P04)"), Some("42P04"));
assert_eq!(extract_sqlstate("ERROR: message ( 42P04 )"), Some("42P04"));
assert_eq!(
extract_sqlstate("ERROR: (extra info) message (42P04)"),
Some("42P04")
);
assert_eq!(extract_sqlstate("ERROR: message (42P)"), None);
assert_eq!(extract_sqlstate("ERROR: message (42P044)"), None);
assert_eq!(extract_sqlstate("ERROR: message (42-04)"), None);
assert_eq!(extract_sqlstate("ERROR: message"), None);
assert_eq!(extract_sqlstate("ERROR: message ()"), None);
}
}