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
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn test_llm_error_format() {
94 let err = Error::Llm {
95 provider: "openai".into(),
96 message: "rate limit hit".into(),
97 };
98 let msg = err.to_string();
99 assert!(msg.contains("openai"));
100 assert!(msg.contains("rate limit"));
101 }
102
103 #[test]
104 fn test_rate_limited_format() {
105 let err = Error::RateLimited {
106 provider: "deepseek".into(),
107 retry_after_ms: 5000,
108 };
109 let msg = err.to_string();
110 assert!(msg.contains("deepseek"));
111 assert!(msg.contains("5000"));
112 }
113
114 #[test]
115 fn test_tool_error_format() {
116 let err = Error::Tool {
117 name: "run_shell".into(),
118 message: "command not found".into(),
119 };
120 let msg = err.to_string();
121 assert!(msg.contains("run_shell"));
122 assert!(msg.contains("command not found"));
123 }
124
125 #[test]
126 fn test_bad_tool_args_format() {
127 let err = Error::BadToolArgs {
128 name: "read_file".into(),
129 message: "missing path".into(),
130 };
131 let msg = err.to_string();
132 assert!(msg.contains("read_file"));
133 assert!(msg.contains("missing path"));
134 }
135
136 #[test]
137 fn test_permission_denied_format() {
138 let err = Error::PermissionDenied {
139 name: "run_shell".into(),
140 };
141 let msg = err.to_string();
142 assert!(msg.contains("run_shell"));
143 }
144
145 #[test]
146 fn test_mcp_error_format() {
147 let err = Error::Mcp {
148 server: "filesystem".into(),
149 message: "connection refused".into(),
150 };
151 let msg = err.to_string();
152 assert!(msg.contains("filesystem"));
153 assert!(msg.contains("connection refused"));
154 }
155
156 #[test]
157 fn test_config_error_format() {
158 let err = Error::Config {
159 message: "missing RECURSIVE_API_KEY".into(),
160 };
161 let msg = err.to_string();
162 assert!(msg.contains("missing RECURSIVE_API_KEY"));
163 }
164
165 #[test]
166 fn test_timeout_format() {
167 let err = Error::Timeout { duration_ms: 30000 };
168 let msg = err.to_string();
169 assert!(msg.contains("30000"));
170 }
171
172 #[test]
173 fn test_is_retryable() {
174 assert!(Error::RateLimited {
175 provider: "x".into(),
176 retry_after_ms: 1000
177 }
178 .is_retryable());
179 assert!(Error::Timeout { duration_ms: 5000 }.is_retryable());
180 assert!(!Error::Tool {
181 name: "x".into(),
182 message: "fail".into()
183 }
184 .is_retryable());
185 assert!(!Error::Config {
186 message: "bad".into()
187 }
188 .is_retryable());
189 }
190
191 #[test]
192 fn test_is_transient() {
193 assert!(Error::RateLimited {
194 provider: "x".into(),
195 retry_after_ms: 1000
196 }
197 .is_transient());
198 assert!(Error::Timeout { duration_ms: 5000 }.is_transient());
199 assert!(!Error::Tool {
202 name: "x".into(),
203 message: "fail".into()
204 }
205 .is_transient());
206 assert!(!Error::Config {
207 message: "bad".into()
208 }
209 .is_transient());
210 }
211
212 #[test]
213 fn test_from_io_error() {
214 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
215 let err: Error = io_err.into();
216 assert!(matches!(err, Error::Io(_)));
217 assert!(err.to_string().contains("file not found"));
218 }
219
220 #[test]
221 fn test_unknown_tool_format() {
222 let err = Error::UnknownTool("nonexistent".into());
223 let msg = err.to_string();
224 assert!(msg.contains("nonexistent"));
225 }
226
227 #[test]
228 fn test_provider_truncated_format() {
229 let err = Error::ProviderTruncated("length".into());
230 let msg = err.to_string();
231 assert!(msg.contains("length"));
232 }
233}