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