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