Skip to main content

claude_wrapper/
error.rs

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