use serde::Serialize;
use serde_json::json;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCategory {
FileNotFound,
PermissionDenied,
PathOutsideBoundary,
ValidationFailed,
SerializationError,
ExternalCommandFailed,
CommandRejected,
Timeout,
NetworkError,
ResourceUnavailable,
InternalError,
UserCancelled,
}
impl ErrorCategory {
pub fn description(&self) -> &'static str {
match self {
Self::FileNotFound => "The requested file or path was not found",
Self::PermissionDenied => "Permission was denied for this operation",
Self::PathOutsideBoundary => "The path is outside the allowed project directory",
Self::ValidationFailed => "Input validation failed",
Self::SerializationError => "Failed to serialize or deserialize data",
Self::ExternalCommandFailed => "An external command or tool failed",
Self::CommandRejected => "The command was rejected (not in allowed list)",
Self::Timeout => "The operation timed out",
Self::NetworkError => "A network or connection error occurred",
Self::ResourceUnavailable => "The requested resource is not available",
Self::InternalError => "An internal error occurred",
Self::UserCancelled => "The operation was cancelled by the user",
}
}
pub fn is_recoverable(&self) -> bool {
matches!(
self,
Self::FileNotFound
| Self::ValidationFailed
| Self::Timeout
| Self::NetworkError
| Self::ResourceUnavailable
| Self::UserCancelled
)
}
pub fn code(&self) -> &'static str {
match self {
Self::FileNotFound => "FILE_NOT_FOUND",
Self::PermissionDenied => "PERMISSION_DENIED",
Self::PathOutsideBoundary => "PATH_OUTSIDE_BOUNDARY",
Self::ValidationFailed => "VALIDATION_FAILED",
Self::SerializationError => "SERIALIZATION_ERROR",
Self::ExternalCommandFailed => "EXTERNAL_COMMAND_FAILED",
Self::CommandRejected => "COMMAND_REJECTED",
Self::Timeout => "TIMEOUT",
Self::NetworkError => "NETWORK_ERROR",
Self::ResourceUnavailable => "RESOURCE_UNAVAILABLE",
Self::InternalError => "INTERNAL_ERROR",
Self::UserCancelled => "USER_CANCELLED",
}
}
}
impl fmt::Display for ErrorCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.code())
}
}
pub fn format_error_for_llm(
tool_name: &str,
category: ErrorCategory,
message: &str,
suggestions: Option<Vec<&str>>,
) -> String {
let mut error_obj = json!({
"error": true,
"tool": tool_name,
"category": category,
"code": category.code(),
"message": message,
"recoverable": category.is_recoverable(),
});
if let Some(suggs) = suggestions {
if !suggs.is_empty() {
error_obj["suggestions"] = json!(suggs);
}
}
serde_json::to_string_pretty(&error_obj).unwrap_or_else(|_| {
format!(
r#"{{"error": true, "tool": "{}", "message": "{}"}}"#,
tool_name, message
)
})
}
pub fn format_error_with_context(
tool_name: &str,
category: ErrorCategory,
message: &str,
context: &[(&str, serde_json::Value)],
) -> String {
let mut error_obj = json!({
"error": true,
"tool": tool_name,
"category": category,
"code": category.code(),
"message": message,
"recoverable": category.is_recoverable(),
});
if let Some(obj) = error_obj.as_object_mut() {
for (key, value) in context {
obj.insert((*key).to_string(), value.clone());
}
}
serde_json::to_string_pretty(&error_obj).unwrap_or_else(|_| {
format!(
r#"{{"error": true, "tool": "{}", "message": "{}"}}"#,
tool_name, message
)
})
}
pub trait ToolErrorContext<T, E> {
fn with_tool_context(self, tool_name: &str, operation: &str) -> Result<T, String>;
}
impl<T, E: fmt::Display> ToolErrorContext<T, E> for Result<T, E> {
fn with_tool_context(self, tool_name: &str, operation: &str) -> Result<T, String> {
self.map_err(|e| format!("[{}] {} failed: {}", tool_name, operation, e))
}
}
pub fn detect_error_category(error_msg: &str) -> ErrorCategory {
let lower = error_msg.to_lowercase();
if lower.contains("not found")
|| lower.contains("no such file")
|| lower.contains("does not exist")
{
ErrorCategory::FileNotFound
} else if lower.contains("permission denied") || lower.contains("access denied") {
ErrorCategory::PermissionDenied
} else if lower.contains("outside") && (lower.contains("project") || lower.contains("boundary"))
{
ErrorCategory::PathOutsideBoundary
} else if lower.contains("timeout") || lower.contains("timed out") {
ErrorCategory::Timeout
} else if lower.contains("connection")
|| lower.contains("network")
|| lower.contains("unreachable")
{
ErrorCategory::NetworkError
} else if lower.contains("serialize")
|| lower.contains("deserialize")
|| lower.contains("json")
|| lower.contains("parse")
{
ErrorCategory::SerializationError
} else if lower.contains("not allowed") || lower.contains("rejected") {
ErrorCategory::CommandRejected
} else if lower.contains("cancelled") || lower.contains("canceled") {
ErrorCategory::UserCancelled
} else if lower.contains("validation") || lower.contains("invalid") {
ErrorCategory::ValidationFailed
} else {
ErrorCategory::InternalError
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_category_codes() {
assert_eq!(ErrorCategory::FileNotFound.code(), "FILE_NOT_FOUND");
assert_eq!(ErrorCategory::PermissionDenied.code(), "PERMISSION_DENIED");
assert_eq!(ErrorCategory::CommandRejected.code(), "COMMAND_REJECTED");
}
#[test]
fn test_error_category_recoverable() {
assert!(ErrorCategory::FileNotFound.is_recoverable());
assert!(ErrorCategory::Timeout.is_recoverable());
assert!(!ErrorCategory::PermissionDenied.is_recoverable());
assert!(!ErrorCategory::InternalError.is_recoverable());
}
#[test]
fn test_format_error_for_llm() {
let json_str = format_error_for_llm(
"read_file",
ErrorCategory::FileNotFound,
"File not found: test.txt",
Some(vec!["Check path", "Use list_directory"]),
);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["error"], true);
assert_eq!(parsed["tool"], "read_file");
assert_eq!(parsed["code"], "FILE_NOT_FOUND");
assert_eq!(parsed["recoverable"], true);
assert!(parsed["suggestions"].is_array());
}
#[test]
fn test_format_error_with_context() {
let json_str = format_error_with_context(
"shell",
ErrorCategory::CommandRejected,
"Command not allowed",
&[
("blocked_command", json!("rm -rf /")),
("allowed_commands", json!(["ls", "cat"])),
],
);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["error"], true);
assert_eq!(parsed["blocked_command"], "rm -rf /");
assert!(parsed["allowed_commands"].is_array());
}
#[test]
fn test_detect_error_category() {
assert_eq!(
detect_error_category("File not found: config.yaml"),
ErrorCategory::FileNotFound
);
assert_eq!(
detect_error_category("Permission denied"),
ErrorCategory::PermissionDenied
);
assert_eq!(
detect_error_category("Path is outside project boundary"),
ErrorCategory::PathOutsideBoundary
);
assert_eq!(
detect_error_category("Connection timeout"),
ErrorCategory::Timeout
);
assert_eq!(
detect_error_category("JSON parse error"),
ErrorCategory::SerializationError
);
assert_eq!(
detect_error_category("Command not allowed"),
ErrorCategory::CommandRejected
);
}
#[test]
fn test_tool_error_context() {
let result: Result<(), std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file missing",
));
let with_context = result.with_tool_context("read_file", "reading config");
assert!(with_context.is_err());
let err_msg = with_context.unwrap_err();
assert!(err_msg.contains("[read_file]"));
assert!(err_msg.contains("reading config failed"));
}
}