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 #[error("claude hit the --max-budget-usd cap{}: {command} (exit code {exit_code})", max_usd.map(|n| format!(" of ${n:.2}")).unwrap_or_default())]
202 MaxBudgetExceeded {
203 command: String,
205 exit_code: i32,
207 max_usd: Option<f64>,
210 },
211}
212
213impl Error {
214 pub fn from_command_failure(
224 command: String,
225 exit_code: i32,
226 stdout: String,
227 stderr: String,
228 working_dir: Option<PathBuf>,
229 ) -> Self {
230 if stdout.contains("\"error_max_turns\"") {
236 return Self::MaxTurnsExceeded {
237 command,
238 exit_code,
239 max_turns: parse_max_turns_cap(&stdout),
240 };
241 }
242 if stdout.contains("\"error_max_budget_usd\"") {
249 return Self::MaxBudgetExceeded {
250 command,
251 exit_code,
252 max_usd: parse_max_budget_cap(&stdout),
253 };
254 }
255 if let Some(kind) = crate::auth::classify_failure(exit_code, &stdout, &stderr) {
256 let message = if !stderr.trim().is_empty() {
260 stderr.trim().to_string()
261 } else {
262 stdout.trim().to_string()
263 };
264 Self::Auth {
265 kind,
266 command,
267 exit_code,
268 message,
269 }
270 } else {
271 Self::CommandFailed {
272 command,
273 exit_code,
274 stdout,
275 stderr,
276 working_dir,
277 }
278 }
279 }
280
281 pub fn auth_kind(&self) -> Option<AuthErrorKind> {
292 match self {
293 Self::Auth { kind, .. } => Some(*kind),
294 Self::CommandFailed {
295 exit_code,
296 stdout,
297 stderr,
298 ..
299 } => crate::auth::classify_failure(*exit_code, stdout, stderr),
300 _ => None,
301 }
302 }
303}
304
305fn parse_max_turns_cap(stdout: &str) -> Option<u32> {
309 stdout
310 .split("maximum number of turns (")
311 .nth(1)
312 .and_then(|rest| rest.split(')').next())
313 .and_then(|n| n.trim().parse::<u32>().ok())
314}
315
316fn parse_max_budget_cap(stdout: &str) -> Option<f64> {
320 stdout
321 .split("maximum budget ($")
322 .nth(1)
323 .and_then(|rest| rest.split(')').next())
324 .and_then(|n| n.trim().parse::<f64>().ok())
325}
326
327impl From<std::io::Error> for Error {
328 fn from(e: std::io::Error) -> Self {
329 Self::Io {
330 message: e.to_string(),
331 source: e,
332 working_dir: None,
333 }
334 }
335}
336
337pub type Result<T> = std::result::Result<T, Error>;
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
345 Error::CommandFailed {
346 command: "/bin/claude --print".to_string(),
347 exit_code: 7,
348 stdout: stdout.to_string(),
349 stderr: stderr.to_string(),
350 working_dir,
351 }
352 }
353
354 #[test]
355 fn command_failed_display_includes_command_and_exit_code() {
356 let e = command_failed("", "", None);
357 let s = e.to_string();
358 assert!(s.contains("/bin/claude --print"));
359 assert!(s.contains("exit code 7"));
360 }
361
362 #[test]
363 fn command_failed_display_omits_empty_stdout_and_stderr() {
364 let s = command_failed("", "", None).to_string();
365 assert!(!s.contains("stdout:"));
366 assert!(!s.contains("stderr:"));
367 }
368
369 #[test]
370 fn command_failed_display_includes_nonempty_stdout() {
371 let s = command_failed("hello", "", None).to_string();
372 assert!(s.contains("stdout: hello"));
373 }
374
375 #[test]
376 fn command_failed_display_includes_nonempty_stderr() {
377 let s = command_failed("", "boom", None).to_string();
378 assert!(s.contains("stderr: boom"));
379 }
380
381 #[test]
382 fn command_failed_display_includes_both_streams_when_present() {
383 let s = command_failed("out", "err", None).to_string();
384 assert!(s.contains("stdout: out"));
385 assert!(s.contains("stderr: err"));
386 }
387
388 #[test]
389 fn command_failed_display_includes_working_dir_when_present() {
390 let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
391 assert!(s.contains("/tmp/proj"));
392 }
393
394 #[test]
395 fn command_failed_display_omits_working_dir_when_absent() {
396 let s = command_failed("", "", None).to_string();
397 assert!(!s.contains("(in "));
398 }
399
400 #[test]
401 fn timeout_display_formats_seconds() {
402 let s = Error::Timeout {
403 timeout_seconds: 42,
404 }
405 .to_string();
406 assert!(s.contains("42s"));
407 }
408
409 #[test]
410 fn io_error_display_includes_working_dir_when_present() {
411 let e = Error::Io {
412 message: "spawn failed".to_string(),
413 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
414 working_dir: Some(PathBuf::from("/work")),
415 };
416 let s = e.to_string();
417 assert!(s.contains("spawn failed"));
418 assert!(s.contains("/work"));
419 }
420
421 #[test]
424 fn from_command_failure_unrelated_stderr_yields_command_failed() {
425 let e = Error::from_command_failure(
426 "claude --print".into(),
427 1,
428 String::new(),
429 "syntax error".into(),
430 None,
431 );
432 assert!(matches!(e, Error::CommandFailed { .. }));
433 assert_eq!(e.auth_kind(), None);
434 }
435
436 #[test]
437 fn from_command_failure_auth_stderr_yields_auth_variant() {
438 let e = Error::from_command_failure(
439 "claude --print".into(),
440 1,
441 String::new(),
442 "Not authenticated. Run `claude login`.".into(),
443 None,
444 );
445 match &e {
446 Error::Auth { kind, message, .. } => {
447 assert_eq!(*kind, AuthErrorKind::NotAuthenticated);
448 assert!(message.contains("Not authenticated"));
449 }
450 other => panic!("expected Auth, got {other:?}"),
451 }
452 assert_eq!(e.auth_kind(), Some(AuthErrorKind::NotAuthenticated));
453 }
454
455 #[test]
456 fn from_command_failure_uses_stdout_message_when_stderr_empty() {
457 let e = Error::from_command_failure(
458 "claude --print".into(),
459 1,
460 "Invalid API key".into(),
461 String::new(),
462 None,
463 );
464 match &e {
465 Error::Auth { message, kind, .. } => {
466 assert_eq!(*kind, AuthErrorKind::InvalidCredentials);
467 assert_eq!(message, "Invalid API key");
468 }
469 other => panic!("expected Auth, got {other:?}"),
470 }
471 }
472
473 #[test]
474 fn auth_kind_inspects_command_failed_for_missed_classifications() {
475 let e = Error::CommandFailed {
479 command: "claude --print".into(),
480 exit_code: 1,
481 stdout: String::new(),
482 stderr: "401 Unauthorized".into(),
483 working_dir: None,
484 };
485 assert_eq!(e.auth_kind(), Some(AuthErrorKind::InvalidCredentials));
486 }
487
488 #[test]
489 fn auth_kind_returns_none_for_non_command_errors() {
490 assert_eq!(Error::NotFound.auth_kind(), None);
491 assert_eq!(Error::Timeout { timeout_seconds: 5 }.auth_kind(), None);
492 }
493
494 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)"]}"#;
499
500 #[test]
501 fn from_command_failure_max_turns_yields_typed_variant() {
502 let e = Error::from_command_failure(
503 "claude --print --max-turns 1".into(),
504 1,
505 MAX_TURNS_STDOUT.into(),
506 String::new(),
507 None,
508 );
509 match e {
510 Error::MaxTurnsExceeded {
511 max_turns,
512 exit_code,
513 ..
514 } => {
515 assert_eq!(max_turns, Some(1));
516 assert_eq!(exit_code, 1);
517 }
518 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
519 }
520 }
521
522 #[test]
523 fn max_turns_detected_without_parseable_cap() {
524 let stdout = r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#;
525 let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
526 match e {
527 Error::MaxTurnsExceeded { max_turns, .. } => assert_eq!(max_turns, None),
528 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
529 }
530 }
531
532 #[test]
533 fn non_max_turns_failure_stays_command_failed() {
534 let e =
535 Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
536 assert!(matches!(e, Error::CommandFailed { .. }));
537 }
538
539 #[test]
540 fn max_turns_check_does_not_swallow_auth() {
541 let e = Error::from_command_failure(
544 "c".into(),
545 1,
546 String::new(),
547 "Not authenticated. Run `claude login`.".into(),
548 None,
549 );
550 assert!(matches!(e, Error::Auth { .. }));
551 }
552
553 #[test]
554 fn parse_max_turns_cap_variants() {
555 assert_eq!(
556 parse_max_turns_cap("Reached maximum number of turns (3)"),
557 Some(3)
558 );
559 assert_eq!(parse_max_turns_cap(MAX_TURNS_STDOUT), Some(1));
560 assert_eq!(parse_max_turns_cap("no such phrase"), None);
561 assert_eq!(parse_max_turns_cap("maximum number of turns (nope)"), None);
562 }
563
564 #[test]
565 fn max_turns_display_includes_cap() {
566 let s = Error::MaxTurnsExceeded {
567 command: "claude --print".into(),
568 exit_code: 1,
569 max_turns: Some(5),
570 }
571 .to_string();
572 assert!(s.contains("--max-turns"), "got: {s}");
573 assert!(s.contains("of 5"), "got: {s}");
574 }
575
576 const MAX_BUDGET_STDOUT: &str = r#"{"type":"result","subtype":"error_max_budget_usd","is_error":true,"errors":["Reached maximum budget ($0.01)"],"num_turns":1,"modelUsage":{"claude-haiku-4-5":{"costUSD":0.1273986}},"session_id":"s1"}"#;
583
584 #[test]
585 fn from_command_failure_max_budget_yields_typed_variant() {
586 let e = Error::from_command_failure(
587 "claude --print --max-budget-usd 0.01".into(),
588 1,
589 MAX_BUDGET_STDOUT.into(),
590 String::new(),
591 None,
592 );
593 match e {
594 Error::MaxBudgetExceeded {
595 max_usd, exit_code, ..
596 } => {
597 assert_eq!(max_usd, Some(0.01));
598 assert_eq!(exit_code, 1);
599 }
600 other => panic!("expected MaxBudgetExceeded, got {other:?}"),
601 }
602 }
603
604 #[test]
605 fn max_budget_detected_without_parseable_cap() {
606 let stdout = r#"{"type":"result","subtype":"error_max_budget_usd","is_error":true}"#;
607 let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
608 match e {
609 Error::MaxBudgetExceeded { max_usd, .. } => assert_eq!(max_usd, None),
610 other => panic!("expected MaxBudgetExceeded, got {other:?}"),
611 }
612 }
613
614 #[test]
615 fn non_max_budget_failure_stays_command_failed() {
616 let e =
617 Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
618 assert!(matches!(e, Error::CommandFailed { .. }));
619 }
620
621 #[test]
622 fn max_budget_check_does_not_swallow_auth() {
623 let e = Error::from_command_failure(
627 "c".into(),
628 1,
629 String::new(),
630 "Not authenticated. Run `claude login`.".into(),
631 None,
632 );
633 assert!(matches!(e, Error::Auth { .. }));
634 }
635
636 #[test]
637 fn parse_max_budget_cap_variants() {
638 assert_eq!(
639 parse_max_budget_cap("Reached maximum budget ($0.01)"),
640 Some(0.01)
641 );
642 assert_eq!(parse_max_budget_cap(MAX_BUDGET_STDOUT), Some(0.01));
643 assert_eq!(
644 parse_max_budget_cap("Reached maximum budget ($5)"),
645 Some(5.0)
646 );
647 assert_eq!(parse_max_budget_cap("no such phrase"), None);
648 assert_eq!(parse_max_budget_cap("maximum budget ($nope)"), None);
649 }
650
651 #[test]
652 fn max_budget_display_includes_cap() {
653 let s = Error::MaxBudgetExceeded {
654 command: "claude --print".into(),
655 exit_code: 1,
656 max_usd: Some(0.01),
657 }
658 .to_string();
659 assert!(s.contains("--max-budget-usd"), "got: {s}");
660 assert!(s.contains("of $0.01"), "got: {s}");
661 }
662}