From 6eebe082c8c54910fb7abe9c30bae7cdfddffabe Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 09:32:16 -0500 Subject: [PATCH] feat: add structured error types for Tauri IPC commands Introduces McError and McErrorCode to replace string-based errors in Tauri commands. This enables the frontend to handle errors programmatically rather than parsing error messages. Key design decisions: - Error codes use SCREAMING_SNAKE_CASE for frontend pattern matching - Each error indicates whether it's recoverable (user can retry) - Automatic conversion from LoreError, BeadsError, and BridgeError - Errors serialize to JSON for consistent IPC transport Error categories: - Lore: LORE_UNAVAILABLE, LORE_UNHEALTHY, LORE_FETCH_FAILED - Bridge: BRIDGE_LOCKED, BRIDGE_MAP_CORRUPTED, BRIDGE_SYNC_FAILED - Beads: BEADS_UNAVAILABLE, BEADS_CREATE_FAILED, BEADS_CLOSE_FAILED - General: IO_ERROR, INTERNAL_ERROR Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/data/mod.rs | 3 +- src-tauri/src/error.rs | 264 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/error.rs diff --git a/src-tauri/src/data/mod.rs b/src-tauri/src/data/mod.rs index 1ba9baa..8e1080d 100644 --- a/src-tauri/src/data/mod.rs +++ b/src-tauri/src/data/mod.rs @@ -5,6 +5,7 @@ //! - br CLI (beads task management) //! - MC local state (mapping, decisions, settings) -pub mod lore; pub mod beads; +pub mod bridge; +pub mod lore; pub mod state; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..477ce08 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,264 @@ +//! Mission Control error types +//! +//! Provides structured errors for IPC commands, enabling the frontend +//! to handle errors programmatically rather than parsing strings. + +use serde::Serialize; + +/// Structured error type for Tauri IPC commands. +/// +/// This replaces string-based errors (`Result`) with typed errors +/// that the frontend can handle programmatically. +#[derive(Debug, Clone, Serialize)] +pub struct McError { + /// Machine-readable error code (e.g., "LORE_UNAVAILABLE", "BRIDGE_LOCKED") + pub code: McErrorCode, + /// Human-readable error message + pub message: String, + /// Whether this error is recoverable (user can retry) + pub recoverable: bool, +} + +/// Error codes for frontend handling +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum McErrorCode { + // Lore errors + LoreUnavailable, + LoreUnhealthy, + LoreFetchFailed, + + // Bridge errors + BridgeLocked, + BridgeMapCorrupted, + BridgeSyncFailed, + + // Beads errors + BeadsUnavailable, + BeadsCreateFailed, + BeadsCloseFailed, + + // General errors + IoError, + InternalError, +} + +impl McError { + /// Create a new error with the given code and message + pub fn new(code: McErrorCode, message: impl Into) -> Self { + let recoverable = matches!( + code, + McErrorCode::LoreUnavailable + | McErrorCode::BeadsUnavailable + | McErrorCode::BridgeLocked + | McErrorCode::IoError + ); + + Self { + code, + message: message.into(), + recoverable, + } + } + + /// Create a lore unavailable error + pub fn lore_unavailable() -> Self { + Self::new( + McErrorCode::LoreUnavailable, + "lore CLI not found -- is it installed?", + ) + } + + /// Create a lore unhealthy error + pub fn lore_unhealthy() -> Self { + Self::new( + McErrorCode::LoreUnhealthy, + "lore health check failed -- run 'lore index --full'", + ) + } + + /// Create a bridge locked error + pub fn bridge_locked() -> Self { + Self::new( + McErrorCode::BridgeLocked, + "Another Mission Control instance is running", + ) + } + + /// Create an internal error with context + pub fn internal(context: impl Into) -> Self { + Self::new(McErrorCode::InternalError, context) + } + + /// Create a beads unavailable error + pub fn beads_unavailable() -> Self { + Self::new( + McErrorCode::BeadsUnavailable, + "br CLI not found -- is beads installed?", + ) + } +} + +impl std::fmt::Display for McError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for McError {} + +// Conversion from bridge errors +impl From for McError { + fn from(err: crate::data::bridge::BridgeError) -> Self { + use crate::data::bridge::BridgeError; + + match err { + BridgeError::InstanceLocked => Self::bridge_locked(), + BridgeError::Lore(e) => Self::new( + McErrorCode::LoreFetchFailed, + format!("Lore error: {}", e), + ), + BridgeError::Beads(e) => Self::new( + McErrorCode::BeadsCreateFailed, + format!("Beads error: {}", e), + ), + BridgeError::Io(e) => Self::new(McErrorCode::IoError, format!("IO error: {}", e)), + BridgeError::Json(e) => Self::new( + McErrorCode::BridgeMapCorrupted, + format!("Map file corrupted: {}", e), + ), + } + } +} + +// Conversion from lore errors +impl From for McError { + fn from(err: crate::data::lore::LoreError) -> Self { + use crate::data::lore::LoreError; + + match err { + LoreError::ExecutionFailed(_) => Self::lore_unavailable(), + LoreError::CommandFailed(msg) => Self::new( + McErrorCode::LoreFetchFailed, + format!("lore command failed: {}", msg), + ), + LoreError::ParseFailed(msg) => Self::new( + McErrorCode::LoreFetchFailed, + format!("Failed to parse lore response: {}", msg), + ), + } + } +} + +// Conversion from beads errors +impl From for McError { + fn from(err: crate::data::beads::BeadsError) -> Self { + use crate::data::beads::BeadsError; + + match err { + BeadsError::ExecutionFailed(_) => Self::beads_unavailable(), + BeadsError::CommandFailed(msg) => Self::new( + McErrorCode::BeadsCreateFailed, + format!("br command failed: {}", msg), + ), + BeadsError::ParseFailed(msg) => Self::new( + McErrorCode::BeadsCreateFailed, + format!("Failed to parse br response: {}", msg), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_serialization() { + let err = McError::lore_unavailable(); + let json = serde_json::to_string(&err).unwrap(); + + assert!(json.contains("LORE_UNAVAILABLE")); + assert!(json.contains("recoverable")); + } + + #[test] + fn test_recoverable_errors() { + assert!(McError::lore_unavailable().recoverable); + assert!(McError::bridge_locked().recoverable); + assert!(!McError::internal("test").recoverable); + } + + #[test] + fn test_error_codes_are_screaming_snake_case() { + let err = McError::new(McErrorCode::BridgeMapCorrupted, "test"); + let json = serde_json::to_string(&err).unwrap(); + + assert!(json.contains("BRIDGE_MAP_CORRUPTED")); + } + + #[test] + fn test_beads_unavailable_is_recoverable() { + assert!(McError::beads_unavailable().recoverable); + } + + #[test] + fn test_beads_error_conversion() { + use crate::data::beads::BeadsError; + + // ExecutionFailed -> BeadsUnavailable (recoverable) + let err: McError = BeadsError::ExecutionFailed("not found".to_string()).into(); + assert_eq!(err.code, McErrorCode::BeadsUnavailable); + assert!(err.recoverable); + + // CommandFailed -> BeadsCreateFailed (not recoverable) + let err: McError = BeadsError::CommandFailed("failed".to_string()).into(); + assert_eq!(err.code, McErrorCode::BeadsCreateFailed); + assert!(!err.recoverable); + + // ParseFailed -> BeadsCreateFailed (not recoverable) + let err: McError = BeadsError::ParseFailed("bad json".to_string()).into(); + assert_eq!(err.code, McErrorCode::BeadsCreateFailed); + assert!(!err.recoverable); + } + + #[test] + fn test_lore_error_conversion() { + use crate::data::lore::LoreError; + + // ExecutionFailed -> LoreUnavailable (recoverable) + let err: McError = LoreError::ExecutionFailed("not found".to_string()).into(); + assert_eq!(err.code, McErrorCode::LoreUnavailable); + assert!(err.recoverable); + + // CommandFailed -> LoreFetchFailed (not recoverable) + let err: McError = LoreError::CommandFailed("failed".to_string()).into(); + assert_eq!(err.code, McErrorCode::LoreFetchFailed); + assert!(!err.recoverable); + } + + #[test] + fn test_bridge_error_conversion() { + use crate::data::bridge::BridgeError; + + // InstanceLocked -> BridgeLocked (recoverable) + let err: McError = BridgeError::InstanceLocked.into(); + assert_eq!(err.code, McErrorCode::BridgeLocked); + assert!(err.recoverable); + + // Io -> IoError (recoverable) + let err: McError = BridgeError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + )) + .into(); + assert_eq!(err.code, McErrorCode::IoError); + assert!(err.recoverable); + + // Json -> BridgeMapCorrupted (not recoverable) + let json_err = serde_json::from_str::<()>("invalid").unwrap_err(); + let err: McError = BridgeError::Json(json_err).into(); + assert_eq!(err.code, McErrorCode::BridgeMapCorrupted); + assert!(!err.recoverable); + } +}