use sqlx::error::DatabaseError; /// Structured business errors for the secrets service. /// /// These replace ad-hoc `anyhow` strings for expected failure modes, /// allowing MCP and Web layers to map to appropriate protocol-level errors. #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("A secret with the name '{secret_name}' already exists for this user")] ConflictSecretName { secret_name: String }, #[error("An entry with folder='{folder}' and name='{name}' already exists")] ConflictEntryName { folder: String, name: String }, #[error("Entry not found")] NotFoundEntry, #[error("Validation failed: {message}")] Validation { message: String }, #[error("Concurrent modification detected")] ConcurrentModification, #[error("Decryption failed — the encryption key may be incorrect")] DecryptionFailed, #[error(transparent)] Internal(#[from] anyhow::Error), } impl AppError { /// Try to convert a sqlx database error into a structured `AppError`. /// /// The caller should provide the context (which table was being written, /// what values were being inserted) so we can produce a meaningful error. pub fn from_db_error(err: sqlx::Error, ctx: DbErrorContext<'_>) -> Self { if let sqlx::Error::Database(ref db_err) = err && db_err.code().as_deref() == Some("23505") { return Self::from_unique_violation(db_err.as_ref(), ctx); } AppError::Internal(err.into()) } fn from_unique_violation(db_err: &dyn DatabaseError, ctx: DbErrorContext<'_>) -> Self { let constraint = db_err.constraint(); match constraint { Some("idx_secrets_unique_user_name") => AppError::ConflictSecretName { secret_name: ctx.secret_name.unwrap_or("unknown").to_string(), }, Some("idx_entries_unique_user") | Some("idx_entries_unique_legacy") => { AppError::ConflictEntryName { folder: ctx.folder.unwrap_or("").to_string(), name: ctx.name.unwrap_or("unknown").to_string(), } } _ => { // Fall back to message-based detection for unnamed constraints let msg = db_err.message(); if msg.contains("secrets") { AppError::ConflictSecretName { secret_name: ctx.secret_name.unwrap_or("unknown").to_string(), } } else { AppError::ConflictEntryName { folder: ctx.folder.unwrap_or("").to_string(), name: ctx.name.unwrap_or("unknown").to_string(), } } } } } } /// Context hints used when converting a database error to `AppError`. #[derive(Debug, Default, Clone, Copy)] pub struct DbErrorContext<'a> { pub secret_name: Option<&'a str>, pub folder: Option<&'a str>, pub name: Option<&'a str>, } impl<'a> DbErrorContext<'a> { pub fn secret_name(name: &'a str) -> Self { Self { secret_name: Some(name), ..Default::default() } } pub fn entry(folder: &'a str, name: &'a str) -> Self { Self { folder: Some(folder), name: Some(name), ..Default::default() } } } #[cfg(test)] mod tests { use super::*; #[test] fn app_error_display_messages() { let err = AppError::ConflictSecretName { secret_name: "token".to_string(), }; assert!(err.to_string().contains("token")); let err = AppError::ConflictEntryName { folder: "refining".to_string(), name: "gitea".to_string(), }; assert!(err.to_string().contains("refining")); assert!(err.to_string().contains("gitea")); let err = AppError::NotFoundEntry; assert_eq!(err.to_string(), "Entry not found"); let err = AppError::Validation { message: "too long".to_string(), }; assert!(err.to_string().contains("too long")); let err = AppError::ConcurrentModification; assert!(err.to_string().contains("Concurrent modification")); } #[test] fn db_error_context_helpers() { let ctx = DbErrorContext::secret_name("my_key"); assert_eq!(ctx.secret_name, Some("my_key")); assert!(ctx.folder.is_none()); let ctx = DbErrorContext::entry("prod", "db-creds"); assert_eq!(ctx.folder, Some("prod")); assert_eq!(ctx.name, Some("db-creds")); assert!(ctx.secret_name.is_none()); } }