1use thiserror::Error;
7
8pub type Result<T, E = Error> = std::result::Result<T, E>;
9
10#[derive(Debug, Error)]
11pub enum Error {
12 #[error("LLM error ({provider}): {message}")]
14 Llm { provider: String, message: String },
15
16 #[error("LLM rate limited ({provider}): retry after {retry_after_ms}ms")]
18 RateLimited {
19 provider: String,
20 retry_after_ms: u64,
21 },
22
23 #[error("tool error ({name}): {message}")]
25 Tool { name: String, message: String },
26
27 #[error("bad tool arguments ({name}): {message}")]
29 BadToolArgs { name: String, message: String },
30
31 #[error("tool `{0}` not found")]
33 UnknownTool(String),
34
35 #[error("permission denied: tool {name}")]
37 PermissionDenied { name: String },
38
39 #[error("llm response truncated by provider (finish_reason = {0:?})")]
41 ProviderTruncated(String),
42
43 #[error("MCP error ({server}): {message}")]
45 Mcp { server: String, message: String },
46
47 #[error("config error: {message}")]
49 Config { message: String },
50
51 #[error("IO error: {0}")]
53 Io(#[from] std::io::Error),
54
55 #[error("HTTP error: {0}")]
57 Http(#[from] reqwest::Error),
58
59 #[error("JSON error: {0}")]
61 Json(#[from] serde_json::Error),
62
63 #[error("timeout after {duration_ms}ms")]
65 Timeout { duration_ms: u64 },
66
67 #[error("{0}")]
69 Other(String),
70}
71
72impl Error {
73 pub fn is_retryable(&self) -> bool {
75 matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
76 }
77
78 pub fn is_transient(&self) -> bool {
81 matches!(
82 self,
83 Error::RateLimited { .. } | Error::Timeout { .. } | Error::Http(_) | Error::Io(_)
84 )
85 }
86}
87
88impl From<anyhow::Error> for Error {
89 fn from(value: anyhow::Error) -> Self {
90 Error::Other(value.to_string())
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn test_llm_error_format() {
100 let err = Error::Llm {
101 provider: "openai".into(),
102 message: "rate limit hit".into(),
103 };
104 let msg = err.to_string();
105 assert!(msg.contains("openai"));
106 assert!(msg.contains("rate limit"));
107 }
108
109 #[test]
110 fn test_rate_limited_format() {
111 let err = Error::RateLimited {
112 provider: "deepseek".into(),
113 retry_after_ms: 5000,
114 };
115 let msg = err.to_string();
116 assert!(msg.contains("deepseek"));
117 assert!(msg.contains("5000"));
118 }
119
120 #[test]
121 fn test_tool_error_format() {
122 let err = Error::Tool {
123 name: "run_shell".into(),
124 message: "command not found".into(),
125 };
126 let msg = err.to_string();
127 assert!(msg.contains("run_shell"));
128 assert!(msg.contains("command not found"));
129 }
130
131 #[test]
132 fn test_bad_tool_args_format() {
133 let err = Error::BadToolArgs {
134 name: "read_file".into(),
135 message: "missing path".into(),
136 };
137 let msg = err.to_string();
138 assert!(msg.contains("read_file"));
139 assert!(msg.contains("missing path"));
140 }
141
142 #[test]
143 fn test_permission_denied_format() {
144 let err = Error::PermissionDenied {
145 name: "run_shell".into(),
146 };
147 let msg = err.to_string();
148 assert!(msg.contains("run_shell"));
149 }
150
151 #[test]
152 fn test_mcp_error_format() {
153 let err = Error::Mcp {
154 server: "filesystem".into(),
155 message: "connection refused".into(),
156 };
157 let msg = err.to_string();
158 assert!(msg.contains("filesystem"));
159 assert!(msg.contains("connection refused"));
160 }
161
162 #[test]
163 fn test_config_error_format() {
164 let err = Error::Config {
165 message: "missing RECURSIVE_API_KEY".into(),
166 };
167 let msg = err.to_string();
168 assert!(msg.contains("missing RECURSIVE_API_KEY"));
169 }
170
171 #[test]
172 fn test_timeout_format() {
173 let err = Error::Timeout { duration_ms: 30000 };
174 let msg = err.to_string();
175 assert!(msg.contains("30000"));
176 }
177
178 #[test]
179 fn test_is_retryable() {
180 assert!(Error::RateLimited {
181 provider: "x".into(),
182 retry_after_ms: 1000
183 }
184 .is_retryable());
185 assert!(Error::Timeout { duration_ms: 5000 }.is_retryable());
186 assert!(!Error::Tool {
187 name: "x".into(),
188 message: "fail".into()
189 }
190 .is_retryable());
191 assert!(!Error::Config {
192 message: "bad".into()
193 }
194 .is_retryable());
195 }
196
197 #[test]
198 fn test_is_transient() {
199 assert!(Error::RateLimited {
200 provider: "x".into(),
201 retry_after_ms: 1000
202 }
203 .is_transient());
204 assert!(Error::Timeout { duration_ms: 5000 }.is_transient());
205 assert!(!Error::Tool {
208 name: "x".into(),
209 message: "fail".into()
210 }
211 .is_transient());
212 assert!(!Error::Config {
213 message: "bad".into()
214 }
215 .is_transient());
216 }
217
218 #[test]
219 fn test_from_io_error() {
220 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
221 let err: Error = io_err.into();
222 assert!(matches!(err, Error::Io(_)));
223 assert!(err.to_string().contains("file not found"));
224 }
225
226 #[test]
227 fn test_from_anyhow_error() {
228 let anyhow_err = anyhow::anyhow!("something went wrong");
229 let err: Error = anyhow_err.into();
230 assert!(matches!(err, Error::Other(_)));
231 assert!(err.to_string().contains("something went wrong"));
232 }
233
234 #[test]
235 fn test_unknown_tool_format() {
236 let err = Error::UnknownTool("nonexistent".into());
237 let msg = err.to_string();
238 assert!(msg.contains("nonexistent"));
239 }
240
241 #[test]
242 fn test_provider_truncated_format() {
243 let err = Error::ProviderTruncated("length".into());
244 let msg = err.to_string();
245 assert!(msg.contains("length"));
246 }
247}