1use std::path::PathBuf;
2
3use crate::auth::AuthErrorKind;
4
5#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
14pub enum Error {
15 #[error("claude binary not found in PATH")]
17 NotFound,
18
19 #[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}") })]
21 CommandFailed {
22 command: String,
23 exit_code: i32,
24 stdout: String,
25 stderr: String,
26 working_dir: Option<PathBuf>,
27 },
28
29 #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
31 Io {
32 message: String,
33 #[source]
34 source: std::io::Error,
35 working_dir: Option<PathBuf>,
36 },
37
38 #[error("claude command timed out after {timeout_seconds}s")]
40 Timeout { timeout_seconds: u64 },
41
42 #[cfg(feature = "json")]
44 #[error("json parse error: {message}")]
45 Json {
46 message: String,
47 #[source]
48 source: serde_json::Error,
49 },
50
51 #[error("CLI version {found} does not meet minimum requirement {minimum}")]
53 VersionMismatch {
54 found: crate::version::CliVersion,
55 minimum: crate::version::CliVersion,
56 },
57
58 #[error(
62 "dangerous operations are not allowed; set the env var `{env_var}=1` at process start if you really mean it"
63 )]
64 DangerousNotAllowed { env_var: &'static str },
65
66 #[error("budget exceeded: ${total_usd:.4} spent, ${max_usd:.4} max")]
70 BudgetExceeded { total_usd: f64, max_usd: f64 },
71
72 #[cfg(feature = "async")]
77 #[error("duplex session is closed")]
78 DuplexClosed,
79
80 #[cfg(feature = "async")]
84 #[error("duplex session has a turn in flight")]
85 DuplexTurnInFlight,
86
87 #[cfg(feature = "async")]
92 #[error("duplex control request failed: {message}")]
93 DuplexControlFailed {
94 message: String,
96 },
97
98 #[error("history error: {message}")]
103 History {
104 message: String,
106 },
107
108 #[error("artifacts error: {message}")]
113 Artifacts {
114 message: String,
116 },
117
118 #[error("worktrees error: {message}")]
123 Worktrees {
124 message: String,
126 },
127
128 #[error("auth error ({kind:?}): {command} (exit code {exit_code}): {message}")]
140 Auth {
141 kind: AuthErrorKind,
143 command: String,
145 exit_code: i32,
147 message: String,
149 },
150
151 #[error("claude hit the --max-turns cap{}: {command} (exit code {exit_code})", max_turns.map(|n| format!(" of {n}")).unwrap_or_default())]
167 MaxTurnsExceeded {
168 command: String,
170 exit_code: i32,
172 max_turns: Option<u32>,
175 },
176}
177
178impl Error {
179 pub fn from_command_failure(
189 command: String,
190 exit_code: i32,
191 stdout: String,
192 stderr: String,
193 working_dir: Option<PathBuf>,
194 ) -> Self {
195 if stdout.contains("\"error_max_turns\"") {
201 return Self::MaxTurnsExceeded {
202 command,
203 exit_code,
204 max_turns: parse_max_turns_cap(&stdout),
205 };
206 }
207 if let Some(kind) = crate::auth::classify_failure(exit_code, &stdout, &stderr) {
208 let message = if !stderr.trim().is_empty() {
212 stderr.trim().to_string()
213 } else {
214 stdout.trim().to_string()
215 };
216 Self::Auth {
217 kind,
218 command,
219 exit_code,
220 message,
221 }
222 } else {
223 Self::CommandFailed {
224 command,
225 exit_code,
226 stdout,
227 stderr,
228 working_dir,
229 }
230 }
231 }
232
233 pub fn auth_kind(&self) -> Option<AuthErrorKind> {
244 match self {
245 Self::Auth { kind, .. } => Some(*kind),
246 Self::CommandFailed {
247 exit_code,
248 stdout,
249 stderr,
250 ..
251 } => crate::auth::classify_failure(*exit_code, stdout, stderr),
252 _ => None,
253 }
254 }
255}
256
257fn parse_max_turns_cap(stdout: &str) -> Option<u32> {
261 stdout
262 .split("maximum number of turns (")
263 .nth(1)
264 .and_then(|rest| rest.split(')').next())
265 .and_then(|n| n.trim().parse::<u32>().ok())
266}
267
268impl From<std::io::Error> for Error {
269 fn from(e: std::io::Error) -> Self {
270 Self::Io {
271 message: e.to_string(),
272 source: e,
273 working_dir: None,
274 }
275 }
276}
277
278pub type Result<T> = std::result::Result<T, Error>;
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
286 Error::CommandFailed {
287 command: "/bin/claude --print".to_string(),
288 exit_code: 7,
289 stdout: stdout.to_string(),
290 stderr: stderr.to_string(),
291 working_dir,
292 }
293 }
294
295 #[test]
296 fn command_failed_display_includes_command_and_exit_code() {
297 let e = command_failed("", "", None);
298 let s = e.to_string();
299 assert!(s.contains("/bin/claude --print"));
300 assert!(s.contains("exit code 7"));
301 }
302
303 #[test]
304 fn command_failed_display_omits_empty_stdout_and_stderr() {
305 let s = command_failed("", "", None).to_string();
306 assert!(!s.contains("stdout:"));
307 assert!(!s.contains("stderr:"));
308 }
309
310 #[test]
311 fn command_failed_display_includes_nonempty_stdout() {
312 let s = command_failed("hello", "", None).to_string();
313 assert!(s.contains("stdout: hello"));
314 }
315
316 #[test]
317 fn command_failed_display_includes_nonempty_stderr() {
318 let s = command_failed("", "boom", None).to_string();
319 assert!(s.contains("stderr: boom"));
320 }
321
322 #[test]
323 fn command_failed_display_includes_both_streams_when_present() {
324 let s = command_failed("out", "err", None).to_string();
325 assert!(s.contains("stdout: out"));
326 assert!(s.contains("stderr: err"));
327 }
328
329 #[test]
330 fn command_failed_display_includes_working_dir_when_present() {
331 let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
332 assert!(s.contains("/tmp/proj"));
333 }
334
335 #[test]
336 fn command_failed_display_omits_working_dir_when_absent() {
337 let s = command_failed("", "", None).to_string();
338 assert!(!s.contains("(in "));
339 }
340
341 #[test]
342 fn timeout_display_formats_seconds() {
343 let s = Error::Timeout {
344 timeout_seconds: 42,
345 }
346 .to_string();
347 assert!(s.contains("42s"));
348 }
349
350 #[test]
351 fn io_error_display_includes_working_dir_when_present() {
352 let e = Error::Io {
353 message: "spawn failed".to_string(),
354 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
355 working_dir: Some(PathBuf::from("/work")),
356 };
357 let s = e.to_string();
358 assert!(s.contains("spawn failed"));
359 assert!(s.contains("/work"));
360 }
361
362 #[test]
365 fn from_command_failure_unrelated_stderr_yields_command_failed() {
366 let e = Error::from_command_failure(
367 "claude --print".into(),
368 1,
369 String::new(),
370 "syntax error".into(),
371 None,
372 );
373 assert!(matches!(e, Error::CommandFailed { .. }));
374 assert_eq!(e.auth_kind(), None);
375 }
376
377 #[test]
378 fn from_command_failure_auth_stderr_yields_auth_variant() {
379 let e = Error::from_command_failure(
380 "claude --print".into(),
381 1,
382 String::new(),
383 "Not authenticated. Run `claude login`.".into(),
384 None,
385 );
386 match &e {
387 Error::Auth { kind, message, .. } => {
388 assert_eq!(*kind, AuthErrorKind::NotAuthenticated);
389 assert!(message.contains("Not authenticated"));
390 }
391 other => panic!("expected Auth, got {other:?}"),
392 }
393 assert_eq!(e.auth_kind(), Some(AuthErrorKind::NotAuthenticated));
394 }
395
396 #[test]
397 fn from_command_failure_uses_stdout_message_when_stderr_empty() {
398 let e = Error::from_command_failure(
399 "claude --print".into(),
400 1,
401 "Invalid API key".into(),
402 String::new(),
403 None,
404 );
405 match &e {
406 Error::Auth { message, kind, .. } => {
407 assert_eq!(*kind, AuthErrorKind::InvalidCredentials);
408 assert_eq!(message, "Invalid API key");
409 }
410 other => panic!("expected Auth, got {other:?}"),
411 }
412 }
413
414 #[test]
415 fn auth_kind_inspects_command_failed_for_missed_classifications() {
416 let e = Error::CommandFailed {
420 command: "claude --print".into(),
421 exit_code: 1,
422 stdout: String::new(),
423 stderr: "401 Unauthorized".into(),
424 working_dir: None,
425 };
426 assert_eq!(e.auth_kind(), Some(AuthErrorKind::InvalidCredentials));
427 }
428
429 #[test]
430 fn auth_kind_returns_none_for_non_command_errors() {
431 assert_eq!(Error::NotFound.auth_kind(), None);
432 assert_eq!(Error::Timeout { timeout_seconds: 5 }.auth_kind(), None);
433 }
434
435 const MAX_TURNS_STDOUT: &str = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"num_turns":2,"session_id":"s1","total_cost_usd":0.08,"terminal_reason":"max_turns","errors":["Reached maximum number of turns (1)"]}"#;
440
441 #[test]
442 fn from_command_failure_max_turns_yields_typed_variant() {
443 let e = Error::from_command_failure(
444 "claude --print --max-turns 1".into(),
445 1,
446 MAX_TURNS_STDOUT.into(),
447 String::new(),
448 None,
449 );
450 match e {
451 Error::MaxTurnsExceeded {
452 max_turns,
453 exit_code,
454 ..
455 } => {
456 assert_eq!(max_turns, Some(1));
457 assert_eq!(exit_code, 1);
458 }
459 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
460 }
461 }
462
463 #[test]
464 fn max_turns_detected_without_parseable_cap() {
465 let stdout = r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#;
466 let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
467 match e {
468 Error::MaxTurnsExceeded { max_turns, .. } => assert_eq!(max_turns, None),
469 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
470 }
471 }
472
473 #[test]
474 fn non_max_turns_failure_stays_command_failed() {
475 let e =
476 Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
477 assert!(matches!(e, Error::CommandFailed { .. }));
478 }
479
480 #[test]
481 fn max_turns_check_does_not_swallow_auth() {
482 let e = Error::from_command_failure(
485 "c".into(),
486 1,
487 String::new(),
488 "Not authenticated. Run `claude login`.".into(),
489 None,
490 );
491 assert!(matches!(e, Error::Auth { .. }));
492 }
493
494 #[test]
495 fn parse_max_turns_cap_variants() {
496 assert_eq!(
497 parse_max_turns_cap("Reached maximum number of turns (3)"),
498 Some(3)
499 );
500 assert_eq!(parse_max_turns_cap(MAX_TURNS_STDOUT), Some(1));
501 assert_eq!(parse_max_turns_cap("no such phrase"), None);
502 assert_eq!(parse_max_turns_cap("maximum number of turns (nope)"), None);
503 }
504
505 #[test]
506 fn max_turns_display_includes_cap() {
507 let s = Error::MaxTurnsExceeded {
508 command: "claude --print".into(),
509 exit_code: 1,
510 max_turns: Some(5),
511 }
512 .to_string();
513 assert!(s.contains("--max-turns"), "got: {s}");
514 assert!(s.contains("of 5"), "got: {s}");
515 }
516}