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