use thiserror::Error;
pub const LOCKED_DB_LEAD: &str = "Database is locked.";
pub const LOCKED_DB_HINT: &str =
"Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.";
pub const LOCKED_DB_MESSAGE: &str = concat!(
"Database is locked. ",
"Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry."
);
pub(crate) fn with_prefix(prefix: &str) -> impl Fn(Error) -> Error + '_ {
move |err| match err {
Error::Tokenization { message } => Error::Tokenization {
message: format!("{prefix} {message}"),
},
other => other,
}
}
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database error: {message}")]
Database { message: String },
#[error("{LOCKED_DB_LEAD} {LOCKED_DB_HINT}")]
DatabaseLocked,
#[error("Inference error: {0}")]
Inference(String),
#[error("Tokenization error: {message}")]
Tokenization { message: String },
#[error("HuggingFace Hub error: {message}")]
HfHub { message: String },
#[error("ONNX session error: {message}")]
Onnx { message: String },
#[error("Configuration error: {0}")]
Config(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Input cannot be empty")]
EmptyInput,
#[error("Input too long: {actual_length} characters (max: {max_length})")]
InputTooLong {
max_length: usize,
actual_length: usize,
},
#[error("content exceeds {max_tokens}-token embedding limit (measured: {token_count} tokens)")]
ContentTooLong {
token_count: usize,
max_tokens: usize,
},
#[error("Invalid timestamp format: {timestamp} ({error})")]
InvalidTimestamp { timestamp: String, error: String },
#[error("Memory not found: {0}")]
NotFound(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Corrupt embedding for memory id: {id} ({reason})")]
CorruptEmbedding { id: String, reason: String },
#[error("Invalid BLOB size: expected {expected} bytes, got {actual} bytes")]
InvalidBlobSize { expected: usize, actual: usize },
#[error("Mismatched dimensions: expected {expected} dimensions, got {actual} dimensions")]
MismatchedDimensions { expected: usize, actual: usize },
#[error("migration refused: {} row(s) exceed the {}-token embedding limit: {}",
offending.len(), crate::embedding::MAX_EMBEDDING_TOKENS, offending.join(", "))]
MigrationRefused { offending: Vec<String> },
#[error("migration incomplete: {} row(s) failed to embed; the marker is left in place and the old identity was not replaced — fix the errors and re-run the migration",
report.failures.len())]
MigrationIncomplete {
report: crate::sqlite::migration_types::MigrationReport,
},
#[allow(dead_code)]
#[error("Embedder unavailable: {reason}")]
EmbedderUnavailable { reason: String },
}
impl Error {
#[must_use]
pub fn is_locked(&self) -> bool {
matches!(self, Self::DatabaseLocked)
}
}
impl From<rusqlite::Error> for Error {
fn from(err: rusqlite::Error) -> Self {
match crate::sqlite::db_error::classify_db_error(&err) {
crate::sqlite::db_error::DbErrorKind::Locked => Self::DatabaseLocked,
crate::sqlite::db_error::DbErrorKind::Domain(domain) => (*domain).clone().into(),
crate::sqlite::db_error::DbErrorKind::Other => Self::Database {
message: err.to_string(),
},
}
}
}
impl From<crate::sqlite::Error> for Error {
fn from(err: crate::sqlite::Error) -> Self {
match err {
crate::sqlite::Error::NotFound(_) => Error::NotFound("memory not found".to_string()),
crate::sqlite::Error::InvalidInput(msg) => Error::InvalidInput(msg),
crate::sqlite::Error::EmptyVector => Error::InvalidInput(err.to_string()),
crate::sqlite::Error::InvalidEmbedding(msg) => Error::InvalidInput(msg),
crate::sqlite::Error::InvalidLimit(msg) => Error::InvalidInput(msg),
crate::sqlite::Error::CorruptEmbedding { id, reason } => {
Error::CorruptEmbedding { id, reason }
}
crate::sqlite::Error::InvalidBlobSize { expected, actual } => {
Error::InvalidBlobSize { expected, actual }
}
crate::sqlite::Error::MismatchedDimensions { expected, actual } => {
Error::MismatchedDimensions { expected, actual }
}
crate::sqlite::Error::MigrationRefused { offending } => {
Error::MigrationRefused { offending }
}
crate::sqlite::Error::MigrationIncomplete { report } => {
Error::MigrationIncomplete { report }
}
crate::sqlite::Error::Locked => Error::DatabaseLocked,
crate::sqlite::Error::Sqlite(msg) => Error::Database { message: msg },
}
}
}
impl From<tokenizers::Error> for Error {
fn from(err: tokenizers::Error) -> Self {
Self::Tokenization {
message: err.to_string(),
}
}
}
impl From<ort::error::Error> for Error {
fn from(err: ort::error::Error) -> Self {
Self::Onnx {
message: err.to_string(),
}
}
}
impl From<hf_hub::api::sync::ApiError> for Error {
fn from(err: hf_hub::api::sync::ApiError) -> Self {
Self::HfHub {
message: err.to_string(),
}
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::InvalidInput(err.to_string())
}
}
impl From<chrono::ParseError> for Error {
fn from(err: chrono::ParseError) -> Self {
Self::InvalidInput(err.to_string())
}
}
impl From<String> for Error {
fn from(s: String) -> Self {
Error::InvalidInput(s)
}
}
#[cfg(test)]
mod error_conversion_tests {
use super::*;
#[test]
fn sqlite_not_found_converts_to_error_not_found() {
let sqlite_err = crate::sqlite::Error::NotFound("any-arbitrary-message".to_string());
let err: Error = sqlite_err.into();
assert!(
matches!(err, Error::NotFound(_)),
"sqlite::Error::NotFound must convert to Error::NotFound regardless of message text"
);
let Error::NotFound(msg) = err else {
unreachable!()
};
assert_eq!(msg, "memory not found");
}
#[test]
fn sqlite_invalid_input_converts_to_error_invalid_input() {
let sqlite_err =
crate::sqlite::Error::InvalidInput("At least one field must be provided".to_string());
let err: Error = sqlite_err.into();
match err {
Error::InvalidInput(msg) => {
assert_eq!(msg, "At least one field must be provided");
}
other => panic!("Expected InvalidInput, got {:?}", other),
}
}
#[test]
fn migration_refused_message_names_the_token_limit_constant() {
let err = Error::MigrationRefused {
offending: vec!["mem-1".to_string()],
};
let msg = err.to_string();
assert!(
msg.contains(&crate::embedding::MAX_EMBEDDING_TOKENS.to_string()),
"expected the rendered message to contain the token-limit constant: {msg}"
);
}
#[test]
fn sqlite_migration_refused_converts_to_migration_refused() {
let offending = vec!["mem-1".to_string(), "mem-2".to_string()];
let sqlite_err = crate::sqlite::Error::MigrationRefused {
offending: offending.clone(),
};
let err: Error = sqlite_err.into();
match err {
Error::MigrationRefused { offending: ids } => {
assert_eq!(
ids, offending,
"offending ids must be carried through unchanged"
);
}
other => panic!("Expected MigrationRefused, got {:?}", other),
}
}
#[test]
fn sqlite_migration_incomplete_converts_to_migration_incomplete() {
let report = crate::sqlite::migration_types::MigrationReport {
reindexed: 3,
skipped: 0,
failures: vec![crate::sqlite::migration_types::MigrationRowFailure {
id: "mem-1".to_string(),
error: "boom".to_string(),
}],
};
let sqlite_err = crate::sqlite::Error::MigrationIncomplete {
report: report.clone(),
};
let err: Error = sqlite_err.into();
match err {
Error::MigrationIncomplete { report: r } => {
assert_eq!(r, report, "report must be carried through unchanged");
}
other => panic!("Expected MigrationIncomplete, got {:?}", other),
}
}
#[test]
fn sqlite_other_errors_convert_to_database_with_their_message() {
let sqlite_err = crate::sqlite::Error::Sqlite("disk I/O error".to_string());
let err: Error = sqlite_err.into();
match err {
Error::Database { message } => {
assert_eq!(message, "disk I/O error");
assert!(
message.contains("disk I/O error"),
"Display must keep the underlying message, got: {message}"
);
}
other => panic!("Expected Database, got {other:?}"),
}
}
#[test]
fn database_display_keeps_the_underlying_message() {
let err = Error::Database {
message: "no such table: memories".to_string(),
};
let msg = err.to_string();
assert!(
msg.contains("no such table: memories"),
"Display must carry the underlying message, got: {msg}"
);
assert!(msg.contains("Database error"), "got: {msg}");
}
#[test]
fn locked_errors_convert_to_the_dedicated_locked_variant() {
let from_sqlite: Error = crate::sqlite::Error::Locked.into();
assert!(
matches!(from_sqlite, Error::DatabaseLocked),
"sqlite::Error::Locked must be classified locked, got {from_sqlite:?}"
);
assert!(from_sqlite.is_locked());
let busy = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
Some("database is locked".to_string()),
);
let from_rusqlite: Error = busy.into();
assert!(
matches!(from_rusqlite, Error::DatabaseLocked),
"a raw rusqlite SQLITE_BUSY must map to DatabaseLocked, got {from_rusqlite:?}"
);
assert!(from_rusqlite.is_locked());
}
#[test]
fn locked_display_keeps_underlying_text_and_hint() {
let err = Error::DatabaseLocked;
let msg = err.to_string();
assert!(msg.starts_with("Database is locked."), "got: {msg}");
assert!(msg.contains("MCP server"), "got: {msg}");
assert!(err.is_locked(), "DatabaseLocked is always locked");
}
}
#[cfg(test)]
#[path = "errors_public_api_tests.rs"]
mod errors_public_api_tests;