use crate::permissions::DecisionReason;
use thiserror::Error;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Error)]
pub enum Error {
#[error("LLM error ({provider}): {message}")]
Llm { provider: String, message: String },
#[error("LLM rate limited ({provider}): retry after {retry_after_ms}ms")]
RateLimited {
provider: String,
retry_after_ms: u64,
},
#[error("tool error ({name}): {message}")]
Tool { name: String, message: String },
#[error("bad tool arguments ({name}): {message}")]
BadToolArgs { name: String, message: String },
#[error("tool `{0}` not found")]
UnknownTool(String),
#[error("permission denied: tool {name} ({reason:?})")]
PermissionDenied {
name: String,
reason: DecisionReason,
},
#[error("permission denial limit exceeded for tool {name}")]
PermissionDeniedLimit { name: String },
#[error("llm response truncated by provider (finish_reason = {0:?})")]
ProviderTruncated(String),
#[error("MCP error ({server}): {message}")]
Mcp { server: String, message: String },
#[error("config error: {message}")]
Config { message: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("timeout after {duration_ms}ms")]
Timeout { duration_ms: u64 },
#[error("storage error: {message}")]
Storage { message: String },
#[error("{0}")]
Other(String),
}
impl Error {
pub fn is_retryable(&self) -> bool {
matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
}
pub fn is_transient(&self) -> bool {
matches!(
self,
Error::RateLimited { .. } | Error::Timeout { .. } | Error::Http(_) | Error::Io(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_llm_error_format() {
let err = Error::Llm {
provider: "openai".into(),
message: "rate limit hit".into(),
};
let msg = err.to_string();
assert!(msg.contains("openai"));
assert!(msg.contains("rate limit"));
}
#[test]
fn test_rate_limited_format() {
let err = Error::RateLimited {
provider: "deepseek".into(),
retry_after_ms: 5000,
};
let msg = err.to_string();
assert!(msg.contains("deepseek"));
assert!(msg.contains("5000"));
}
#[test]
fn test_tool_error_format() {
let err = Error::Tool {
name: "run_shell".into(),
message: "command not found".into(),
};
let msg = err.to_string();
assert!(msg.contains("run_shell"));
assert!(msg.contains("command not found"));
}
#[test]
fn test_bad_tool_args_format() {
let err = Error::BadToolArgs {
name: "read_file".into(),
message: "missing path".into(),
};
let msg = err.to_string();
assert!(msg.contains("read_file"));
assert!(msg.contains("missing path"));
}
#[test]
fn test_permission_denied_format() {
let err = Error::PermissionDenied {
name: "run_shell".into(),
reason: crate::permissions::DecisionReason::Mode(
crate::permissions::PermissionMode::DontAsk,
),
};
let msg = err.to_string();
assert!(msg.contains("run_shell"));
assert!(msg.contains("DontAsk"));
}
#[test]
fn test_mcp_error_format() {
let err = Error::Mcp {
server: "filesystem".into(),
message: "connection refused".into(),
};
let msg = err.to_string();
assert!(msg.contains("filesystem"));
assert!(msg.contains("connection refused"));
}
#[test]
fn test_config_error_format() {
let err = Error::Config {
message: "missing RECURSIVE_API_KEY".into(),
};
let msg = err.to_string();
assert!(msg.contains("missing RECURSIVE_API_KEY"));
}
#[test]
fn test_timeout_format() {
let err = Error::Timeout { duration_ms: 30000 };
let msg = err.to_string();
assert!(msg.contains("30000"));
}
#[test]
fn test_is_retryable() {
assert!(Error::RateLimited {
provider: "x".into(),
retry_after_ms: 1000
}
.is_retryable());
assert!(Error::Timeout { duration_ms: 5000 }.is_retryable());
assert!(!Error::Tool {
name: "x".into(),
message: "fail".into()
}
.is_retryable());
assert!(!Error::Config {
message: "bad".into()
}
.is_retryable());
}
#[test]
fn test_is_transient() {
assert!(Error::RateLimited {
provider: "x".into(),
retry_after_ms: 1000
}
.is_transient());
assert!(Error::Timeout { duration_ms: 5000 }.is_transient());
assert!(!Error::Tool {
name: "x".into(),
message: "fail".into()
}
.is_transient());
assert!(!Error::Config {
message: "bad".into()
}
.is_transient());
}
#[test]
fn test_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err: Error = io_err.into();
assert!(matches!(err, Error::Io(_)));
assert!(err.to_string().contains("file not found"));
}
#[test]
fn test_unknown_tool_format() {
let err = Error::UnknownTool("nonexistent".into());
let msg = err.to_string();
assert!(msg.contains("nonexistent"));
}
#[test]
fn test_provider_truncated_format() {
let err = Error::ProviderTruncated("length".into());
let msg = err.to_string();
assert!(msg.contains("length"));
}
}