feat(error): add Rust AppError enum and migrate command modules
- AppError enum with 10 variants (Database, Auth, Filesystem, Ipc, NotFound,
Validation, Sidecar, Config, Network, Internal) + serde tag serialization
- From impls for rusqlite::Error, std::io::Error, serde_json::Error
- Migrated 9 command modules from Result<T, String> to Result<T, AppError>
- Frontend receives structured {kind, detail} objects via IPC
This commit is contained in:
parent
365c420901
commit
8b3b0ab720
11 changed files with 319 additions and 81 deletions
222
src-tauri/src/error.rs
Normal file
222
src-tauri/src/error.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! Typed application error enum.
|
||||
//!
|
||||
//! Replaces `Result<T, String>` across Tauri commands and backend modules.
|
||||
//! Tauri 2.x requires `serde::Serialize` on command error types.
|
||||
//! The custom `Serialize` impl produces `{ kind: "Database", detail: "..." }`
|
||||
//! so the frontend receives structured, classifiable errors.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Structured error type for all Tauri commands and backend operations.
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
Database { detail: String },
|
||||
Auth { detail: String },
|
||||
Filesystem { detail: String },
|
||||
Ipc { detail: String },
|
||||
NotFound { detail: String },
|
||||
Validation { detail: String },
|
||||
Sidecar { detail: String },
|
||||
Config { detail: String },
|
||||
Network { detail: String },
|
||||
Internal { detail: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AppError::Database { detail } => write!(f, "Database: {detail}"),
|
||||
AppError::Auth { detail } => write!(f, "Auth: {detail}"),
|
||||
AppError::Filesystem { detail } => write!(f, "Filesystem: {detail}"),
|
||||
AppError::Ipc { detail } => write!(f, "IPC: {detail}"),
|
||||
AppError::NotFound { detail } => write!(f, "NotFound: {detail}"),
|
||||
AppError::Validation { detail } => write!(f, "Validation: {detail}"),
|
||||
AppError::Sidecar { detail } => write!(f, "Sidecar: {detail}"),
|
||||
AppError::Config { detail } => write!(f, "Config: {detail}"),
|
||||
AppError::Network { detail } => write!(f, "Network: {detail}"),
|
||||
AppError::Internal { detail } => write!(f, "Internal: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
|
||||
/// Helper struct for structured JSON serialization.
|
||||
/// Produces `{ "kind": "Database", "detail": "message" }`.
|
||||
#[derive(Serialize)]
|
||||
struct ErrorPayload<'a> {
|
||||
kind: &'a str,
|
||||
detail: &'a str,
|
||||
}
|
||||
|
||||
impl Serialize for AppError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::ser::Serializer,
|
||||
{
|
||||
let (kind, detail) = match self {
|
||||
AppError::Database { detail } => ("Database", detail.as_str()),
|
||||
AppError::Auth { detail } => ("Auth", detail.as_str()),
|
||||
AppError::Filesystem { detail } => ("Filesystem", detail.as_str()),
|
||||
AppError::Ipc { detail } => ("IPC", detail.as_str()),
|
||||
AppError::NotFound { detail } => ("NotFound", detail.as_str()),
|
||||
AppError::Validation { detail } => ("Validation", detail.as_str()),
|
||||
AppError::Sidecar { detail } => ("Sidecar", detail.as_str()),
|
||||
AppError::Config { detail } => ("Config", detail.as_str()),
|
||||
AppError::Network { detail } => ("Network", detail.as_str()),
|
||||
AppError::Internal { detail } => ("Internal", detail.as_str()),
|
||||
};
|
||||
ErrorPayload { kind, detail }.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Convenience conversions ---
|
||||
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
AppError::Database {
|
||||
detail: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
AppError::Filesystem {
|
||||
detail: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
AppError::Validation {
|
||||
detail: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert from legacy `String` errors (used at the boundary between
|
||||
/// agor-core functions that still return `Result<T, String>` and
|
||||
/// Tauri commands that now return `Result<T, AppError>`).
|
||||
impl From<String> for AppError {
|
||||
fn from(s: String) -> Self {
|
||||
AppError::Internal { detail: s }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AppError {
|
||||
fn from(s: &str) -> Self {
|
||||
AppError::Internal {
|
||||
detail: s.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructors for common error patterns.
|
||||
impl AppError {
|
||||
pub fn database(detail: impl Into<String>) -> Self {
|
||||
AppError::Database {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth(detail: impl Into<String>) -> Self {
|
||||
AppError::Auth {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filesystem(detail: impl Into<String>) -> Self {
|
||||
AppError::Filesystem {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(detail: impl Into<String>) -> Self {
|
||||
AppError::NotFound {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(detail: impl Into<String>) -> Self {
|
||||
AppError::Validation {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sidecar(detail: impl Into<String>) -> Self {
|
||||
AppError::Sidecar {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(detail: impl Into<String>) -> Self {
|
||||
AppError::Config {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn network(detail: impl Into<String>) -> Self {
|
||||
AppError::Network {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(detail: impl Into<String>) -> Self {
|
||||
AppError::Internal {
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
let e = AppError::database("connection refused");
|
||||
assert_eq!(e.to_string(), "Database: connection refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_produces_structured_json() {
|
||||
let e = AppError::Database {
|
||||
detail: "table not found".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&e).unwrap();
|
||||
assert_eq!(json["kind"], "Database");
|
||||
assert_eq!(json["detail"], "table not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_rusqlite_error() {
|
||||
let e: AppError = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(1),
|
||||
Some("test".to_string()),
|
||||
)
|
||||
.into();
|
||||
assert!(matches!(e, AppError::Database { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_io_error() {
|
||||
let e: AppError = std::io::Error::new(std::io::ErrorKind::NotFound, "gone").into();
|
||||
assert!(matches!(e, AppError::Filesystem { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_string() {
|
||||
let e: AppError = "something went wrong".into();
|
||||
assert!(matches!(e, AppError::Internal { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_serde_json_error() {
|
||||
let e: AppError = serde_json::from_str::<serde_json::Value>("not json")
|
||||
.unwrap_err()
|
||||
.into();
|
||||
assert!(matches!(e, AppError::Validation { .. }));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue