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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
264
src-tauri/src/error.rs
Normal file
264
src-tauri/src/error.rs
Normal file
@@ -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<T, String>`) 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<String>) -> 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<String>) -> 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<crate::data::bridge::BridgeError> 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<crate::data::lore::LoreError> 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<crate::data::beads::BeadsError> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user