Skip to main content

claude_wrapper/
error.rs

1use std::path::PathBuf;
2
3/// Errors returned by claude-wrapper operations.
4#[derive(Debug, thiserror::Error)]
5pub enum Error {
6    /// The `claude` binary was not found in PATH.
7    #[error("claude binary not found in PATH")]
8    NotFound,
9
10    /// A claude command failed with a non-zero exit code.
11    #[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}") })]
12    CommandFailed {
13        command: String,
14        exit_code: i32,
15        stdout: String,
16        stderr: String,
17        working_dir: Option<PathBuf>,
18    },
19
20    /// An I/O error occurred while spawning or communicating with the process.
21    #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
22    Io {
23        message: String,
24        #[source]
25        source: std::io::Error,
26        working_dir: Option<PathBuf>,
27    },
28
29    /// The command timed out.
30    #[error("claude command timed out after {timeout_seconds}s")]
31    Timeout { timeout_seconds: u64 },
32
33    /// JSON parsing failed.
34    #[cfg(feature = "json")]
35    #[error("json parse error: {message}")]
36    Json {
37        message: String,
38        #[source]
39        source: serde_json::Error,
40    },
41
42    /// The installed CLI version does not meet the minimum requirement.
43    #[error("CLI version {found} does not meet minimum requirement {minimum}")]
44    VersionMismatch {
45        found: crate::version::CliVersion,
46        minimum: crate::version::CliVersion,
47    },
48
49    /// Construction of a `dangerous::Client` was attempted without
50    /// the opt-in env-var set. The env-var name is a compile-time
51    /// constant exported from [`crate::dangerous::ALLOW_ENV`].
52    #[error(
53        "dangerous operations are not allowed; set the env var `{env_var}=1` at process start if you really mean it"
54    )]
55    DangerousNotAllowed { env_var: &'static str },
56
57    /// A configured [`BudgetTracker`](crate::budget::BudgetTracker) has
58    /// hit its `max_usd` ceiling. Raised before the next call is
59    /// dispatched, so the CLI is not invoked.
60    #[error("budget exceeded: ${total_usd:.4} spent, ${max_usd:.4} max")]
61    BudgetExceeded { total_usd: f64, max_usd: f64 },
62
63    /// A [`DuplexSession`](crate::duplex::DuplexSession) operation was
64    /// attempted after the session task exited (child died, EOF on
65    /// stdout, or the session was closed). Pending replies are
66    /// resolved with this error.
67    #[cfg(feature = "async")]
68    #[error("duplex session is closed")]
69    DuplexClosed,
70
71    /// [`DuplexSession::send`](crate::duplex::DuplexSession::send) was
72    /// called while another turn is already in flight. Wait for the
73    /// outstanding turn to resolve before issuing another.
74    #[cfg(feature = "async")]
75    #[error("duplex session has a turn in flight")]
76    DuplexTurnInFlight,
77
78    /// A control request issued from
79    /// [`DuplexSession::interrupt`](crate::duplex::DuplexSession::interrupt)
80    /// (or any other outbound `control_request`) was answered by the
81    /// CLI with a `subtype: "error"` payload.
82    #[cfg(feature = "async")]
83    #[error("duplex control request failed: {message}")]
84    DuplexControlFailed {
85        /// The error message extracted from the CLI's control_response.
86        message: String,
87    },
88}
89
90impl From<std::io::Error> for Error {
91    fn from(e: std::io::Error) -> Self {
92        Self::Io {
93            message: e.to_string(),
94            source: e,
95            working_dir: None,
96        }
97    }
98}
99
100/// Result type alias for claude-wrapper operations.
101pub type Result<T> = std::result::Result<T, Error>;
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
108        Error::CommandFailed {
109            command: "/bin/claude --print".to_string(),
110            exit_code: 7,
111            stdout: stdout.to_string(),
112            stderr: stderr.to_string(),
113            working_dir,
114        }
115    }
116
117    #[test]
118    fn command_failed_display_includes_command_and_exit_code() {
119        let e = command_failed("", "", None);
120        let s = e.to_string();
121        assert!(s.contains("/bin/claude --print"));
122        assert!(s.contains("exit code 7"));
123    }
124
125    #[test]
126    fn command_failed_display_omits_empty_stdout_and_stderr() {
127        let s = command_failed("", "", None).to_string();
128        assert!(!s.contains("stdout:"));
129        assert!(!s.contains("stderr:"));
130    }
131
132    #[test]
133    fn command_failed_display_includes_nonempty_stdout() {
134        let s = command_failed("hello", "", None).to_string();
135        assert!(s.contains("stdout: hello"));
136    }
137
138    #[test]
139    fn command_failed_display_includes_nonempty_stderr() {
140        let s = command_failed("", "boom", None).to_string();
141        assert!(s.contains("stderr: boom"));
142    }
143
144    #[test]
145    fn command_failed_display_includes_both_streams_when_present() {
146        let s = command_failed("out", "err", None).to_string();
147        assert!(s.contains("stdout: out"));
148        assert!(s.contains("stderr: err"));
149    }
150
151    #[test]
152    fn command_failed_display_includes_working_dir_when_present() {
153        let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
154        assert!(s.contains("/tmp/proj"));
155    }
156
157    #[test]
158    fn command_failed_display_omits_working_dir_when_absent() {
159        let s = command_failed("", "", None).to_string();
160        assert!(!s.contains("(in "));
161    }
162
163    #[test]
164    fn timeout_display_formats_seconds() {
165        let s = Error::Timeout {
166            timeout_seconds: 42,
167        }
168        .to_string();
169        assert!(s.contains("42s"));
170    }
171
172    #[test]
173    fn io_error_display_includes_working_dir_when_present() {
174        let e = Error::Io {
175            message: "spawn failed".to_string(),
176            source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
177            working_dir: Some(PathBuf::from("/work")),
178        };
179        let s = e.to_string();
180        assert!(s.contains("spawn failed"));
181        assert!(s.contains("/work"));
182    }
183}