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())]
170 #[non_exhaustive]
171 MaxTurnsExceeded {
172 command: String,
174 exit_code: i32,
176 max_turns: Option<u32>,
179 cost_usd: Option<f64>,
182 num_turns: Option<u32>,
185 session_id: Option<String>,
188 },
189
190 #[error("claude hit the --max-budget-usd cap{}: {command} (exit code {exit_code})", max_usd.map(|n| format!(" of ${n:.2}")).unwrap_or_default())]
218 #[non_exhaustive]
219 MaxBudgetExceeded {
220 command: String,
222 exit_code: i32,
224 max_usd: Option<f64>,
227 cost_usd: Option<f64>,
230 num_turns: Option<u32>,
233 session_id: Option<String>,
236 },
237}
238
239impl Error {
240 pub fn from_command_failure(
250 command: String,
251 exit_code: i32,
252 stdout: String,
253 stderr: String,
254 working_dir: Option<PathBuf>,
255 ) -> Self {
256 if stdout.contains("\"error_max_turns\"") {
262 return Self::MaxTurnsExceeded {
263 command,
264 exit_code,
265 max_turns: parse_max_turns_cap(&stdout),
266 cost_usd: parse_result_number(&stdout, "total_cost_usd"),
267 num_turns: parse_result_number(&stdout, "num_turns"),
268 session_id: parse_result_string(&stdout, "session_id"),
269 };
270 }
271 if stdout.contains("\"error_max_budget_usd\"") {
278 return Self::MaxBudgetExceeded {
279 command,
280 exit_code,
281 max_usd: parse_max_budget_cap(&stdout),
282 cost_usd: parse_result_number(&stdout, "total_cost_usd"),
283 num_turns: parse_result_number(&stdout, "num_turns"),
284 session_id: parse_result_string(&stdout, "session_id"),
285 };
286 }
287 if let Some(kind) = crate::auth::classify_failure(exit_code, &stdout, &stderr) {
288 let message = if !stderr.trim().is_empty() {
292 stderr.trim().to_string()
293 } else {
294 stdout.trim().to_string()
295 };
296 Self::Auth {
297 kind,
298 command,
299 exit_code,
300 message,
301 }
302 } else {
303 Self::CommandFailed {
304 command,
305 exit_code,
306 stdout,
307 stderr,
308 working_dir,
309 }
310 }
311 }
312
313 pub fn auth_kind(&self) -> Option<AuthErrorKind> {
324 match self {
325 Self::Auth { kind, .. } => Some(*kind),
326 Self::CommandFailed {
327 exit_code,
328 stdout,
329 stderr,
330 ..
331 } => crate::auth::classify_failure(*exit_code, stdout, stderr),
332 _ => None,
333 }
334 }
335}
336
337fn parse_max_turns_cap(stdout: &str) -> Option<u32> {
341 stdout
342 .split("maximum number of turns (")
343 .nth(1)
344 .and_then(|rest| rest.split(')').next())
345 .and_then(|n| n.trim().parse::<u32>().ok())
346}
347
348fn parse_max_budget_cap(stdout: &str) -> Option<f64> {
352 stdout
353 .split("maximum budget ($")
354 .nth(1)
355 .and_then(|rest| rest.split(')').next())
356 .and_then(|n| n.trim().parse::<f64>().ok())
357}
358
359fn parse_result_number<T: std::str::FromStr>(stdout: &str, field: &str) -> Option<T> {
365 let rest = stdout.split(&format!("\"{field}\":")).nth(1)?;
366 let end = rest.find([',', '}']).unwrap_or(rest.len());
367 rest[..end].trim().parse::<T>().ok()
368}
369
370fn parse_result_string(stdout: &str, field: &str) -> Option<String> {
375 let rest = stdout.split(&format!("\"{field}\":")).nth(1)?;
376 let rest = rest.trim_start().strip_prefix('"')?;
377 rest.split('"').next().map(str::to_string)
378}
379
380impl From<std::io::Error> for Error {
381 fn from(e: std::io::Error) -> Self {
382 Self::Io {
383 message: e.to_string(),
384 source: e,
385 working_dir: None,
386 }
387 }
388}
389
390pub type Result<T> = std::result::Result<T, Error>;
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
398 Error::CommandFailed {
399 command: "/bin/claude --print".to_string(),
400 exit_code: 7,
401 stdout: stdout.to_string(),
402 stderr: stderr.to_string(),
403 working_dir,
404 }
405 }
406
407 #[test]
408 fn command_failed_display_includes_command_and_exit_code() {
409 let e = command_failed("", "", None);
410 let s = e.to_string();
411 assert!(s.contains("/bin/claude --print"));
412 assert!(s.contains("exit code 7"));
413 }
414
415 #[test]
416 fn command_failed_display_omits_empty_stdout_and_stderr() {
417 let s = command_failed("", "", None).to_string();
418 assert!(!s.contains("stdout:"));
419 assert!(!s.contains("stderr:"));
420 }
421
422 #[test]
423 fn command_failed_display_includes_nonempty_stdout() {
424 let s = command_failed("hello", "", None).to_string();
425 assert!(s.contains("stdout: hello"));
426 }
427
428 #[test]
429 fn command_failed_display_includes_nonempty_stderr() {
430 let s = command_failed("", "boom", None).to_string();
431 assert!(s.contains("stderr: boom"));
432 }
433
434 #[test]
435 fn command_failed_display_includes_both_streams_when_present() {
436 let s = command_failed("out", "err", None).to_string();
437 assert!(s.contains("stdout: out"));
438 assert!(s.contains("stderr: err"));
439 }
440
441 #[test]
442 fn command_failed_display_includes_working_dir_when_present() {
443 let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
444 assert!(s.contains("/tmp/proj"));
445 }
446
447 #[test]
448 fn command_failed_display_omits_working_dir_when_absent() {
449 let s = command_failed("", "", None).to_string();
450 assert!(!s.contains("(in "));
451 }
452
453 #[test]
454 fn timeout_display_formats_seconds() {
455 let s = Error::Timeout {
456 timeout_seconds: 42,
457 }
458 .to_string();
459 assert!(s.contains("42s"));
460 }
461
462 #[test]
463 fn io_error_display_includes_working_dir_when_present() {
464 let e = Error::Io {
465 message: "spawn failed".to_string(),
466 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
467 working_dir: Some(PathBuf::from("/work")),
468 };
469 let s = e.to_string();
470 assert!(s.contains("spawn failed"));
471 assert!(s.contains("/work"));
472 }
473
474 #[test]
477 fn from_command_failure_unrelated_stderr_yields_command_failed() {
478 let e = Error::from_command_failure(
479 "claude --print".into(),
480 1,
481 String::new(),
482 "syntax error".into(),
483 None,
484 );
485 assert!(matches!(e, Error::CommandFailed { .. }));
486 assert_eq!(e.auth_kind(), None);
487 }
488
489 #[test]
490 fn from_command_failure_auth_stderr_yields_auth_variant() {
491 let e = Error::from_command_failure(
492 "claude --print".into(),
493 1,
494 String::new(),
495 "Not authenticated. Run `claude login`.".into(),
496 None,
497 );
498 match &e {
499 Error::Auth { kind, message, .. } => {
500 assert_eq!(*kind, AuthErrorKind::NotAuthenticated);
501 assert!(message.contains("Not authenticated"));
502 }
503 other => panic!("expected Auth, got {other:?}"),
504 }
505 assert_eq!(e.auth_kind(), Some(AuthErrorKind::NotAuthenticated));
506 }
507
508 #[test]
509 fn from_command_failure_uses_stdout_message_when_stderr_empty() {
510 let e = Error::from_command_failure(
511 "claude --print".into(),
512 1,
513 "Invalid API key".into(),
514 String::new(),
515 None,
516 );
517 match &e {
518 Error::Auth { message, kind, .. } => {
519 assert_eq!(*kind, AuthErrorKind::InvalidCredentials);
520 assert_eq!(message, "Invalid API key");
521 }
522 other => panic!("expected Auth, got {other:?}"),
523 }
524 }
525
526 #[test]
527 fn auth_kind_inspects_command_failed_for_missed_classifications() {
528 let e = Error::CommandFailed {
532 command: "claude --print".into(),
533 exit_code: 1,
534 stdout: String::new(),
535 stderr: "401 Unauthorized".into(),
536 working_dir: None,
537 };
538 assert_eq!(e.auth_kind(), Some(AuthErrorKind::InvalidCredentials));
539 }
540
541 #[test]
542 fn auth_kind_returns_none_for_non_command_errors() {
543 assert_eq!(Error::NotFound.auth_kind(), None);
544 assert_eq!(Error::Timeout { timeout_seconds: 5 }.auth_kind(), None);
545 }
546
547 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)"]}"#;
552
553 #[test]
554 fn from_command_failure_max_turns_yields_typed_variant() {
555 let e = Error::from_command_failure(
556 "claude --print --max-turns 1".into(),
557 1,
558 MAX_TURNS_STDOUT.into(),
559 String::new(),
560 None,
561 );
562 match e {
563 Error::MaxTurnsExceeded {
564 max_turns,
565 exit_code,
566 cost_usd,
567 num_turns,
568 session_id,
569 ..
570 } => {
571 assert_eq!(max_turns, Some(1));
572 assert_eq!(exit_code, 1);
573 assert_eq!(cost_usd, Some(0.08));
574 assert_eq!(num_turns, Some(2));
575 assert_eq!(session_id.as_deref(), Some("s1"));
576 }
577 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
578 }
579 }
580
581 #[test]
582 fn max_turns_detected_without_parseable_cap() {
583 let stdout = r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#;
584 let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
585 match e {
586 Error::MaxTurnsExceeded {
587 max_turns,
588 cost_usd,
589 num_turns,
590 session_id,
591 ..
592 } => {
593 assert_eq!(max_turns, None);
594 assert_eq!(cost_usd, None);
595 assert_eq!(num_turns, None);
596 assert_eq!(session_id, None);
597 }
598 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
599 }
600 }
601
602 #[test]
603 fn non_max_turns_failure_stays_command_failed() {
604 let e =
605 Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
606 assert!(matches!(e, Error::CommandFailed { .. }));
607 }
608
609 #[test]
610 fn max_turns_check_does_not_swallow_auth() {
611 let e = Error::from_command_failure(
614 "c".into(),
615 1,
616 String::new(),
617 "Not authenticated. Run `claude login`.".into(),
618 None,
619 );
620 assert!(matches!(e, Error::Auth { .. }));
621 }
622
623 #[test]
624 fn parse_max_turns_cap_variants() {
625 assert_eq!(
626 parse_max_turns_cap("Reached maximum number of turns (3)"),
627 Some(3)
628 );
629 assert_eq!(parse_max_turns_cap(MAX_TURNS_STDOUT), Some(1));
630 assert_eq!(parse_max_turns_cap("no such phrase"), None);
631 assert_eq!(parse_max_turns_cap("maximum number of turns (nope)"), None);
632 }
633
634 #[test]
635 fn max_turns_display_includes_cap() {
636 let s = Error::MaxTurnsExceeded {
637 command: "claude --print".into(),
638 exit_code: 1,
639 max_turns: Some(5),
640 cost_usd: None,
641 num_turns: None,
642 session_id: None,
643 }
644 .to_string();
645 assert!(s.contains("--max-turns"), "got: {s}");
646 assert!(s.contains("of 5"), "got: {s}");
647 }
648
649 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,"total_cost_usd":0.1273986,"modelUsage":{"claude-haiku-4-5":{"costUSD":0.1273986}},"session_id":"s1"}"#;
656
657 #[test]
658 fn from_command_failure_max_budget_yields_typed_variant() {
659 let e = Error::from_command_failure(
660 "claude --print --max-budget-usd 0.01".into(),
661 1,
662 MAX_BUDGET_STDOUT.into(),
663 String::new(),
664 None,
665 );
666 match e {
667 Error::MaxBudgetExceeded {
668 max_usd,
669 exit_code,
670 cost_usd,
671 num_turns,
672 session_id,
673 ..
674 } => {
675 assert_eq!(max_usd, Some(0.01));
676 assert_eq!(exit_code, 1);
677 assert_eq!(cost_usd, Some(0.1273986));
678 assert_eq!(num_turns, Some(1));
679 assert_eq!(session_id.as_deref(), Some("s1"));
680 }
681 other => panic!("expected MaxBudgetExceeded, got {other:?}"),
682 }
683 }
684
685 #[test]
686 fn max_budget_detected_without_parseable_cap() {
687 let stdout = r#"{"type":"result","subtype":"error_max_budget_usd","is_error":true}"#;
688 let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
689 match e {
690 Error::MaxBudgetExceeded {
691 max_usd,
692 cost_usd,
693 num_turns,
694 session_id,
695 ..
696 } => {
697 assert_eq!(max_usd, None);
698 assert_eq!(cost_usd, None);
699 assert_eq!(num_turns, None);
700 assert_eq!(session_id, None);
701 }
702 other => panic!("expected MaxBudgetExceeded, got {other:?}"),
703 }
704 }
705
706 #[test]
707 fn non_max_budget_failure_stays_command_failed() {
708 let e =
709 Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
710 assert!(matches!(e, Error::CommandFailed { .. }));
711 }
712
713 #[test]
714 fn max_budget_check_does_not_swallow_auth() {
715 let e = Error::from_command_failure(
719 "c".into(),
720 1,
721 String::new(),
722 "Not authenticated. Run `claude login`.".into(),
723 None,
724 );
725 assert!(matches!(e, Error::Auth { .. }));
726 }
727
728 #[test]
729 fn parse_max_budget_cap_variants() {
730 assert_eq!(
731 parse_max_budget_cap("Reached maximum budget ($0.01)"),
732 Some(0.01)
733 );
734 assert_eq!(parse_max_budget_cap(MAX_BUDGET_STDOUT), Some(0.01));
735 assert_eq!(
736 parse_max_budget_cap("Reached maximum budget ($5)"),
737 Some(5.0)
738 );
739 assert_eq!(parse_max_budget_cap("no such phrase"), None);
740 assert_eq!(parse_max_budget_cap("maximum budget ($nope)"), None);
741 }
742
743 #[test]
744 fn max_budget_display_includes_cap() {
745 let s = Error::MaxBudgetExceeded {
746 command: "claude --print".into(),
747 exit_code: 1,
748 max_usd: Some(0.01),
749 cost_usd: None,
750 num_turns: None,
751 session_id: None,
752 }
753 .to_string();
754 assert!(s.contains("--max-budget-usd"), "got: {s}");
755 assert!(s.contains("of $0.01"), "got: {s}");
756 }
757
758 #[test]
761 fn parse_result_number_variants() {
762 assert_eq!(
763 parse_result_number::<f64>(MAX_TURNS_STDOUT, "total_cost_usd"),
764 Some(0.08)
765 );
766 assert_eq!(
767 parse_result_number::<u32>(MAX_TURNS_STDOUT, "num_turns"),
768 Some(2)
769 );
770 assert_eq!(
772 parse_result_number::<u32>(r#"{"num_turns":3}"#, "num_turns"),
773 Some(3)
774 );
775 assert_eq!(
776 parse_result_number::<f64>("no json here", "total_cost_usd"),
777 None
778 );
779 assert_eq!(
780 parse_result_number::<u32>(r#"{"num_turns":"nope"}"#, "num_turns"),
781 None
782 );
783 }
784
785 #[test]
786 fn parse_result_string_variants() {
787 assert_eq!(
788 parse_result_string(MAX_TURNS_STDOUT, "session_id").as_deref(),
789 Some("s1")
790 );
791 assert_eq!(parse_result_string("no json here", "session_id"), None);
792 assert_eq!(
794 parse_result_string(r#"{"session_id":42}"#, "session_id"),
795 None
796 );
797 }
798}