1use std::path::PathBuf;
2
3use crate::auth::AuthErrorKind;
4
5#[derive(Debug, thiserror::Error)]
7pub enum Error {
8 #[error("claude binary not found in PATH")]
10 NotFound,
11
12 #[error("claude command failed: {command} (exit code {exit_code}){}{}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default(), if stdout.is_empty() { String::new() } else { format!("\nstdout: {stdout}") }, if stderr.is_empty() { String::new() } else { format!("\nstderr: {stderr}") })]
14 CommandFailed {
15 command: String,
16 exit_code: i32,
17 stdout: String,
18 stderr: String,
19 working_dir: Option<PathBuf>,
20 },
21
22 #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
24 Io {
25 message: String,
26 #[source]
27 source: std::io::Error,
28 working_dir: Option<PathBuf>,
29 },
30
31 #[error("claude command timed out after {timeout_seconds}s")]
33 Timeout { timeout_seconds: u64 },
34
35 #[cfg(feature = "json")]
37 #[error("json parse error: {message}")]
38 Json {
39 message: String,
40 #[source]
41 source: serde_json::Error,
42 },
43
44 #[error("CLI version {found} does not meet minimum requirement {minimum}")]
46 VersionMismatch {
47 found: crate::version::CliVersion,
48 minimum: crate::version::CliVersion,
49 },
50
51 #[error(
55 "dangerous operations are not allowed; set the env var `{env_var}=1` at process start if you really mean it"
56 )]
57 DangerousNotAllowed { env_var: &'static str },
58
59 #[error("budget exceeded: ${total_usd:.4} spent, ${max_usd:.4} max")]
63 BudgetExceeded { total_usd: f64, max_usd: f64 },
64
65 #[cfg(feature = "async")]
70 #[error("duplex session is closed")]
71 DuplexClosed,
72
73 #[cfg(feature = "async")]
77 #[error("duplex session has a turn in flight")]
78 DuplexTurnInFlight,
79
80 #[cfg(feature = "async")]
85 #[error("duplex control request failed: {message}")]
86 DuplexControlFailed {
87 message: String,
89 },
90
91 #[error("history error: {message}")]
96 History {
97 message: String,
99 },
100
101 #[error("artifacts error: {message}")]
106 Artifacts {
107 message: String,
109 },
110
111 #[error("worktrees error: {message}")]
116 Worktrees {
117 message: String,
119 },
120
121 #[error("auth error ({kind:?}): {command} (exit code {exit_code}): {message}")]
133 Auth {
134 kind: AuthErrorKind,
136 command: String,
138 exit_code: i32,
140 message: String,
142 },
143}
144
145impl Error {
146 pub fn from_command_failure(
156 command: String,
157 exit_code: i32,
158 stdout: String,
159 stderr: String,
160 working_dir: Option<PathBuf>,
161 ) -> Self {
162 if let Some(kind) = crate::auth::classify_failure(exit_code, &stdout, &stderr) {
163 let message = if !stderr.trim().is_empty() {
167 stderr.trim().to_string()
168 } else {
169 stdout.trim().to_string()
170 };
171 Self::Auth {
172 kind,
173 command,
174 exit_code,
175 message,
176 }
177 } else {
178 Self::CommandFailed {
179 command,
180 exit_code,
181 stdout,
182 stderr,
183 working_dir,
184 }
185 }
186 }
187
188 pub fn auth_kind(&self) -> Option<AuthErrorKind> {
199 match self {
200 Self::Auth { kind, .. } => Some(*kind),
201 Self::CommandFailed {
202 exit_code,
203 stdout,
204 stderr,
205 ..
206 } => crate::auth::classify_failure(*exit_code, stdout, stderr),
207 _ => None,
208 }
209 }
210}
211
212impl From<std::io::Error> for Error {
213 fn from(e: std::io::Error) -> Self {
214 Self::Io {
215 message: e.to_string(),
216 source: e,
217 working_dir: None,
218 }
219 }
220}
221
222pub type Result<T> = std::result::Result<T, Error>;
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
230 Error::CommandFailed {
231 command: "/bin/claude --print".to_string(),
232 exit_code: 7,
233 stdout: stdout.to_string(),
234 stderr: stderr.to_string(),
235 working_dir,
236 }
237 }
238
239 #[test]
240 fn command_failed_display_includes_command_and_exit_code() {
241 let e = command_failed("", "", None);
242 let s = e.to_string();
243 assert!(s.contains("/bin/claude --print"));
244 assert!(s.contains("exit code 7"));
245 }
246
247 #[test]
248 fn command_failed_display_omits_empty_stdout_and_stderr() {
249 let s = command_failed("", "", None).to_string();
250 assert!(!s.contains("stdout:"));
251 assert!(!s.contains("stderr:"));
252 }
253
254 #[test]
255 fn command_failed_display_includes_nonempty_stdout() {
256 let s = command_failed("hello", "", None).to_string();
257 assert!(s.contains("stdout: hello"));
258 }
259
260 #[test]
261 fn command_failed_display_includes_nonempty_stderr() {
262 let s = command_failed("", "boom", None).to_string();
263 assert!(s.contains("stderr: boom"));
264 }
265
266 #[test]
267 fn command_failed_display_includes_both_streams_when_present() {
268 let s = command_failed("out", "err", None).to_string();
269 assert!(s.contains("stdout: out"));
270 assert!(s.contains("stderr: err"));
271 }
272
273 #[test]
274 fn command_failed_display_includes_working_dir_when_present() {
275 let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
276 assert!(s.contains("/tmp/proj"));
277 }
278
279 #[test]
280 fn command_failed_display_omits_working_dir_when_absent() {
281 let s = command_failed("", "", None).to_string();
282 assert!(!s.contains("(in "));
283 }
284
285 #[test]
286 fn timeout_display_formats_seconds() {
287 let s = Error::Timeout {
288 timeout_seconds: 42,
289 }
290 .to_string();
291 assert!(s.contains("42s"));
292 }
293
294 #[test]
295 fn io_error_display_includes_working_dir_when_present() {
296 let e = Error::Io {
297 message: "spawn failed".to_string(),
298 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
299 working_dir: Some(PathBuf::from("/work")),
300 };
301 let s = e.to_string();
302 assert!(s.contains("spawn failed"));
303 assert!(s.contains("/work"));
304 }
305
306 #[test]
309 fn from_command_failure_unrelated_stderr_yields_command_failed() {
310 let e = Error::from_command_failure(
311 "claude --print".into(),
312 1,
313 String::new(),
314 "syntax error".into(),
315 None,
316 );
317 assert!(matches!(e, Error::CommandFailed { .. }));
318 assert_eq!(e.auth_kind(), None);
319 }
320
321 #[test]
322 fn from_command_failure_auth_stderr_yields_auth_variant() {
323 let e = Error::from_command_failure(
324 "claude --print".into(),
325 1,
326 String::new(),
327 "Not authenticated. Run `claude login`.".into(),
328 None,
329 );
330 match &e {
331 Error::Auth { kind, message, .. } => {
332 assert_eq!(*kind, AuthErrorKind::NotAuthenticated);
333 assert!(message.contains("Not authenticated"));
334 }
335 other => panic!("expected Auth, got {other:?}"),
336 }
337 assert_eq!(e.auth_kind(), Some(AuthErrorKind::NotAuthenticated));
338 }
339
340 #[test]
341 fn from_command_failure_uses_stdout_message_when_stderr_empty() {
342 let e = Error::from_command_failure(
343 "claude --print".into(),
344 1,
345 "Invalid API key".into(),
346 String::new(),
347 None,
348 );
349 match &e {
350 Error::Auth { message, kind, .. } => {
351 assert_eq!(*kind, AuthErrorKind::InvalidCredentials);
352 assert_eq!(message, "Invalid API key");
353 }
354 other => panic!("expected Auth, got {other:?}"),
355 }
356 }
357
358 #[test]
359 fn auth_kind_inspects_command_failed_for_missed_classifications() {
360 let e = Error::CommandFailed {
364 command: "claude --print".into(),
365 exit_code: 1,
366 stdout: String::new(),
367 stderr: "401 Unauthorized".into(),
368 working_dir: None,
369 };
370 assert_eq!(e.auth_kind(), Some(AuthErrorKind::InvalidCredentials));
371 }
372
373 #[test]
374 fn auth_kind_returns_none_for_non_command_errors() {
375 assert_eq!(Error::NotFound.auth_kind(), None);
376 assert_eq!(Error::Timeout { timeout_seconds: 5 }.auth_kind(), None);
377 }
378}