Skip to main content

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}