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:
teernisse
2026-02-26 09:32:16 -05:00
parent 8c9f66cdee
commit 6eebe082c8
2 changed files with 266 additions and 1 deletions

View File

@@ -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
View 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);
}
}