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