Files
mission-control/src-tauri/src/error.rs
teernisse a949f51bab feat(bd-3ke): add title truncation and key escaping for GitLab-to-Beads bridge
- 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>
2026-02-26 11:00:07 -05:00

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