1#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum Error {
23 #[error("Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code")]
27 CliNotFound,
28
29 #[error("CLI version {found} below minimum {required}")]
31 VersionMismatch {
32 found: String,
34 required: String,
36 },
37
38 #[error("Failed to spawn Claude process: {0}")]
40 SpawnFailed(#[source] std::io::Error),
41
42 #[error("Claude process exited with code {code:?}: {stderr}")]
46 ProcessExited {
47 code: Option<i32>,
49 stderr: String,
51 },
52
53 #[error("Failed to parse JSON: {message} (line: {line})")]
55 ParseError {
56 message: String,
58 line: String,
60 },
61
62 #[error("I/O error: {0}")]
64 Io(#[from] std::io::Error),
65
66 #[error("JSON error: {0}")]
68 Json(#[from] serde_json::Error),
69
70 #[error("Client not connected. Call connect() first.")]
72 NotConnected,
73
74 #[error("Transport error: {0}")]
76 Transport(String),
77
78 #[error("Invalid configuration: {0}")]
80 Config(String),
81
82 #[error("Control protocol error: {0}")]
85 ControlProtocol(String),
86
87 #[error("Image validation error: {0}")]
90 ImageValidation(String),
91
92 #[error("Operation timed out: {0}")]
94 Timeout(String),
95
96 #[error("Operation cancelled")]
98 Cancelled,
99}
100
101pub type Result<T> = std::result::Result<T, Error>;
104
105impl Error {
108 #[inline]
111 #[must_use]
112 pub fn is_process_exit(&self) -> bool {
113 matches!(self, Self::ProcessExited { .. })
114 }
115
116 #[inline]
119 #[must_use]
120 pub fn is_retriable(&self) -> bool {
121 matches!(self, Self::Io(_) | Self::Timeout(_) | Self::Transport(_))
122 }
123
124 #[inline]
127 #[must_use]
128 pub fn is_cancelled(&self) -> bool {
129 matches!(self, Self::Cancelled)
130 }
131}
132
133#[cfg(test)]
136mod tests {
137 use super::*;
138
139 fn display(e: &Error) -> String {
141 e.to_string()
142 }
143
144 #[test]
145 fn cli_not_found_display() {
146 let e = Error::CliNotFound;
147 assert!(
148 display(&e).contains("npm install -g @anthropic-ai/claude-code"),
149 "message should include install hint, got: {e}"
150 );
151 }
152
153 #[test]
154 fn version_mismatch_display() {
155 let e = Error::VersionMismatch {
156 found: "1.0.0".to_owned(),
157 required: "2.0.0".to_owned(),
158 };
159 let s = display(&e);
160 assert!(s.contains("1.0.0"), "should contain found version");
161 assert!(s.contains("2.0.0"), "should contain required version");
162 }
163
164 #[test]
165 fn spawn_failed_display() {
166 let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
167 let e = Error::SpawnFailed(inner);
168 assert!(display(&e).contains("Failed to spawn Claude process"));
169 }
170
171 #[test]
172 fn process_exited_display_with_code() {
173 let e = Error::ProcessExited {
174 code: Some(1),
175 stderr: "fatal error".to_owned(),
176 };
177 let s = display(&e);
178 assert!(s.contains("1"), "should contain exit code");
179 assert!(s.contains("fatal error"), "should contain stderr");
180 }
181
182 #[test]
183 fn process_exited_display_signal_kill() {
184 let e = Error::ProcessExited {
185 code: None,
186 stderr: String::new(),
187 };
188 assert!(display(&e).contains("None"));
190 }
191
192 #[test]
193 fn parse_error_display() {
194 let e = Error::ParseError {
195 message: "unexpected token".to_owned(),
196 line: "{bad json}".to_owned(),
197 };
198 let s = display(&e);
199 assert!(s.contains("unexpected token"));
200 assert!(s.contains("{bad json}"));
201 }
202
203 #[test]
204 fn io_error_display() {
205 let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
206 let e = Error::from(inner);
207 assert!(display(&e).contains("I/O error"));
208 }
209
210 #[test]
211 fn json_error_display() {
212 let inner: serde_json::Error =
213 serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
214 let e = Error::from(inner);
215 assert!(display(&e).contains("JSON error"));
216 }
217
218 #[test]
219 fn not_connected_display() {
220 let e = Error::NotConnected;
221 assert!(display(&e).contains("connect()"));
222 }
223
224 #[test]
225 fn transport_display() {
226 let e = Error::Transport("connection refused".to_owned());
227 assert!(display(&e).contains("connection refused"));
228 }
229
230 #[test]
231 fn config_display() {
232 let e = Error::Config("max_turns must be > 0".to_owned());
233 assert!(display(&e).contains("max_turns must be > 0"));
234 }
235
236 #[test]
237 fn control_protocol_display() {
238 let e = Error::ControlProtocol("unexpected init sequence".to_owned());
239 assert!(display(&e).contains("unexpected init sequence"));
240 }
241
242 #[test]
243 fn image_validation_display() {
244 let e = Error::ImageValidation("unsupported MIME type: image/bmp".to_owned());
245 assert!(display(&e).contains("image/bmp"));
246 }
247
248 #[test]
249 fn timeout_display() {
250 let e = Error::Timeout("connect timed out after 30s".to_owned());
251 assert!(display(&e).contains("30s"));
252 }
253
254 #[test]
257 fn is_process_exit_true_for_process_exited() {
258 let e = Error::ProcessExited {
259 code: Some(1),
260 stderr: String::new(),
261 };
262 assert!(e.is_process_exit());
263 }
264
265 #[test]
266 fn is_process_exit_false_for_other_variants() {
267 assert!(!Error::NotConnected.is_process_exit());
268 assert!(!Error::CliNotFound.is_process_exit());
269 }
270
271 #[test]
272 fn is_retriable_for_io_timeout_transport() {
273 let io_err = Error::Io(std::io::Error::new(
274 std::io::ErrorKind::ConnectionReset,
275 "reset",
276 ));
277 assert!(io_err.is_retriable());
278 assert!(Error::Timeout("deadline".to_owned()).is_retriable());
279 assert!(Error::Transport("broken".to_owned()).is_retriable());
280 }
281
282 #[test]
283 fn is_retriable_false_for_not_connected() {
284 assert!(!Error::NotConnected.is_retriable());
285 }
286
287 #[test]
288 fn cancelled_display() {
289 let e = Error::Cancelled;
290 assert!(display(&e).contains("cancelled"));
291 }
292
293 #[test]
294 fn is_cancelled_true_for_cancelled() {
295 assert!(Error::Cancelled.is_cancelled());
296 }
297
298 #[test]
299 fn is_cancelled_false_for_other_variants() {
300 assert!(!Error::NotConnected.is_cancelled());
301 assert!(!Error::CliNotFound.is_cancelled());
302 assert!(!Error::Timeout("x".into()).is_cancelled());
303 }
304
305 #[test]
306 fn cancelled_is_not_retriable() {
307 assert!(!Error::Cancelled.is_retriable());
308 }
309
310 #[test]
311 fn result_alias_compiles() {
312 fn ok_fn() -> Result<u32> {
313 Ok(42)
314 }
315 assert_eq!(ok_fn().unwrap(), 42);
316 }
317}