Skip to main content

claude_wrapper/
error.rs

1use std::path::PathBuf;
2
3use crate::auth::AuthErrorKind;
4
5/// Errors returned by claude-wrapper operations.
6#[derive(Debug, thiserror::Error)]
7pub enum Error {
8    /// The `claude` binary was not found in PATH.
9    #[error("claude binary not found in PATH")]
10    NotFound,
11
12    /// A claude command failed with a non-zero exit code.
13    #[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    /// An I/O error occurred while spawning or communicating with the process.
23    #[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    /// The command timed out.
32    #[error("claude command timed out after {timeout_seconds}s")]
33    Timeout { timeout_seconds: u64 },
34
35    /// JSON parsing failed.
36    #[cfg(feature = "json")]
37    #[error("json parse error: {message}")]
38    Json {
39        message: String,
40        #[source]
41        source: serde_json::Error,
42    },
43
44    /// The installed CLI version does not meet the minimum requirement.
45    #[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    /// Construction of a `dangerous::Client` was attempted without
52    /// the opt-in env-var set. The env-var name is a compile-time
53    /// constant exported from [`crate::dangerous::ALLOW_ENV`].
54    #[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    /// A configured [`BudgetTracker`](crate::budget::BudgetTracker) has
60    /// hit its `max_usd` ceiling. Raised before the next call is
61    /// dispatched, so the CLI is not invoked.
62    #[error("budget exceeded: ${total_usd:.4} spent, ${max_usd:.4} max")]
63    BudgetExceeded { total_usd: f64, max_usd: f64 },
64
65    /// A [`DuplexSession`](crate::duplex::DuplexSession) operation was
66    /// attempted after the session task exited (child died, EOF on
67    /// stdout, or the session was closed). Pending replies are
68    /// resolved with this error.
69    #[cfg(feature = "async")]
70    #[error("duplex session is closed")]
71    DuplexClosed,
72
73    /// [`DuplexSession::send`](crate::duplex::DuplexSession::send) was
74    /// called while another turn is already in flight. Wait for the
75    /// outstanding turn to resolve before issuing another.
76    #[cfg(feature = "async")]
77    #[error("duplex session has a turn in flight")]
78    DuplexTurnInFlight,
79
80    /// A control request issued from
81    /// [`DuplexSession::interrupt`](crate::duplex::DuplexSession::interrupt)
82    /// (or any other outbound `control_request`) was answered by the
83    /// CLI with a `subtype: "error"` payload.
84    #[cfg(feature = "async")]
85    #[error("duplex control request failed: {message}")]
86    DuplexControlFailed {
87        /// The error message extracted from the CLI's control_response.
88        message: String,
89    },
90
91    /// A history-module operation (parsing or locating session
92    /// JSONL under `~/.claude/projects/`) failed in a way that
93    /// doesn't fit the I/O or JSON variants -- e.g. unknown
94    /// session id, missing user home directory.
95    #[error("history error: {message}")]
96    History {
97        /// Human-readable description of what went wrong.
98        message: String,
99    },
100
101    /// An artifacts-module operation (parsing or locating files
102    /// under `~/.claude/agents/`, `~/.claude/skills/`, and friends)
103    /// failed in a way that doesn't fit the I/O variant -- e.g.
104    /// unknown agent/skill name, missing user home directory.
105    #[error("artifacts error: {message}")]
106    Artifacts {
107        /// Human-readable description of what went wrong.
108        message: String,
109    },
110
111    /// A worktrees-module operation (running or parsing
112    /// `git worktree list --porcelain`) failed in a way that
113    /// doesn't fit the I/O variant -- e.g. git not on PATH,
114    /// path isn't a git repo, malformed porcelain output.
115    #[error("worktrees error: {message}")]
116    Worktrees {
117        /// Human-readable description of what went wrong.
118        message: String,
119    },
120
121    /// A `claude` invocation failed and looked auth-shaped to the
122    /// classifier. Hosts can match on this variant to trigger a
123    /// re-auth flow, surface a clean message, or skip retries.
124    /// `kind` carries the best-effort subcategory; `message` is the
125    /// stderr (or stdout fallback) the classifier matched against.
126    ///
127    /// Raised at exec time when [`crate::auth::classify_failure`]
128    /// returns `Some(_)` for a CLI failure that would otherwise
129    /// have been [`Error::CommandFailed`]. Cases the classifier
130    /// missed remain `CommandFailed`; call
131    /// [`Error::auth_kind`] for opt-in inspection of those.
132    #[error("auth error ({kind:?}): {command} (exit code {exit_code}): {message}")]
133    Auth {
134        /// Best-effort classification.
135        kind: AuthErrorKind,
136        /// The full command line that failed.
137        command: String,
138        /// Process exit code.
139        exit_code: i32,
140        /// Human-readable message extracted from stderr (or stdout).
141        message: String,
142    },
143}
144
145impl Error {
146    /// Construct an [`Error`] from a CLI failure. Runs the
147    /// auth-error classifier; if it matches, returns
148    /// [`Error::Auth`]. Otherwise returns [`Error::CommandFailed`]
149    /// unchanged.
150    ///
151    /// This is the canonical entry point for raising failures from
152    /// `exec.rs`-shaped sites -- replacing direct construction of
153    /// `CommandFailed` ensures every consumer benefits from typed
154    /// auth errors automatically.
155    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            // Prefer stderr for the human-facing message; fall back
164            // to stdout when stderr is empty (some CLIs send all
165            // diagnostics to stdout).
166            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    /// Inspect whether this error is auth-shaped. Returns
189    /// `Some(kind)` for [`Error::Auth`] (the auto-typed path) and
190    /// also re-runs [`crate::auth::classify_failure`] on
191    /// [`Error::CommandFailed`] for cases the constructor missed.
192    /// Returns `None` for everything else (`Io`, `Timeout`, etc.).
193    ///
194    /// Most consumers should match on [`Error::Auth`] directly --
195    /// this method is the escape hatch for low-confidence
196    /// classifier patterns the constructor was too conservative
197    /// about.
198    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
222/// Result type alias for claude-wrapper operations.
223pub 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    // -- from_command_failure / auth_kind ---------------------------
307
308    #[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        // The constructor would have caught this, but a hand-built
361        // CommandFailed (e.g. constructed by older code or by a
362        // caller not going through the helper) is still inspectable.
363        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}