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