use rusqlite::ffi;
use super::Error;
pub(crate) const LOCKED_DISPLAY: &str = "Database error: database is locked";
#[derive(Debug)]
pub(crate) enum DbErrorKind<'a> {
Locked,
Domain(&'a Error),
Other,
}
fn is_trusted_domain_error(err: &Error) -> bool {
err.is_structured_embedding_error()
}
#[must_use]
pub(crate) fn classify_db_error(err: &rusqlite::Error) -> DbErrorKind<'_> {
if matches!(
err.sqlite_error_code(),
Some(ffi::ErrorCode::DatabaseBusy) | Some(ffi::ErrorCode::DatabaseLocked)
) {
return DbErrorKind::Locked;
}
if let rusqlite::Error::FromSqlConversionFailure(_, _, boxed) = err
&& let Some(source) = (**boxed).source()
&& let Some(domain) = source.downcast_ref::<Error>()
&& is_trusted_domain_error(domain)
{
return DbErrorKind::Domain(domain);
}
DbErrorKind::Other
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn busy_and_locked_codes_classify_as_locked() {
for code in [rusqlite::ffi::SQLITE_BUSY, rusqlite::ffi::SQLITE_LOCKED] {
let err = rusqlite::Error::SqliteFailure(ffi::Error::new(code), None);
assert!(
matches!(classify_db_error(&err), DbErrorKind::Locked),
"code {code} must classify as Locked"
);
}
}
#[test]
fn unrelated_failures_are_other() {
let err = rusqlite::Error::SqliteFailure(
ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE),
Some("UNIQUE constraint failed: memories.id".to_string()),
);
assert!(matches!(classify_db_error(&err), DbErrorKind::Other));
}
#[test]
fn a_row_conversion_failure_surfaces_its_domain_error() {
let domain = Error::CorruptEmbedding {
id: "mem-1".to_string(),
reason: "not enough bytes".to_string(),
};
let err = crate::sqlite::query_mod::corrupt_embedding_error("mem-1".to_string(), domain);
match classify_db_error(&err) {
DbErrorKind::Domain(Error::CorruptEmbedding { id, .. }) => assert_eq!(id, "mem-1"),
other => panic!("expected the CorruptEmbedding domain error, got {other:?}"),
}
}
#[test]
fn a_row_conversion_failure_carrying_store_derived_text_is_other() {
let store_derived = "content echoed from the memories table".to_string();
let smuggled = rusqlite::Error::FromSqlConversionFailure(
crate::sqlite::EMBEDDING_COLUMN,
rusqlite::types::Type::Blob,
Box::new(rusqlite::types::FromSqlError::other(
super::super::Error::Sqlite(store_derived),
)),
);
assert!(
matches!(classify_db_error(&smuggled), DbErrorKind::Other),
"a Sqlite payload carrying store-derived text must not be promoted"
);
}
#[test]
fn an_untrusted_domain_error_on_the_source_chain_is_other() {
let smuggled = rusqlite::Error::FromSqlConversionFailure(
crate::sqlite::EMBEDDING_COLUMN,
rusqlite::types::Type::Blob,
Box::new(rusqlite::types::FromSqlError::other(Error::NotFound(
"secret-uuid".to_string(),
))),
);
assert!(matches!(classify_db_error(&smuggled), DbErrorKind::Other));
}
#[test]
fn locked_storage_error_renders_the_canonical_busy_text() {
let busy = rusqlite::Error::SqliteFailure(
ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
Some("database is locked".to_string()),
);
let storage: Error = busy.into();
assert!(matches!(storage, Error::Locked));
assert_eq!(storage.to_string(), LOCKED_DISPLAY);
}
#[test]
fn a_lock_without_a_result_code_is_other() {
let no_code = rusqlite::Error::InvalidColumnType(
0,
"embedding".to_string(),
rusqlite::types::Type::Blob,
);
assert!(matches!(classify_db_error(&no_code), DbErrorKind::Other));
}
}