- Add truncate_title() function for bead titles (max 60 chars with ellipsis) - Add escape_project() to replace / with :: in mapping keys for filesystem safety - Add InvalidInput error code for validation errors - Add comprehensive tests for truncation, escaping, and Unicode handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
333 lines
10 KiB
Rust
333 lines
10 KiB
Rust
//! Mission Control error types
|
|
//!
|
|
//! Provides structured errors for IPC commands, enabling the frontend
|
|
//! to handle errors programmatically rather than parsing strings.
|
|
|
|
use serde::Serialize;
|
|
use specta::Type;
|
|
|
|
/// 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, Type)]
|
|
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, Type)]
|
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
pub enum McErrorCode {
|
|
// Lore errors
|
|
LoreUnavailable,
|
|
LoreUnhealthy,
|
|
LoreFetchFailed,
|
|
|
|
// Bridge errors
|
|
BridgeLocked,
|
|
BridgeMapCorrupted,
|
|
BridgeSyncFailed,
|
|
|
|
// Beads errors
|
|
BeadsUnavailable,
|
|
BeadsCreateFailed,
|
|
BeadsCloseFailed,
|
|
|
|
// Bv errors
|
|
BvUnavailable,
|
|
BvTriageFailed,
|
|
|
|
// General errors
|
|
IoError,
|
|
InternalError,
|
|
InvalidInput,
|
|
}
|
|
|
|
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::BvUnavailable
|
|
| 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?",
|
|
)
|
|
}
|
|
|
|
/// Create an IO error with context
|
|
pub fn io_error(context: impl Into<String>) -> Self {
|
|
Self::new(McErrorCode::IoError, context)
|
|
}
|
|
|
|
/// Create a bv unavailable error
|
|
pub fn bv_unavailable() -> Self {
|
|
Self::new(
|
|
McErrorCode::BvUnavailable,
|
|
"bv CLI not found -- is beads installed?",
|
|
)
|
|
}
|
|
|
|
/// Create an invalid input error
|
|
pub fn invalid_input(message: impl Into<String>) -> Self {
|
|
Self {
|
|
code: McErrorCode::InvalidInput,
|
|
message: message.into(),
|
|
recoverable: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
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),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Conversion from bv errors
|
|
impl From<crate::data::bv::BvError> for McError {
|
|
fn from(err: crate::data::bv::BvError) -> Self {
|
|
use crate::data::bv::BvError;
|
|
|
|
match err {
|
|
BvError::ExecutionFailed(_) => Self::bv_unavailable(),
|
|
BvError::CommandFailed(msg) => Self::new(
|
|
McErrorCode::BvTriageFailed,
|
|
format!("bv command failed: {}", msg),
|
|
),
|
|
BvError::ParseFailed(msg) => Self::new(
|
|
McErrorCode::BvTriageFailed,
|
|
format!("Failed to parse bv 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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bv_error_conversion() {
|
|
use crate::data::bv::BvError;
|
|
|
|
// ExecutionFailed -> BvUnavailable (recoverable)
|
|
let err: McError = BvError::ExecutionFailed("not found".to_string()).into();
|
|
assert_eq!(err.code, McErrorCode::BvUnavailable);
|
|
assert!(err.recoverable);
|
|
|
|
// CommandFailed -> BvTriageFailed (not recoverable)
|
|
let err: McError = BvError::CommandFailed("failed".to_string()).into();
|
|
assert_eq!(err.code, McErrorCode::BvTriageFailed);
|
|
assert!(!err.recoverable);
|
|
|
|
// ParseFailed -> BvTriageFailed (not recoverable)
|
|
let err: McError = BvError::ParseFailed("bad json".to_string()).into();
|
|
assert_eq!(err.code, McErrorCode::BvTriageFailed);
|
|
assert!(!err.recoverable);
|
|
}
|
|
}
|