From 8b3b0ab720bf6b1356a733187254ae157e470373 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 18 Mar 2026 01:22:04 +0100 Subject: [PATCH] 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 to Result - Frontend receives structured {kind, detail} objects via IPC --- src-tauri/src/commands/agent.rs | 19 +-- src-tauri/src/commands/claude.rs | 15 +- src-tauri/src/commands/files.rs | 31 ++-- src-tauri/src/commands/groups.rs | 13 +- src-tauri/src/commands/persistence.rs | 23 +-- src-tauri/src/commands/pty.rs | 17 +- src-tauri/src/commands/secrets.rs | 17 +- src-tauri/src/commands/session.rs | 29 ++-- src-tauri/src/commands/watcher.rs | 13 +- src-tauri/src/error.rs | 222 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 11 files changed, 319 insertions(+), 81 deletions(-) create mode 100644 src-tauri/src/error.rs diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index ee495f8..0866d87 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -1,5 +1,6 @@ use tauri::State; use crate::AppState; +use crate::error::AppError; use crate::sidecar::AgentQueryOptions; use agor_core::sandbox::SandboxConfig; @@ -8,18 +9,18 @@ use agor_core::sandbox::SandboxConfig; pub fn agent_query( state: State<'_, AppState>, options: AgentQueryOptions, -) -> Result<(), String> { +) -> Result<(), AppError> { // Run pre-dispatch hooks (budget enforcement, branch policy, etc.) for hook in &state.pre_dispatch_hooks { - hook(&options)?; + hook(&options).map_err(AppError::sidecar)?; } - state.sidecar_manager.query(&options) + state.sidecar_manager.query(&options).map_err(AppError::sidecar) } #[tauri::command] #[tracing::instrument(skip(state))] -pub fn agent_stop(state: State<'_, AppState>, session_id: String) -> Result<(), String> { - state.sidecar_manager.stop_session(&session_id) +pub fn agent_stop(state: State<'_, AppState>, session_id: String) -> Result<(), AppError> { + state.sidecar_manager.stop_session(&session_id).map_err(AppError::sidecar) } #[tauri::command] @@ -29,8 +30,8 @@ pub fn agent_ready(state: State<'_, AppState>) -> bool { #[tauri::command] #[tracing::instrument(skip(state))] -pub fn agent_restart(state: State<'_, AppState>) -> Result<(), String> { - state.sidecar_manager.restart() +pub fn agent_restart(state: State<'_, AppState>) -> Result<(), AppError> { + state.sidecar_manager.restart().map_err(AppError::sidecar) } /// Update sidecar sandbox configuration and restart to apply. @@ -44,7 +45,7 @@ pub fn agent_set_sandbox( project_cwds: Vec, worktree_roots: Vec, enabled: bool, -) -> Result<(), String> { +) -> Result<(), AppError> { let cwd_refs: Vec<&str> = project_cwds.iter().map(|s| s.as_str()).collect(); let wt_refs: Vec<&str> = worktree_roots.iter().map(|s| s.as_str()).collect(); @@ -55,7 +56,7 @@ pub fn agent_set_sandbox( // Restart sidecar so Landlock restrictions take effect on the new process if state.sidecar_manager.is_ready() { - state.sidecar_manager.restart()?; + state.sidecar_manager.restart().map_err(AppError::sidecar)?; } Ok(()) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index f6ece52..192cbcb 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -1,5 +1,7 @@ // Claude profile and skill discovery commands +use crate::error::AppError; + #[derive(serde::Serialize)] pub struct ClaudeProfile { pub name: String, @@ -142,17 +144,18 @@ pub fn claude_list_skills() -> Vec { } #[tauri::command] -pub fn claude_read_skill(path: String) -> Result { +pub fn claude_read_skill(path: String) -> Result { let skills_dir = dirs::home_dir() - .ok_or("Cannot determine home directory")? + .ok_or_else(|| AppError::config("Cannot determine home directory"))? .join(".claude") .join("skills"); let canonical_skills = skills_dir.canonicalize() - .map_err(|_| "Skills directory does not exist".to_string())?; + .map_err(|_| AppError::not_found("Skills directory does not exist"))?; let canonical_path = std::path::Path::new(&path).canonicalize() - .map_err(|e| format!("Invalid skill path: {e}"))?; + .map_err(|e| AppError::filesystem(format!("Invalid skill path: {e}")))?; if !canonical_path.starts_with(&canonical_skills) { - return Err("Access denied: path is outside skills directory".to_string()); + return Err(AppError::auth("Access denied: path is outside skills directory")); } - std::fs::read_to_string(&canonical_path).map_err(|e| format!("Failed to read skill: {e}")) + std::fs::read_to_string(&canonical_path) + .map_err(|e| AppError::filesystem(format!("Failed to read skill: {e}"))) } diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 157526d..ba69ef7 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -1,5 +1,7 @@ // File browser commands (Files tab) +use crate::error::AppError; + #[derive(serde::Serialize)] pub struct DirEntry { pub name: String, @@ -19,16 +21,18 @@ pub enum FileContent { } #[tauri::command] -pub fn list_directory_children(path: String) -> Result, String> { +pub fn list_directory_children(path: String) -> Result, AppError> { let dir = std::path::Path::new(&path); if !dir.is_dir() { - return Err(format!("Not a directory: {path}")); + return Err(AppError::not_found(format!("Not a directory: {path}"))); } let mut entries = Vec::new(); - let read_dir = std::fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {e}"))?; + let read_dir = std::fs::read_dir(dir) + .map_err(|e| AppError::filesystem(format!("Failed to read directory: {e}")))?; for entry in read_dir { - let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; - let metadata = entry.metadata().map_err(|e| format!("Failed to read metadata: {e}"))?; + let entry = entry.map_err(|e| AppError::filesystem(format!("Failed to read entry: {e}")))?; + let metadata = entry.metadata() + .map_err(|e| AppError::filesystem(format!("Failed to read metadata: {e}")))?; let name = entry.file_name().to_string_lossy().into_owned(); if name.starts_with('.') { continue; @@ -57,12 +61,13 @@ pub fn list_directory_children(path: String) -> Result, String> { } #[tauri::command] -pub fn read_file_content(path: String) -> Result { +pub fn read_file_content(path: String) -> Result { let file_path = std::path::Path::new(&path); if !file_path.is_file() { - return Err(format!("Not a file: {path}")); + return Err(AppError::not_found(format!("Not a file: {path}"))); } - let metadata = std::fs::metadata(&path).map_err(|e| format!("Failed to read metadata: {e}"))?; + let metadata = std::fs::metadata(&path) + .map_err(|e| AppError::filesystem(format!("Failed to read metadata: {e}")))?; let size = metadata.len(); if size > 10 * 1024 * 1024 { @@ -84,7 +89,7 @@ pub fn read_file_content(path: String) -> Result { } let content = std::fs::read_to_string(&path) - .map_err(|_| format!("Binary or non-UTF-8 file"))?; + .map_err(|_| AppError::validation("Binary or non-UTF-8 file"))?; let lang = match ext.as_str() { "rs" => "rust", @@ -111,17 +116,17 @@ pub fn read_file_content(path: String) -> Result { } #[tauri::command] -pub fn write_file_content(path: String, content: String) -> Result<(), String> { +pub fn write_file_content(path: String, content: String) -> Result<(), AppError> { let file_path = std::path::Path::new(&path); if !file_path.is_file() { - return Err(format!("Not an existing file: {path}")); + return Err(AppError::not_found(format!("Not an existing file: {path}"))); } std::fs::write(&path, content.as_bytes()) - .map_err(|e| format!("Failed to write file: {e}")) + .map_err(|e| AppError::filesystem(format!("Failed to write file: {e}"))) } #[tauri::command] -pub async fn pick_directory(window: tauri::Window) -> Result, String> { +pub async fn pick_directory(window: tauri::Window) -> Result, AppError> { let dialog = rfd::AsyncFileDialog::new() .set_title("Select Directory") .set_parent(&window); diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index b05185a..3e9e892 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -1,16 +1,17 @@ +use crate::error::AppError; use crate::groups::{GroupsFile, MdFileEntry}; #[tauri::command] -pub fn groups_load() -> Result { - crate::groups::load_groups() +pub fn groups_load() -> Result { + crate::groups::load_groups().map_err(AppError::config) } #[tauri::command] -pub fn groups_save(config: GroupsFile) -> Result<(), String> { - crate::groups::save_groups(&config) +pub fn groups_save(config: GroupsFile) -> Result<(), AppError> { + crate::groups::save_groups(&config).map_err(AppError::config) } #[tauri::command] -pub fn discover_markdown_files(cwd: String) -> Result, String> { - crate::groups::discover_markdown_files(&cwd) +pub fn discover_markdown_files(cwd: String) -> Result, AppError> { + crate::groups::discover_markdown_files(&cwd).map_err(AppError::filesystem) } diff --git a/src-tauri/src/commands/persistence.rs b/src-tauri/src/commands/persistence.rs index c8c5c39..4869608 100644 --- a/src-tauri/src/commands/persistence.rs +++ b/src-tauri/src/commands/persistence.rs @@ -1,5 +1,6 @@ use tauri::State; use crate::AppState; +use crate::error::AppError; use crate::session::{AgentMessageRecord, ProjectAgentState, SessionMetric, SessionAnchorRecord}; // --- Agent message persistence --- @@ -11,7 +12,7 @@ pub fn agent_messages_save( project_id: String, sdk_session_id: Option, messages: Vec, -) -> Result<(), String> { +) -> Result<(), AppError> { state.session_db.save_agent_messages( &session_id, &project_id, @@ -24,7 +25,7 @@ pub fn agent_messages_save( pub fn agent_messages_load( state: State<'_, AppState>, project_id: String, -) -> Result, String> { +) -> Result, AppError> { state.session_db.load_agent_messages(&project_id) } @@ -34,7 +35,7 @@ pub fn agent_messages_load( pub fn project_agent_state_save( state: State<'_, AppState>, agent_state: ProjectAgentState, -) -> Result<(), String> { +) -> Result<(), AppError> { state.session_db.save_project_agent_state(&agent_state) } @@ -42,7 +43,7 @@ pub fn project_agent_state_save( pub fn project_agent_state_load( state: State<'_, AppState>, project_id: String, -) -> Result, String> { +) -> Result, AppError> { state.session_db.load_project_agent_state(&project_id) } @@ -52,7 +53,7 @@ pub fn project_agent_state_load( pub fn session_metric_save( state: State<'_, AppState>, metric: SessionMetric, -) -> Result<(), String> { +) -> Result<(), AppError> { state.session_db.save_session_metric(&metric) } @@ -61,7 +62,7 @@ pub fn session_metrics_load( state: State<'_, AppState>, project_id: String, limit: i64, -) -> Result, String> { +) -> Result, AppError> { state.session_db.load_session_metrics(&project_id, limit) } @@ -71,7 +72,7 @@ pub fn session_metrics_load( pub fn session_anchors_save( state: State<'_, AppState>, anchors: Vec, -) -> Result<(), String> { +) -> Result<(), AppError> { state.session_db.save_session_anchors(&anchors) } @@ -79,7 +80,7 @@ pub fn session_anchors_save( pub fn session_anchors_load( state: State<'_, AppState>, project_id: String, -) -> Result, String> { +) -> Result, AppError> { state.session_db.load_session_anchors(&project_id) } @@ -87,7 +88,7 @@ pub fn session_anchors_load( pub fn session_anchor_delete( state: State<'_, AppState>, id: String, -) -> Result<(), String> { +) -> Result<(), AppError> { state.session_db.delete_session_anchor(&id) } @@ -95,7 +96,7 @@ pub fn session_anchor_delete( pub fn session_anchors_clear( state: State<'_, AppState>, project_id: String, -) -> Result<(), String> { +) -> Result<(), AppError> { state.session_db.delete_project_anchors(&project_id) } @@ -104,6 +105,6 @@ pub fn session_anchor_update_type( state: State<'_, AppState>, id: String, anchor_type: String, -) -> Result<(), String> { +) -> Result<(), AppError> { state.session_db.update_anchor_type(&id, &anchor_type) } diff --git a/src-tauri/src/commands/pty.rs b/src-tauri/src/commands/pty.rs index f4aa5cc..29adf12 100644 --- a/src-tauri/src/commands/pty.rs +++ b/src-tauri/src/commands/pty.rs @@ -1,5 +1,6 @@ use tauri::State; use crate::AppState; +use crate::error::AppError; use crate::pty::PtyOptions; #[tauri::command] @@ -7,13 +8,13 @@ use crate::pty::PtyOptions; pub fn pty_spawn( state: State<'_, AppState>, options: PtyOptions, -) -> Result { - state.pty_manager.spawn(options) +) -> Result { + state.pty_manager.spawn(options).map_err(AppError::sidecar) } #[tauri::command] -pub fn pty_write(state: State<'_, AppState>, id: String, data: String) -> Result<(), String> { - state.pty_manager.write(&id, &data) +pub fn pty_write(state: State<'_, AppState>, id: String, data: String) -> Result<(), AppError> { + state.pty_manager.write(&id, &data).map_err(AppError::sidecar) } #[tauri::command] @@ -22,12 +23,12 @@ pub fn pty_resize( id: String, cols: u16, rows: u16, -) -> Result<(), String> { - state.pty_manager.resize(&id, cols, rows) +) -> Result<(), AppError> { + state.pty_manager.resize(&id, cols, rows).map_err(AppError::sidecar) } #[tauri::command] #[tracing::instrument(skip(state))] -pub fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), String> { - state.pty_manager.kill(&id) +pub fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), AppError> { + state.pty_manager.kill(&id).map_err(AppError::sidecar) } diff --git a/src-tauri/src/commands/secrets.rs b/src-tauri/src/commands/secrets.rs index 1e35387..fd092d0 100644 --- a/src-tauri/src/commands/secrets.rs +++ b/src-tauri/src/commands/secrets.rs @@ -1,23 +1,24 @@ +use crate::error::AppError; use crate::secrets::SecretsManager; #[tauri::command] -pub fn secrets_store(key: String, value: String) -> Result<(), String> { - SecretsManager::store_secret(&key, &value) +pub fn secrets_store(key: String, value: String) -> Result<(), AppError> { + SecretsManager::store_secret(&key, &value).map_err(AppError::auth) } #[tauri::command] -pub fn secrets_get(key: String) -> Result, String> { - SecretsManager::get_secret(&key) +pub fn secrets_get(key: String) -> Result, AppError> { + SecretsManager::get_secret(&key).map_err(AppError::auth) } #[tauri::command] -pub fn secrets_delete(key: String) -> Result<(), String> { - SecretsManager::delete_secret(&key) +pub fn secrets_delete(key: String) -> Result<(), AppError> { + SecretsManager::delete_secret(&key).map_err(AppError::auth) } #[tauri::command] -pub fn secrets_list() -> Result, String> { - SecretsManager::list_keys() +pub fn secrets_list() -> Result, AppError> { + SecretsManager::list_keys().map_err(AppError::auth) } #[tauri::command] diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index bbfd6d6..76f1750 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -1,81 +1,82 @@ use tauri::State; use crate::AppState; +use crate::error::AppError; use crate::session::{Session, LayoutState, SshSession}; // --- Session persistence --- #[tauri::command] -pub fn session_list(state: State<'_, AppState>) -> Result, String> { +pub fn session_list(state: State<'_, AppState>) -> Result, AppError> { state.session_db.list_sessions() } #[tauri::command] -pub fn session_save(state: State<'_, AppState>, session: Session) -> Result<(), String> { +pub fn session_save(state: State<'_, AppState>, session: Session) -> Result<(), AppError> { state.session_db.save_session(&session) } #[tauri::command] -pub fn session_delete(state: State<'_, AppState>, id: String) -> Result<(), String> { +pub fn session_delete(state: State<'_, AppState>, id: String) -> Result<(), AppError> { state.session_db.delete_session(&id) } #[tauri::command] -pub fn session_update_title(state: State<'_, AppState>, id: String, title: String) -> Result<(), String> { +pub fn session_update_title(state: State<'_, AppState>, id: String, title: String) -> Result<(), AppError> { state.session_db.update_title(&id, &title) } #[tauri::command] -pub fn session_touch(state: State<'_, AppState>, id: String) -> Result<(), String> { +pub fn session_touch(state: State<'_, AppState>, id: String) -> Result<(), AppError> { state.session_db.touch_session(&id) } #[tauri::command] -pub fn session_update_group(state: State<'_, AppState>, id: String, group_name: String) -> Result<(), String> { +pub fn session_update_group(state: State<'_, AppState>, id: String, group_name: String) -> Result<(), AppError> { state.session_db.update_group(&id, &group_name) } // --- Layout --- #[tauri::command] -pub fn layout_save(state: State<'_, AppState>, layout: LayoutState) -> Result<(), String> { +pub fn layout_save(state: State<'_, AppState>, layout: LayoutState) -> Result<(), AppError> { state.session_db.save_layout(&layout) } #[tauri::command] -pub fn layout_load(state: State<'_, AppState>) -> Result { +pub fn layout_load(state: State<'_, AppState>) -> Result { state.session_db.load_layout() } // --- Settings --- #[tauri::command] -pub fn settings_get(state: State<'_, AppState>, key: String) -> Result, String> { +pub fn settings_get(state: State<'_, AppState>, key: String) -> Result, AppError> { state.session_db.get_setting(&key) } #[tauri::command] -pub fn settings_set(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> { +pub fn settings_set(state: State<'_, AppState>, key: String, value: String) -> Result<(), AppError> { state.session_db.set_setting(&key, &value) } #[tauri::command] -pub fn settings_list(state: State<'_, AppState>) -> Result, String> { +pub fn settings_list(state: State<'_, AppState>) -> Result, AppError> { state.session_db.get_all_settings() } // --- SSH sessions --- #[tauri::command] -pub fn ssh_session_list(state: State<'_, AppState>) -> Result, String> { +pub fn ssh_session_list(state: State<'_, AppState>) -> Result, AppError> { state.session_db.list_ssh_sessions() } #[tauri::command] -pub fn ssh_session_save(state: State<'_, AppState>, session: SshSession) -> Result<(), String> { +pub fn ssh_session_save(state: State<'_, AppState>, session: SshSession) -> Result<(), AppError> { state.session_db.save_ssh_session(&session) } #[tauri::command] -pub fn ssh_session_delete(state: State<'_, AppState>, id: String) -> Result<(), String> { +pub fn ssh_session_delete(state: State<'_, AppState>, id: String) -> Result<(), AppError> { state.session_db.delete_ssh_session(&id) } diff --git a/src-tauri/src/commands/watcher.rs b/src-tauri/src/commands/watcher.rs index ebacbae..ef116e3 100644 --- a/src-tauri/src/commands/watcher.rs +++ b/src-tauri/src/commands/watcher.rs @@ -1,5 +1,6 @@ use tauri::State; use crate::AppState; +use crate::error::AppError; use crate::fs_watcher::FsWatcherStatus; #[tauri::command] @@ -8,8 +9,8 @@ pub fn file_watch( state: State<'_, AppState>, pane_id: String, path: String, -) -> Result { - state.file_watcher.watch(&app, &pane_id, &path) +) -> Result { + state.file_watcher.watch(&app, &pane_id, &path).map_err(AppError::filesystem) } #[tauri::command] @@ -18,8 +19,8 @@ pub fn file_unwatch(state: State<'_, AppState>, pane_id: String) { } #[tauri::command] -pub fn file_read(state: State<'_, AppState>, path: String) -> Result { - state.file_watcher.read_file(&path) +pub fn file_read(state: State<'_, AppState>, path: String) -> Result { + state.file_watcher.read_file(&path).map_err(AppError::filesystem) } #[tauri::command] @@ -28,8 +29,8 @@ pub fn fs_watch_project( state: State<'_, AppState>, project_id: String, cwd: String, -) -> Result<(), String> { - state.fs_watcher.watch_project(&app, &project_id, &cwd) +) -> Result<(), AppError> { + state.fs_watcher.watch_project(&app, &project_id, &cwd).map_err(AppError::filesystem) } #[tauri::command] diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..220929e --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,222 @@ +//! Typed application error enum. +//! +//! Replaces `Result` 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(&self, serializer: S) -> Result + 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 for AppError { + fn from(e: rusqlite::Error) -> Self { + AppError::Database { + detail: e.to_string(), + } + } +} + +impl From for AppError { + fn from(e: std::io::Error) -> Self { + AppError::Filesystem { + detail: e.to_string(), + } + } +} + +impl From 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` and +/// Tauri commands that now return `Result`). +impl From 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) -> Self { + AppError::Database { + detail: detail.into(), + } + } + + pub fn auth(detail: impl Into) -> Self { + AppError::Auth { + detail: detail.into(), + } + } + + pub fn filesystem(detail: impl Into) -> Self { + AppError::Filesystem { + detail: detail.into(), + } + } + + pub fn not_found(detail: impl Into) -> Self { + AppError::NotFound { + detail: detail.into(), + } + } + + pub fn validation(detail: impl Into) -> Self { + AppError::Validation { + detail: detail.into(), + } + } + + pub fn sidecar(detail: impl Into) -> Self { + AppError::Sidecar { + detail: detail.into(), + } + } + + pub fn config(detail: impl Into) -> Self { + AppError::Config { + detail: detail.into(), + } + } + + pub fn network(detail: impl Into) -> Self { + AppError::Network { + detail: detail.into(), + } + } + + pub fn internal(detail: impl Into) -> 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::("not json") + .unwrap_err() + .into(); + assert!(matches!(e, AppError::Validation { .. })); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7af146..90b71ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod btmsg; mod bttask; mod commands; +pub mod error; mod ctx; mod event_sink; mod fs_watcher;