claude_wrapper/error.rs
1//! The crate's [`Error`] type and [`Result`] alias.
2//!
3//! Every fallible operation returns [`Result<T>`]. [`Error`] is
4//! `#[non_exhaustive]` and classifies CLI failures into typed variants
5//! (auth, rail-stop caps, timeouts) via
6//! [`Error::from_command_failure`]; see its variant docs for what each
7//! carries.
8
9use std::path::PathBuf;
10
11use crate::auth::AuthErrorKind;
12
13/// Which of a child's captured streams an [`Error::OutputLimitExceeded`]
14/// refers to.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum OutputStream {
17 /// The child's standard output.
18 Stdout,
19 /// The child's standard error.
20 Stderr,
21}
22
23impl std::fmt::Display for OutputStream {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 f.write_str(match self {
26 Self::Stdout => "stdout",
27 Self::Stderr => "stderr",
28 })
29 }
30}
31
32/// Which opt-in [`DuplexOptions`](crate::duplex::DuplexOptions) resource
33/// limit an [`Error::DuplexLimitExceeded`] refers to.
34#[cfg(feature = "async")]
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36#[non_exhaustive]
37pub enum DuplexLimit {
38 /// One stdout frame, including its newline terminator, was larger than
39 /// [`DuplexOptions::max_frame_bytes`](crate::duplex::DuplexOptions::max_frame_bytes).
40 FrameBytes,
41 /// Stdout bytes consumed during one turn exceeded
42 /// [`DuplexOptions::max_turn_bytes`](crate::duplex::DuplexOptions::max_turn_bytes).
43 TurnBytes,
44 /// Events retained for one turn exceeded
45 /// [`DuplexOptions::max_turn_events`](crate::duplex::DuplexOptions::max_turn_events).
46 TurnEvents,
47}
48
49#[cfg(feature = "async")]
50impl std::fmt::Display for DuplexLimit {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(match self {
53 Self::FrameBytes => "frame bytes",
54 Self::TurnBytes => "turn bytes",
55 Self::TurnEvents => "turn events",
56 })
57 }
58}
59
60/// Which duplex operation an [`Error::DuplexDeadlineExceeded`] refers to.
61#[cfg(feature = "async")]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63#[non_exhaustive]
64pub enum DuplexOperation {
65 /// A turn started by
66 /// [`DuplexSession::send`](crate::duplex::DuplexSession::send), bounded by
67 /// [`DuplexOptions::turn_timeout`](crate::duplex::DuplexOptions::turn_timeout).
68 Turn,
69 /// A control request such as
70 /// [`DuplexSession::interrupt`](crate::duplex::DuplexSession::interrupt),
71 /// bounded by
72 /// [`DuplexOptions::control_timeout`](crate::duplex::DuplexOptions::control_timeout).
73 Control,
74}
75
76#[cfg(feature = "async")]
77impl std::fmt::Display for DuplexOperation {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.write_str(match self {
80 Self::Turn => "turn",
81 Self::Control => "control request",
82 })
83 }
84}
85
86/// Errors returned by claude-wrapper operations.
87///
88/// This enum is `#[non_exhaustive]`: new variants may be added in
89/// future releases without a major version bump, so downstream `match`
90/// expressions must include a wildcard (`_ =>`) arm. Matching on the
91/// specific variants you care about (e.g. [`Error::Auth`],
92/// [`Error::MaxTurnsExceeded`]) keeps working across upgrades.
93#[derive(Debug, thiserror::Error)]
94#[non_exhaustive]
95pub enum Error {
96 /// The `claude` binary was not found in PATH.
97 #[error("claude binary not found in PATH")]
98 NotFound,
99
100 /// A claude command failed with a non-zero exit code.
101 #[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}") })]
102 CommandFailed {
103 /// The full command line that failed.
104 command: String,
105 /// Process exit code.
106 exit_code: i32,
107 /// Captured standard output.
108 stdout: String,
109 /// Captured standard error.
110 stderr: String,
111 /// Working directory the command ran in, when set.
112 working_dir: Option<PathBuf>,
113 },
114
115 /// A resident duplex child exited unsuccessfully. Its native status is
116 /// retained directly, including a Unix terminating signal when present.
117 /// The turn's semantic result remains separate from this process failure.
118 #[error("duplex child exited unsuccessfully ({status}; shutdown budget exceeded: {shutdown_budget_exceeded}){}{}", if stderr.is_empty() { String::new() } else { format!("\nstderr: {stderr}") }, stderr_read_error.as_ref().map(|error| format!("\nstderr read failed: {error}")).unwrap_or_default())]
119 DuplexProcessFailed {
120 /// Observed native status. `code()` is absent for a Unix signal;
121 /// use `std::os::unix::process::ExitStatusExt::signal` on Unix.
122 status: std::process::ExitStatus,
123 /// The wrapper's shutdown budget expired and it invoked its stop
124 /// policy. This records the attempt, not which signal caused exit.
125 shutdown_budget_exceeded: bool,
126 /// At most the latest 64 KiB of diagnostic bytes, decoded lossily.
127 stderr: String,
128 /// A diagnostic read failure, retained alongside the native status.
129 #[source]
130 stderr_read_error: Option<std::io::Error>,
131 },
132
133 /// An I/O error occurred while spawning or communicating with the process.
134 #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
135 Io {
136 /// Human-readable description of the I/O failure.
137 message: String,
138 /// The underlying I/O error.
139 #[source]
140 source: std::io::Error,
141 /// Working directory the operation ran in, when set.
142 working_dir: Option<PathBuf>,
143 },
144
145 /// The command timed out.
146 #[error("claude command timed out after {timeout_seconds}s")]
147 Timeout {
148 /// The timeout, in seconds, that was exceeded.
149 timeout_seconds: u64,
150 },
151
152 /// The caller explicitly cancelled an in-flight command.
153 ///
154 /// The wrapper does not return this error until it has terminated the
155 /// owned process group and reaped the direct child.
156 #[cfg(feature = "async")]
157 #[error("claude command cancelled")]
158 Cancelled,
159
160 /// The child wrote more to a captured stream than the configured
161 /// ceiling allows.
162 ///
163 /// Returned instead of a truncated success, so a caller can tell a
164 /// complete answer from one that exceeded what it agreed to hold.
165 /// The wrapper does not return this error until it has terminated the
166 /// owned process group and reaped the direct child, on the same path
167 /// as [`Error::Cancelled`] and [`Error::Timeout`]. Output captured
168 /// before the ceiling tripped is logged at warn rather than carried
169 /// here.
170 ///
171 /// Configure the ceiling with
172 /// [`ClaudeBuilder::output_limit`](crate::ClaudeBuilder::output_limit);
173 /// it is off by default and this error cannot occur while it is unset.
174 #[error("claude {stream} exceeded the {limit_bytes}-byte capture limit")]
175 OutputLimitExceeded {
176 /// Which captured stream exceeded the ceiling.
177 stream: OutputStream,
178 /// The configured ceiling, in bytes, that was exceeded.
179 limit_bytes: usize,
180 },
181
182 /// JSON parsing failed.
183 #[cfg(feature = "json")]
184 #[error("json parse error: {message}")]
185 Json {
186 /// Human-readable description of what failed to parse.
187 message: String,
188 /// The underlying serde error.
189 #[source]
190 source: serde_json::Error,
191 },
192
193 /// The installed CLI version does not meet the minimum requirement.
194 #[error("CLI version {found} does not meet minimum requirement {minimum}")]
195 VersionMismatch {
196 /// The version detected on the system.
197 found: crate::version::CliVersion,
198 /// The minimum version required.
199 minimum: crate::version::CliVersion,
200 },
201
202 /// The installed CLI is outside the tested-against range and the
203 /// caller asked for a hard gate via
204 /// [`Claude::ensure_tested_cli_version`](crate::Claude::ensure_tested_cli_version).
205 ///
206 /// Distinct from [`Error::VersionMismatch`], which is about a
207 /// caller-declared minimum for one operation. This one carries
208 /// both bounds because being *newer* than the tested maximum is
209 /// also a refusable condition.
210 #[error(
211 "CLI version {found} is outside the tested range {tested_min}..={tested_max}{}",
212 if found < tested_min { " (older than the supported minimum)" } else { " (newer than the tested maximum)" }
213 )]
214 UntestedCliVersion {
215 /// The version detected on the system.
216 found: crate::version::CliVersion,
217 /// Lowest CLI version the wrapper supports.
218 tested_min: crate::version::CliVersion,
219 /// Highest CLI version the wrapper has been tested against.
220 tested_max: crate::version::CliVersion,
221 },
222
223 /// Construction of a `dangerous::Client` was attempted without
224 /// the opt-in env-var set. The env-var name is a compile-time
225 /// constant exported from [`crate::dangerous::ALLOW_ENV`].
226 #[error(
227 "dangerous operations are not allowed; set the env var `{env_var}=1` at process start if you really mean it"
228 )]
229 DangerousNotAllowed {
230 /// Name of the opt-in env var that must be set.
231 env_var: &'static str,
232 },
233
234 /// A configured [`BudgetTracker`](crate::budget::BudgetTracker) has
235 /// hit its `max_usd` ceiling. Raised before the next call is
236 /// dispatched, so the CLI is not invoked.
237 #[error("budget exceeded: ${total_usd:.4} spent, ${max_usd:.4} max")]
238 BudgetExceeded {
239 /// Total spend accumulated so far, in USD.
240 total_usd: f64,
241 /// The configured ceiling, in USD.
242 max_usd: f64,
243 },
244
245 /// A [`DuplexSession`](crate::duplex::DuplexSession) operation was
246 /// attempted after the session task exited (child died, EOF on
247 /// stdout, or the session was closed). Pending replies are
248 /// resolved with this error.
249 #[cfg(feature = "async")]
250 #[error("duplex session is closed")]
251 DuplexClosed,
252
253 /// [`DuplexSession::send`](crate::duplex::DuplexSession::send) was
254 /// called while another turn is already in flight. Wait for the
255 /// outstanding turn to resolve before issuing another.
256 #[cfg(feature = "async")]
257 #[error("duplex session has a turn in flight")]
258 DuplexTurnInFlight,
259
260 /// A control request issued from
261 /// [`DuplexSession::interrupt`](crate::duplex::DuplexSession::interrupt)
262 /// (or any other outbound `control_request`) was answered by the
263 /// CLI with a `subtype: "error"` payload.
264 #[cfg(feature = "async")]
265 #[error("duplex control request failed: {message}")]
266 DuplexControlFailed {
267 /// The error message extracted from the CLI's control_response.
268 message: String,
269 },
270
271 /// An opt-in [`DuplexOptions`](crate::duplex::DuplexOptions) resource
272 /// limit tripped, so the session was closed.
273 ///
274 /// Returned to every accepted turn and control waiter, and by
275 /// [`DuplexSession::close`](crate::duplex::DuplexSession::close). A turn
276 /// that trips a limit never resolves with a partial
277 /// [`TurnResult`](crate::duplex::TurnResult). Cleanup is attempted with
278 /// the client's configured process-group, `kill_grace`, and
279 /// `termination_signal` settings before this error is delivered.
280 /// Check `process_settled` for confirmation that the direct child was reaped.
281 ///
282 /// This variant is `#[non_exhaustive]`: match with `..`.
283 #[cfg(feature = "async")]
284 #[error(
285 "duplex {limit} limit of {max} exceeded; session closed (process settled: {process_settled})"
286 )]
287 #[non_exhaustive]
288 DuplexLimitExceeded {
289 /// Which limit tripped.
290 limit: DuplexLimit,
291 /// The configured ceiling that was exceeded.
292 max: usize,
293 /// The direct child was reaped after the stop policy ran. This does
294 /// not show that escaped descendants were contained.
295 process_settled: bool,
296 },
297
298 /// An opt-in duplex operation deadline expired, so the session was
299 /// closed.
300 ///
301 /// Returned to every accepted turn and control waiter, and by
302 /// [`DuplexSession::close`](crate::duplex::DuplexSession::close). The
303 /// cleanup attempt uses the client's configured process-group,
304 /// `kill_grace`, and `termination_signal` settings before this error is
305 /// delivered. Check `process_settled` for confirmation that the direct
306 /// child was reaped. It does not mean the CLI acknowledged an interrupt.
307 ///
308 /// This variant is `#[non_exhaustive]`: match with `..`.
309 #[cfg(feature = "async")]
310 #[error(
311 "duplex {operation} deadline of {timeout:?} exceeded; session closed (process settled: {process_settled})"
312 )]
313 #[non_exhaustive]
314 DuplexDeadlineExceeded {
315 /// Which operation's deadline expired first.
316 operation: DuplexOperation,
317 /// The configured timeout for that operation.
318 timeout: std::time::Duration,
319 /// The direct child was reaped after the stop policy ran. This does
320 /// not show that escaped descendants were contained.
321 process_settled: bool,
322 },
323
324 /// A history-module operation (parsing or locating session
325 /// JSONL under `~/.claude/projects/`) failed in a way that
326 /// doesn't fit the I/O or JSON variants -- e.g. unknown
327 /// session id, missing user home directory.
328 #[error("history error: {message}")]
329 History {
330 /// Human-readable description of what went wrong.
331 message: String,
332 },
333
334 /// An artifacts-module operation (parsing or locating files
335 /// under `~/.claude/agents/`, `~/.claude/skills/`, and friends)
336 /// failed in a way that doesn't fit the I/O variant -- e.g.
337 /// unknown agent/skill name, missing user home directory.
338 #[error("artifacts error: {message}")]
339 Artifacts {
340 /// Human-readable description of what went wrong.
341 message: String,
342 },
343
344 /// A worktrees-module operation (running or parsing
345 /// `git worktree list --porcelain`) failed in a way that
346 /// doesn't fit the I/O variant -- e.g. git not on PATH,
347 /// path isn't a git repo, malformed porcelain output.
348 #[error("worktrees error: {message}")]
349 Worktrees {
350 /// Human-readable description of what went wrong.
351 message: String,
352 },
353
354 /// A `claude` invocation failed and looked auth-shaped to the
355 /// classifier. Hosts can match on this variant to trigger a
356 /// re-auth flow, surface a clean message, or skip retries.
357 /// `kind` carries the best-effort subcategory; `message` is the
358 /// stderr (or stdout fallback) the classifier matched against.
359 ///
360 /// Raised at exec time when [`crate::auth::classify_failure`]
361 /// returns `Some(_)` for a CLI failure that would otherwise
362 /// have been [`Error::CommandFailed`]. Cases the classifier
363 /// missed remain `CommandFailed`; call
364 /// [`Error::auth_kind`] for opt-in inspection of those.
365 #[error("auth error ({kind:?}): {command} (exit code {exit_code}): {message}")]
366 Auth {
367 /// Best-effort classification.
368 kind: AuthErrorKind,
369 /// The full command line that failed.
370 command: String,
371 /// Process exit code.
372 exit_code: i32,
373 /// Human-readable message extracted from stderr (or stdout).
374 message: String,
375 },
376
377 /// A `--max-turns`-capped run exhausted its turn budget. The CLI
378 /// emits a terminal `result` event with `subtype ==
379 /// "error_max_turns"` (exit 1, with the result JSON on stdout),
380 /// which would otherwise fold into [`Error::CommandFailed`].
381 ///
382 /// This is distinct from a genuine failure: the working tree may
383 /// be fine and the run simply hit the cap mid-task. Orchestrators
384 /// can match this variant to finish the lifecycle (run remaining
385 /// gates, commit) rather than treating it as broken or re-parsing
386 /// the trace for `error_max_turns`.
387 ///
388 /// Raised by [`Error::from_command_failure`] ahead of the auth
389 /// classifier. Only detected when the result event is present on
390 /// stdout (the `json` / `stream-json` output formats); text-mode
391 /// failures without it remain [`Error::CommandFailed`].
392 ///
393 /// This variant is `#[non_exhaustive]`: match with `..` so future
394 /// field additions are not breaking.
395 #[error("claude hit the --max-turns cap{}: {command} (exit code {exit_code})", max_turns.map(|n| format!(" of {n}")).unwrap_or_default())]
396 #[non_exhaustive]
397 MaxTurnsExceeded {
398 /// The full command line that failed.
399 command: String,
400 /// Process exit code (1).
401 exit_code: i32,
402 /// The configured `--max-turns` cap, parsed from the result
403 /// event ("Reached maximum number of turns (N)") when present.
404 max_turns: Option<u32>,
405 /// Actual spend, from the result event's `total_cost_usd`
406 /// when present.
407 cost_usd: Option<f64>,
408 /// Turns completed before the cap, from the result event's
409 /// `num_turns` when present.
410 num_turns: Option<u32>,
411 /// Session id from the result event when present; usable to
412 /// resume the capped run.
413 session_id: Option<String>,
414 },
415
416 /// A `--max-budget-usd`-capped run hit its spend ceiling. The CLI
417 /// emits a terminal `result` event with `subtype ==
418 /// "error_max_budget_usd"` (exit 1, with the result JSON on
419 /// stdout), which would otherwise fold into
420 /// [`Error::CommandFailed`].
421 ///
422 /// This is distinct from a genuine failure: the working tree may
423 /// be fine and the run simply hit the cap mid-task. Orchestrators
424 /// can match this variant to finish the lifecycle (run remaining
425 /// gates, commit) rather than treating it as broken or re-parsing
426 /// the trace for `error_max_budget_usd`.
427 ///
428 /// The `max_usd` is claude's reported cap, not the actual spend.
429 /// Detection is post-hoc (claude checks the budget after each API
430 /// call completes), so a run can overspend the cap before tripping.
431 ///
432 /// Raised by [`Error::from_command_failure`] ahead of the auth
433 /// classifier. Only detected when the result event is present on
434 /// stdout (the `json` / `stream-json` output formats); text-mode
435 /// failures without it remain [`Error::CommandFailed`].
436 ///
437 /// This is separate from [`Error::BudgetExceeded`], which is the
438 /// wrapper's own [`BudgetTracker`](crate::budget::BudgetTracker)
439 /// ceiling -- a different mechanism from claude's CLI cap.
440 ///
441 /// This variant is `#[non_exhaustive]`: match with `..` so future
442 /// field additions are not breaking.
443 #[error("claude hit the --max-budget-usd cap{}: {command} (exit code {exit_code})", max_usd.map(|n| format!(" of ${n:.2}")).unwrap_or_default())]
444 #[non_exhaustive]
445 MaxBudgetExceeded {
446 /// The full command line that failed.
447 command: String,
448 /// Process exit code (1).
449 exit_code: i32,
450 /// The configured `--max-budget-usd` cap, parsed from the
451 /// result event ("Reached maximum budget ($X)") when present.
452 max_usd: Option<f64>,
453 /// Actual spend, from the result event's `total_cost_usd`
454 /// when present.
455 cost_usd: Option<f64>,
456 /// Turns completed before the cap, from the result event's
457 /// `num_turns` when present.
458 num_turns: Option<u32>,
459 /// Session id from the result event when present; usable to
460 /// resume the capped run.
461 session_id: Option<String>,
462 },
463}
464
465impl Error {
466 /// Construct an [`Error`] from a CLI failure. Runs the
467 /// auth-error classifier; if it matches, returns
468 /// [`Error::Auth`]. Otherwise returns [`Error::CommandFailed`]
469 /// unchanged.
470 ///
471 /// This is the canonical entry point for raising failures from
472 /// `exec.rs`-shaped sites -- replacing direct construction of
473 /// `CommandFailed` ensures every consumer benefits from typed
474 /// auth errors automatically.
475 pub fn from_command_failure(
476 command: String,
477 exit_code: i32,
478 stdout: String,
479 stderr: String,
480 working_dir: Option<PathBuf>,
481 ) -> Self {
482 // A --max-turns cap hit is a terminal `result` event with
483 // subtype "error_max_turns" on stdout. Surface it as its own
484 // typed variant -- ahead of the auth classifier, since it is
485 // never auth-shaped -- so consumers can tell "hit the cap"
486 // (recoverable) from a genuine failure.
487 if stdout.contains("\"error_max_turns\"") {
488 return Self::MaxTurnsExceeded {
489 command,
490 exit_code,
491 max_turns: parse_max_turns_cap(&stdout),
492 cost_usd: parse_result_number(&stdout, "total_cost_usd"),
493 num_turns: parse_result_number(&stdout, "num_turns"),
494 session_id: parse_result_string(&stdout, "session_id"),
495 };
496 }
497 // A --max-budget-usd cap hit mirrors the max-turns shape: a
498 // terminal `result` event with subtype "error_max_budget_usd"
499 // on stdout. Surface it as its own typed variant -- ahead of
500 // the auth classifier, since it is never auth-shaped -- so
501 // consumers can tell "hit the cap" (recoverable) from a genuine
502 // failure.
503 if stdout.contains("\"error_max_budget_usd\"") {
504 return Self::MaxBudgetExceeded {
505 command,
506 exit_code,
507 max_usd: parse_max_budget_cap(&stdout),
508 cost_usd: parse_result_number(&stdout, "total_cost_usd"),
509 num_turns: parse_result_number(&stdout, "num_turns"),
510 session_id: parse_result_string(&stdout, "session_id"),
511 };
512 }
513 if let Some(kind) = crate::auth::classify_failure(exit_code, &stdout, &stderr) {
514 // Prefer stderr for the human-facing message; fall back
515 // to stdout when stderr is empty (some CLIs send all
516 // diagnostics to stdout).
517 let message = if !stderr.trim().is_empty() {
518 stderr.trim().to_string()
519 } else {
520 stdout.trim().to_string()
521 };
522 Self::Auth {
523 kind,
524 command,
525 exit_code,
526 message,
527 }
528 } else {
529 Self::CommandFailed {
530 command,
531 exit_code,
532 stdout,
533 stderr,
534 working_dir,
535 }
536 }
537 }
538
539 /// Inspect whether this error is auth-shaped. Returns
540 /// `Some(kind)` for [`Error::Auth`] (the auto-typed path) and
541 /// also re-runs [`crate::auth::classify_failure`] on
542 /// [`Error::CommandFailed`] for cases the constructor missed.
543 /// Returns `None` for everything else (`Io`, `Timeout`, etc.).
544 ///
545 /// Most consumers should match on [`Error::Auth`] directly --
546 /// this method is the escape hatch for low-confidence
547 /// classifier patterns the constructor was too conservative
548 /// about.
549 pub fn auth_kind(&self) -> Option<AuthErrorKind> {
550 match self {
551 Self::Auth { kind, .. } => Some(*kind),
552 Self::CommandFailed {
553 exit_code,
554 stdout,
555 stderr,
556 ..
557 } => crate::auth::classify_failure(*exit_code, stdout, stderr),
558 _ => None,
559 }
560 }
561}
562
563/// Parse the configured `--max-turns` cap from a CLI result event's
564/// human-readable error ("Reached maximum number of turns (N)").
565/// Returns `None` when the phrase or a parseable number is absent.
566fn parse_max_turns_cap(stdout: &str) -> Option<u32> {
567 stdout
568 .split("maximum number of turns (")
569 .nth(1)
570 .and_then(|rest| rest.split(')').next())
571 .and_then(|n| n.trim().parse::<u32>().ok())
572}
573
574/// Parse the configured `--max-budget-usd` cap from a CLI result
575/// event's human-readable error ("Reached maximum budget ($X)").
576/// Returns `None` when the phrase or a parseable amount is absent.
577fn parse_max_budget_cap(stdout: &str) -> Option<f64> {
578 stdout
579 .split("maximum budget ($")
580 .nth(1)
581 .and_then(|rest| rest.split(')').next())
582 .and_then(|n| n.trim().parse::<f64>().ok())
583}
584
585/// Extract a top-level numeric field (e.g. `"num_turns":2`) from a
586/// result event's raw JSON. String-based, like the cap parsers above:
587/// `serde_json` is an optional dependency and this module must work
588/// without it. Returns `None` when the field or a parseable value is
589/// absent.
590fn parse_result_number<T: std::str::FromStr>(stdout: &str, field: &str) -> Option<T> {
591 let rest = stdout.split(&format!("\"{field}\":")).nth(1)?;
592 let end = rest.find([',', '}']).unwrap_or(rest.len());
593 rest[..end].trim().parse::<T>().ok()
594}
595
596/// Extract a top-level string field (e.g. `"session_id":"abc"`) from
597/// a result event's raw JSON. Assumes the value contains no escaped
598/// quotes, which holds for session ids. Returns `None` when the field
599/// is absent or not a string.
600fn parse_result_string(stdout: &str, field: &str) -> Option<String> {
601 let rest = stdout.split(&format!("\"{field}\":")).nth(1)?;
602 let rest = rest.trim_start().strip_prefix('"')?;
603 rest.split('"').next().map(str::to_string)
604}
605
606impl From<std::io::Error> for Error {
607 fn from(e: std::io::Error) -> Self {
608 Self::Io {
609 message: e.to_string(),
610 source: e,
611 working_dir: None,
612 }
613 }
614}
615
616/// Result type alias for claude-wrapper operations.
617pub type Result<T> = std::result::Result<T, Error>;
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
624 Error::CommandFailed {
625 command: "/bin/claude --print".to_string(),
626 exit_code: 7,
627 stdout: stdout.to_string(),
628 stderr: stderr.to_string(),
629 working_dir,
630 }
631 }
632
633 #[test]
634 fn command_failed_display_includes_command_and_exit_code() {
635 let e = command_failed("", "", None);
636 let s = e.to_string();
637 assert!(s.contains("/bin/claude --print"));
638 assert!(s.contains("exit code 7"));
639 }
640
641 #[test]
642 fn command_failed_display_omits_empty_stdout_and_stderr() {
643 let s = command_failed("", "", None).to_string();
644 assert!(!s.contains("stdout:"));
645 assert!(!s.contains("stderr:"));
646 }
647
648 #[test]
649 fn command_failed_display_includes_nonempty_stdout() {
650 let s = command_failed("hello", "", None).to_string();
651 assert!(s.contains("stdout: hello"));
652 }
653
654 #[test]
655 fn command_failed_display_includes_nonempty_stderr() {
656 let s = command_failed("", "boom", None).to_string();
657 assert!(s.contains("stderr: boom"));
658 }
659
660 #[test]
661 fn command_failed_display_includes_both_streams_when_present() {
662 let s = command_failed("out", "err", None).to_string();
663 assert!(s.contains("stdout: out"));
664 assert!(s.contains("stderr: err"));
665 }
666
667 #[test]
668 fn command_failed_display_includes_working_dir_when_present() {
669 let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
670 assert!(s.contains("/tmp/proj"));
671 }
672
673 #[test]
674 fn command_failed_display_omits_working_dir_when_absent() {
675 let s = command_failed("", "", None).to_string();
676 assert!(!s.contains("(in "));
677 }
678
679 #[test]
680 fn timeout_display_formats_seconds() {
681 let s = Error::Timeout {
682 timeout_seconds: 42,
683 }
684 .to_string();
685 assert!(s.contains("42s"));
686 }
687
688 #[test]
689 fn io_error_display_includes_working_dir_when_present() {
690 let e = Error::Io {
691 message: "spawn failed".to_string(),
692 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
693 working_dir: Some(PathBuf::from("/work")),
694 };
695 let s = e.to_string();
696 assert!(s.contains("spawn failed"));
697 assert!(s.contains("/work"));
698 }
699
700 // -- from_command_failure / auth_kind ---------------------------
701
702 #[test]
703 fn from_command_failure_unrelated_stderr_yields_command_failed() {
704 let e = Error::from_command_failure(
705 "claude --print".into(),
706 1,
707 String::new(),
708 "syntax error".into(),
709 None,
710 );
711 assert!(matches!(e, Error::CommandFailed { .. }));
712 assert_eq!(e.auth_kind(), None);
713 }
714
715 #[test]
716 fn from_command_failure_auth_stderr_yields_auth_variant() {
717 let e = Error::from_command_failure(
718 "claude --print".into(),
719 1,
720 String::new(),
721 "Not authenticated. Run `claude login`.".into(),
722 None,
723 );
724 match &e {
725 Error::Auth { kind, message, .. } => {
726 assert_eq!(*kind, AuthErrorKind::NotAuthenticated);
727 assert!(message.contains("Not authenticated"));
728 }
729 other => panic!("expected Auth, got {other:?}"),
730 }
731 assert_eq!(e.auth_kind(), Some(AuthErrorKind::NotAuthenticated));
732 }
733
734 #[test]
735 fn from_command_failure_uses_stdout_message_when_stderr_empty() {
736 let e = Error::from_command_failure(
737 "claude --print".into(),
738 1,
739 "Invalid API key".into(),
740 String::new(),
741 None,
742 );
743 match &e {
744 Error::Auth { message, kind, .. } => {
745 assert_eq!(*kind, AuthErrorKind::InvalidCredentials);
746 assert_eq!(message, "Invalid API key");
747 }
748 other => panic!("expected Auth, got {other:?}"),
749 }
750 }
751
752 #[test]
753 fn auth_kind_inspects_command_failed_for_missed_classifications() {
754 // The constructor would have caught this, but a hand-built
755 // CommandFailed (e.g. constructed by older code or by a
756 // caller not going through the helper) is still inspectable.
757 let e = Error::CommandFailed {
758 command: "claude --print".into(),
759 exit_code: 1,
760 stdout: String::new(),
761 stderr: "401 Unauthorized".into(),
762 working_dir: None,
763 };
764 assert_eq!(e.auth_kind(), Some(AuthErrorKind::InvalidCredentials));
765 }
766
767 #[test]
768 fn auth_kind_returns_none_for_non_command_errors() {
769 assert_eq!(Error::NotFound.auth_kind(), None);
770 assert_eq!(Error::Timeout { timeout_seconds: 5 }.auth_kind(), None);
771 }
772
773 // -- max-turns classification (#641) ----------------------------
774
775 // Exact shape of a --max-turns cap-hit result event, from the
776 // field (claude 2.1.173, --output-format json).
777 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)"]}"#;
778
779 #[test]
780 fn from_command_failure_max_turns_yields_typed_variant() {
781 let e = Error::from_command_failure(
782 "claude --print --max-turns 1".into(),
783 1,
784 MAX_TURNS_STDOUT.into(),
785 String::new(),
786 None,
787 );
788 match e {
789 Error::MaxTurnsExceeded {
790 max_turns,
791 exit_code,
792 cost_usd,
793 num_turns,
794 session_id,
795 ..
796 } => {
797 assert_eq!(max_turns, Some(1));
798 assert_eq!(exit_code, 1);
799 assert_eq!(cost_usd, Some(0.08));
800 assert_eq!(num_turns, Some(2));
801 assert_eq!(session_id.as_deref(), Some("s1"));
802 }
803 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
804 }
805 }
806
807 #[test]
808 fn max_turns_detected_without_parseable_cap() {
809 let stdout = r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#;
810 let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
811 match e {
812 Error::MaxTurnsExceeded {
813 max_turns,
814 cost_usd,
815 num_turns,
816 session_id,
817 ..
818 } => {
819 assert_eq!(max_turns, None);
820 assert_eq!(cost_usd, None);
821 assert_eq!(num_turns, None);
822 assert_eq!(session_id, None);
823 }
824 other => panic!("expected MaxTurnsExceeded, got {other:?}"),
825 }
826 }
827
828 #[test]
829 fn non_max_turns_failure_stays_command_failed() {
830 let e =
831 Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
832 assert!(matches!(e, Error::CommandFailed { .. }));
833 }
834
835 #[test]
836 fn max_turns_check_does_not_swallow_auth() {
837 // A genuine auth failure (no error_max_turns) still classifies
838 // as Auth -- the max-turns guard precedes but doesn't shadow it.
839 let e = Error::from_command_failure(
840 "c".into(),
841 1,
842 String::new(),
843 "Not authenticated. Run `claude login`.".into(),
844 None,
845 );
846 assert!(matches!(e, Error::Auth { .. }));
847 }
848
849 #[test]
850 fn parse_max_turns_cap_variants() {
851 assert_eq!(
852 parse_max_turns_cap("Reached maximum number of turns (3)"),
853 Some(3)
854 );
855 assert_eq!(parse_max_turns_cap(MAX_TURNS_STDOUT), Some(1));
856 assert_eq!(parse_max_turns_cap("no such phrase"), None);
857 assert_eq!(parse_max_turns_cap("maximum number of turns (nope)"), None);
858 }
859
860 #[test]
861 fn max_turns_display_includes_cap() {
862 let s = Error::MaxTurnsExceeded {
863 command: "claude --print".into(),
864 exit_code: 1,
865 max_turns: Some(5),
866 cost_usd: None,
867 num_turns: None,
868 session_id: None,
869 }
870 .to_string();
871 assert!(s.contains("--max-turns"), "got: {s}");
872 assert!(s.contains("of 5"), "got: {s}");
873 }
874
875 // -- max-budget-usd classification (#664) -----------------------
876
877 // Shape of a --max-budget-usd cap-hit result event, from the field
878 // (claude 2.1.186, --output-format stream-json). The cap was $0.01
879 // but actual spend was $0.127 -- detection is post-hoc, so `max_usd`
880 // reports the cap and `cost_usd` the spend.
881 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"}"#;
882
883 #[test]
884 fn from_command_failure_max_budget_yields_typed_variant() {
885 let e = Error::from_command_failure(
886 "claude --print --max-budget-usd 0.01".into(),
887 1,
888 MAX_BUDGET_STDOUT.into(),
889 String::new(),
890 None,
891 );
892 match e {
893 Error::MaxBudgetExceeded {
894 max_usd,
895 exit_code,
896 cost_usd,
897 num_turns,
898 session_id,
899 ..
900 } => {
901 assert_eq!(max_usd, Some(0.01));
902 assert_eq!(exit_code, 1);
903 assert_eq!(cost_usd, Some(0.1273986));
904 assert_eq!(num_turns, Some(1));
905 assert_eq!(session_id.as_deref(), Some("s1"));
906 }
907 other => panic!("expected MaxBudgetExceeded, got {other:?}"),
908 }
909 }
910
911 #[test]
912 fn max_budget_detected_without_parseable_cap() {
913 let stdout = r#"{"type":"result","subtype":"error_max_budget_usd","is_error":true}"#;
914 let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
915 match e {
916 Error::MaxBudgetExceeded {
917 max_usd,
918 cost_usd,
919 num_turns,
920 session_id,
921 ..
922 } => {
923 assert_eq!(max_usd, None);
924 assert_eq!(cost_usd, None);
925 assert_eq!(num_turns, None);
926 assert_eq!(session_id, None);
927 }
928 other => panic!("expected MaxBudgetExceeded, got {other:?}"),
929 }
930 }
931
932 #[test]
933 fn non_max_budget_failure_stays_command_failed() {
934 let e =
935 Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
936 assert!(matches!(e, Error::CommandFailed { .. }));
937 }
938
939 #[test]
940 fn max_budget_check_does_not_swallow_auth() {
941 // A genuine auth failure (no error_max_budget_usd) still
942 // classifies as Auth -- the budget guard precedes but doesn't
943 // shadow it.
944 let e = Error::from_command_failure(
945 "c".into(),
946 1,
947 String::new(),
948 "Not authenticated. Run `claude login`.".into(),
949 None,
950 );
951 assert!(matches!(e, Error::Auth { .. }));
952 }
953
954 #[test]
955 fn parse_max_budget_cap_variants() {
956 assert_eq!(
957 parse_max_budget_cap("Reached maximum budget ($0.01)"),
958 Some(0.01)
959 );
960 assert_eq!(parse_max_budget_cap(MAX_BUDGET_STDOUT), Some(0.01));
961 assert_eq!(
962 parse_max_budget_cap("Reached maximum budget ($5)"),
963 Some(5.0)
964 );
965 assert_eq!(parse_max_budget_cap("no such phrase"), None);
966 assert_eq!(parse_max_budget_cap("maximum budget ($nope)"), None);
967 }
968
969 #[test]
970 fn max_budget_display_includes_cap() {
971 let s = Error::MaxBudgetExceeded {
972 command: "claude --print".into(),
973 exit_code: 1,
974 max_usd: Some(0.01),
975 cost_usd: None,
976 num_turns: None,
977 session_id: None,
978 }
979 .to_string();
980 assert!(s.contains("--max-budget-usd"), "got: {s}");
981 assert!(s.contains("of $0.01"), "got: {s}");
982 }
983
984 // -- result-event spend-field extraction (#668) ------------------
985
986 #[test]
987 fn parse_result_number_variants() {
988 assert_eq!(
989 parse_result_number::<f64>(MAX_TURNS_STDOUT, "total_cost_usd"),
990 Some(0.08)
991 );
992 assert_eq!(
993 parse_result_number::<u32>(MAX_TURNS_STDOUT, "num_turns"),
994 Some(2)
995 );
996 // Terminal field (closed by `}` rather than `,`).
997 assert_eq!(
998 parse_result_number::<u32>(r#"{"num_turns":3}"#, "num_turns"),
999 Some(3)
1000 );
1001 assert_eq!(
1002 parse_result_number::<f64>("no json here", "total_cost_usd"),
1003 None
1004 );
1005 assert_eq!(
1006 parse_result_number::<u32>(r#"{"num_turns":"nope"}"#, "num_turns"),
1007 None
1008 );
1009 }
1010
1011 #[test]
1012 fn parse_result_string_variants() {
1013 assert_eq!(
1014 parse_result_string(MAX_TURNS_STDOUT, "session_id").as_deref(),
1015 Some("s1")
1016 );
1017 assert_eq!(parse_result_string("no json here", "session_id"), None);
1018 // A non-string value is not misread as a string.
1019 assert_eq!(
1020 parse_result_string(r#"{"session_id":42}"#, "session_id"),
1021 None
1022 );
1023 }
1024}