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
97pub type Result<T> = std::result::Result<T, Error>;
100
101impl Error {
104 #[inline]
107 #[must_use]
108 pub fn is_process_exit(&self) -> bool {
109 matches!(self, Self::ProcessExited { .. })
110 }
111
112 #[inline]
115 #[must_use]
116 pub fn is_retriable(&self) -> bool {
117 matches!(self, Self::Io(_) | Self::Timeout(_) | Self::Transport(_))
118 }
119}
120
121#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn display(e: &Error) -> String {
129 e.to_string()
130 }
131
132 #[test]
133 fn cli_not_found_display() {
134 let e = Error::CliNotFound;
135 assert!(
136 display(&e).contains("npm install -g @anthropic-ai/claude-code"),
137 "message should include install hint, got: {e}"
138 );
139 }
140
141 #[test]
142 fn version_mismatch_display() {
143 let e = Error::VersionMismatch {
144 found: "1.0.0".to_owned(),
145 required: "2.0.0".to_owned(),
146 };
147 let s = display(&e);
148 assert!(s.contains("1.0.0"), "should contain found version");
149 assert!(s.contains("2.0.0"), "should contain required version");
150 }
151
152 #[test]
153 fn spawn_failed_display() {
154 let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
155 let e = Error::SpawnFailed(inner);
156 assert!(display(&e).contains("Failed to spawn Claude process"));
157 }
158
159 #[test]
160 fn process_exited_display_with_code() {
161 let e = Error::ProcessExited {
162 code: Some(1),
163 stderr: "fatal error".to_owned(),
164 };
165 let s = display(&e);
166 assert!(s.contains("1"), "should contain exit code");
167 assert!(s.contains("fatal error"), "should contain stderr");
168 }
169
170 #[test]
171 fn process_exited_display_signal_kill() {
172 let e = Error::ProcessExited {
173 code: None,
174 stderr: String::new(),
175 };
176 assert!(display(&e).contains("None"));
178 }
179
180 #[test]
181 fn parse_error_display() {
182 let e = Error::ParseError {
183 message: "unexpected token".to_owned(),
184 line: "{bad json}".to_owned(),
185 };
186 let s = display(&e);
187 assert!(s.contains("unexpected token"));
188 assert!(s.contains("{bad json}"));
189 }
190
191 #[test]
192 fn io_error_display() {
193 let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
194 let e = Error::from(inner);
195 assert!(display(&e).contains("I/O error"));
196 }
197
198 #[test]
199 fn json_error_display() {
200 let inner: serde_json::Error =
201 serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
202 let e = Error::from(inner);
203 assert!(display(&e).contains("JSON error"));
204 }
205
206 #[test]
207 fn not_connected_display() {
208 let e = Error::NotConnected;
209 assert!(display(&e).contains("connect()"));
210 }
211
212 #[test]
213 fn transport_display() {
214 let e = Error::Transport("connection refused".to_owned());
215 assert!(display(&e).contains("connection refused"));
216 }
217
218 #[test]
219 fn config_display() {
220 let e = Error::Config("max_turns must be > 0".to_owned());
221 assert!(display(&e).contains("max_turns must be > 0"));
222 }
223
224 #[test]
225 fn control_protocol_display() {
226 let e = Error::ControlProtocol("unexpected init sequence".to_owned());
227 assert!(display(&e).contains("unexpected init sequence"));
228 }
229
230 #[test]
231 fn image_validation_display() {
232 let e = Error::ImageValidation("unsupported MIME type: image/bmp".to_owned());
233 assert!(display(&e).contains("image/bmp"));
234 }
235
236 #[test]
237 fn timeout_display() {
238 let e = Error::Timeout("connect timed out after 30s".to_owned());
239 assert!(display(&e).contains("30s"));
240 }
241
242 #[test]
245 fn is_process_exit_true_for_process_exited() {
246 let e = Error::ProcessExited {
247 code: Some(1),
248 stderr: String::new(),
249 };
250 assert!(e.is_process_exit());
251 }
252
253 #[test]
254 fn is_process_exit_false_for_other_variants() {
255 assert!(!Error::NotConnected.is_process_exit());
256 assert!(!Error::CliNotFound.is_process_exit());
257 }
258
259 #[test]
260 fn is_retriable_for_io_timeout_transport() {
261 let io_err = Error::Io(std::io::Error::new(
262 std::io::ErrorKind::ConnectionReset,
263 "reset",
264 ));
265 assert!(io_err.is_retriable());
266 assert!(Error::Timeout("deadline".to_owned()).is_retriable());
267 assert!(Error::Transport("broken".to_owned()).is_retriable());
268 }
269
270 #[test]
271 fn is_retriable_false_for_not_connected() {
272 assert!(!Error::NotConnected.is_retriable());
273 }
274
275 #[test]
276 fn result_alias_compiles() {
277 fn ok_fn() -> Result<u32> {
278 Ok(42)
279 }
280 assert_eq!(ok_fn().unwrap(), 42);
281 }
282}