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    ///
167    /// This variant is `#[non_exhaustive]`: match with `..` so future
168    /// field additions are not breaking.
169    #[error("claude hit the --max-turns cap{}: {command} (exit code {exit_code})", max_turns.map(|n| format!(" of {n}")).unwrap_or_default())]
170    #[non_exhaustive]
171    MaxTurnsExceeded {
172        /// The full command line that failed.
173        command: String,
174        /// Process exit code (1).
175        exit_code: i32,
176        /// The configured `--max-turns` cap, parsed from the result
177        /// event ("Reached maximum number of turns (N)") when present.
178        max_turns: Option<u32>,
179        /// Actual spend, from the result event's `total_cost_usd`
180        /// when present.
181        cost_usd: Option<f64>,
182        /// Turns completed before the cap, from the result event's
183        /// `num_turns` when present.
184        num_turns: Option<u32>,
185        /// Session id from the result event when present; usable to
186        /// resume the capped run.
187        session_id: Option<String>,
188    },
189
190    /// A `--max-budget-usd`-capped run hit its spend ceiling. The CLI
191    /// emits a terminal `result` event with `subtype ==
192    /// "error_max_budget_usd"` (exit 1, with the result JSON on
193    /// stdout), which would otherwise fold into
194    /// [`Error::CommandFailed`].
195    ///
196    /// This is distinct from a genuine failure: the working tree may
197    /// be fine and the run simply hit the cap mid-task. Orchestrators
198    /// can match this variant to finish the lifecycle (run remaining
199    /// gates, commit) rather than treating it as broken or re-parsing
200    /// the trace for `error_max_budget_usd`.
201    ///
202    /// The `max_usd` is claude's reported cap, not the actual spend.
203    /// Detection is post-hoc (claude checks the budget after each API
204    /// call completes), so a run can overspend the cap before tripping.
205    ///
206    /// Raised by [`Error::from_command_failure`] ahead of the auth
207    /// classifier. Only detected when the result event is present on
208    /// stdout (the `json` / `stream-json` output formats); text-mode
209    /// failures without it remain [`Error::CommandFailed`].
210    ///
211    /// This is separate from [`Error::BudgetExceeded`], which is the
212    /// wrapper's own [`BudgetTracker`](crate::budget::BudgetTracker)
213    /// ceiling -- a different mechanism from claude's CLI cap.
214    ///
215    /// This variant is `#[non_exhaustive]`: match with `..` so future
216    /// field additions are not breaking.
217    #[error("claude hit the --max-budget-usd cap{}: {command} (exit code {exit_code})", max_usd.map(|n| format!(" of ${n:.2}")).unwrap_or_default())]
218    #[non_exhaustive]
219    MaxBudgetExceeded {
220        /// The full command line that failed.
221        command: String,
222        /// Process exit code (1).
223        exit_code: i32,
224        /// The configured `--max-budget-usd` cap, parsed from the
225        /// result event ("Reached maximum budget ($X)") when present.
226        max_usd: Option<f64>,
227        /// Actual spend, from the result event's `total_cost_usd`
228        /// when present.
229        cost_usd: Option<f64>,
230        /// Turns completed before the cap, from the result event's
231        /// `num_turns` when present.
232        num_turns: Option<u32>,
233        /// Session id from the result event when present; usable to
234        /// resume the capped run.
235        session_id: Option<String>,
236    },
237}
238
239impl Error {
240    /// Construct an [`Error`] from a CLI failure. Runs the
241    /// auth-error classifier; if it matches, returns
242    /// [`Error::Auth`]. Otherwise returns [`Error::CommandFailed`]
243    /// unchanged.
244    ///
245    /// This is the canonical entry point for raising failures from
246    /// `exec.rs`-shaped sites -- replacing direct construction of
247    /// `CommandFailed` ensures every consumer benefits from typed
248    /// auth errors automatically.
249    pub fn from_command_failure(
250        command: String,
251        exit_code: i32,
252        stdout: String,
253        stderr: String,
254        working_dir: Option<PathBuf>,
255    ) -> Self {
256        // A --max-turns cap hit is a terminal `result` event with
257        // subtype "error_max_turns" on stdout. Surface it as its own
258        // typed variant -- ahead of the auth classifier, since it is
259        // never auth-shaped -- so consumers can tell "hit the cap"
260        // (recoverable) from a genuine failure.
261        if stdout.contains("\"error_max_turns\"") {
262            return Self::MaxTurnsExceeded {
263                command,
264                exit_code,
265                max_turns: parse_max_turns_cap(&stdout),
266                cost_usd: parse_result_number(&stdout, "total_cost_usd"),
267                num_turns: parse_result_number(&stdout, "num_turns"),
268                session_id: parse_result_string(&stdout, "session_id"),
269            };
270        }
271        // A --max-budget-usd cap hit mirrors the max-turns shape: a
272        // terminal `result` event with subtype "error_max_budget_usd"
273        // on stdout. Surface it as its own typed variant -- ahead of
274        // the auth classifier, since it is never auth-shaped -- so
275        // consumers can tell "hit the cap" (recoverable) from a genuine
276        // failure.
277        if stdout.contains("\"error_max_budget_usd\"") {
278            return Self::MaxBudgetExceeded {
279                command,
280                exit_code,
281                max_usd: parse_max_budget_cap(&stdout),
282                cost_usd: parse_result_number(&stdout, "total_cost_usd"),
283                num_turns: parse_result_number(&stdout, "num_turns"),
284                session_id: parse_result_string(&stdout, "session_id"),
285            };
286        }
287        if let Some(kind) = crate::auth::classify_failure(exit_code, &stdout, &stderr) {
288            // Prefer stderr for the human-facing message; fall back
289            // to stdout when stderr is empty (some CLIs send all
290            // diagnostics to stdout).
291            let message = if !stderr.trim().is_empty() {
292                stderr.trim().to_string()
293            } else {
294                stdout.trim().to_string()
295            };
296            Self::Auth {
297                kind,
298                command,
299                exit_code,
300                message,
301            }
302        } else {
303            Self::CommandFailed {
304                command,
305                exit_code,
306                stdout,
307                stderr,
308                working_dir,
309            }
310        }
311    }
312
313    /// Inspect whether this error is auth-shaped. Returns
314    /// `Some(kind)` for [`Error::Auth`] (the auto-typed path) and
315    /// also re-runs [`crate::auth::classify_failure`] on
316    /// [`Error::CommandFailed`] for cases the constructor missed.
317    /// Returns `None` for everything else (`Io`, `Timeout`, etc.).
318    ///
319    /// Most consumers should match on [`Error::Auth`] directly --
320    /// this method is the escape hatch for low-confidence
321    /// classifier patterns the constructor was too conservative
322    /// about.
323    pub fn auth_kind(&self) -> Option<AuthErrorKind> {
324        match self {
325            Self::Auth { kind, .. } => Some(*kind),
326            Self::CommandFailed {
327                exit_code,
328                stdout,
329                stderr,
330                ..
331            } => crate::auth::classify_failure(*exit_code, stdout, stderr),
332            _ => None,
333        }
334    }
335}
336
337/// Parse the configured `--max-turns` cap from a CLI result event's
338/// human-readable error ("Reached maximum number of turns (N)").
339/// Returns `None` when the phrase or a parseable number is absent.
340fn parse_max_turns_cap(stdout: &str) -> Option<u32> {
341    stdout
342        .split("maximum number of turns (")
343        .nth(1)
344        .and_then(|rest| rest.split(')').next())
345        .and_then(|n| n.trim().parse::<u32>().ok())
346}
347
348/// Parse the configured `--max-budget-usd` cap from a CLI result
349/// event's human-readable error ("Reached maximum budget ($X)").
350/// Returns `None` when the phrase or a parseable amount is absent.
351fn parse_max_budget_cap(stdout: &str) -> Option<f64> {
352    stdout
353        .split("maximum budget ($")
354        .nth(1)
355        .and_then(|rest| rest.split(')').next())
356        .and_then(|n| n.trim().parse::<f64>().ok())
357}
358
359/// Extract a top-level numeric field (e.g. `"num_turns":2`) from a
360/// result event's raw JSON. String-based, like the cap parsers above:
361/// `serde_json` is an optional dependency and this module must work
362/// without it. Returns `None` when the field or a parseable value is
363/// absent.
364fn parse_result_number<T: std::str::FromStr>(stdout: &str, field: &str) -> Option<T> {
365    let rest = stdout.split(&format!("\"{field}\":")).nth(1)?;
366    let end = rest.find([',', '}']).unwrap_or(rest.len());
367    rest[..end].trim().parse::<T>().ok()
368}
369
370/// Extract a top-level string field (e.g. `"session_id":"abc"`) from
371/// a result event's raw JSON. Assumes the value contains no escaped
372/// quotes, which holds for session ids. Returns `None` when the field
373/// is absent or not a string.
374fn parse_result_string(stdout: &str, field: &str) -> Option<String> {
375    let rest = stdout.split(&format!("\"{field}\":")).nth(1)?;
376    let rest = rest.trim_start().strip_prefix('"')?;
377    rest.split('"').next().map(str::to_string)
378}
379
380impl From<std::io::Error> for Error {
381    fn from(e: std::io::Error) -> Self {
382        Self::Io {
383            message: e.to_string(),
384            source: e,
385            working_dir: None,
386        }
387    }
388}
389
390/// Result type alias for claude-wrapper operations.
391pub type Result<T> = std::result::Result<T, Error>;
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
398        Error::CommandFailed {
399            command: "/bin/claude --print".to_string(),
400            exit_code: 7,
401            stdout: stdout.to_string(),
402            stderr: stderr.to_string(),
403            working_dir,
404        }
405    }
406
407    #[test]
408    fn command_failed_display_includes_command_and_exit_code() {
409        let e = command_failed("", "", None);
410        let s = e.to_string();
411        assert!(s.contains("/bin/claude --print"));
412        assert!(s.contains("exit code 7"));
413    }
414
415    #[test]
416    fn command_failed_display_omits_empty_stdout_and_stderr() {
417        let s = command_failed("", "", None).to_string();
418        assert!(!s.contains("stdout:"));
419        assert!(!s.contains("stderr:"));
420    }
421
422    #[test]
423    fn command_failed_display_includes_nonempty_stdout() {
424        let s = command_failed("hello", "", None).to_string();
425        assert!(s.contains("stdout: hello"));
426    }
427
428    #[test]
429    fn command_failed_display_includes_nonempty_stderr() {
430        let s = command_failed("", "boom", None).to_string();
431        assert!(s.contains("stderr: boom"));
432    }
433
434    #[test]
435    fn command_failed_display_includes_both_streams_when_present() {
436        let s = command_failed("out", "err", None).to_string();
437        assert!(s.contains("stdout: out"));
438        assert!(s.contains("stderr: err"));
439    }
440
441    #[test]
442    fn command_failed_display_includes_working_dir_when_present() {
443        let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
444        assert!(s.contains("/tmp/proj"));
445    }
446
447    #[test]
448    fn command_failed_display_omits_working_dir_when_absent() {
449        let s = command_failed("", "", None).to_string();
450        assert!(!s.contains("(in "));
451    }
452
453    #[test]
454    fn timeout_display_formats_seconds() {
455        let s = Error::Timeout {
456            timeout_seconds: 42,
457        }
458        .to_string();
459        assert!(s.contains("42s"));
460    }
461
462    #[test]
463    fn io_error_display_includes_working_dir_when_present() {
464        let e = Error::Io {
465            message: "spawn failed".to_string(),
466            source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
467            working_dir: Some(PathBuf::from("/work")),
468        };
469        let s = e.to_string();
470        assert!(s.contains("spawn failed"));
471        assert!(s.contains("/work"));
472    }
473
474    // -- from_command_failure / auth_kind ---------------------------
475
476    #[test]
477    fn from_command_failure_unrelated_stderr_yields_command_failed() {
478        let e = Error::from_command_failure(
479            "claude --print".into(),
480            1,
481            String::new(),
482            "syntax error".into(),
483            None,
484        );
485        assert!(matches!(e, Error::CommandFailed { .. }));
486        assert_eq!(e.auth_kind(), None);
487    }
488
489    #[test]
490    fn from_command_failure_auth_stderr_yields_auth_variant() {
491        let e = Error::from_command_failure(
492            "claude --print".into(),
493            1,
494            String::new(),
495            "Not authenticated. Run `claude login`.".into(),
496            None,
497        );
498        match &e {
499            Error::Auth { kind, message, .. } => {
500                assert_eq!(*kind, AuthErrorKind::NotAuthenticated);
501                assert!(message.contains("Not authenticated"));
502            }
503            other => panic!("expected Auth, got {other:?}"),
504        }
505        assert_eq!(e.auth_kind(), Some(AuthErrorKind::NotAuthenticated));
506    }
507
508    #[test]
509    fn from_command_failure_uses_stdout_message_when_stderr_empty() {
510        let e = Error::from_command_failure(
511            "claude --print".into(),
512            1,
513            "Invalid API key".into(),
514            String::new(),
515            None,
516        );
517        match &e {
518            Error::Auth { message, kind, .. } => {
519                assert_eq!(*kind, AuthErrorKind::InvalidCredentials);
520                assert_eq!(message, "Invalid API key");
521            }
522            other => panic!("expected Auth, got {other:?}"),
523        }
524    }
525
526    #[test]
527    fn auth_kind_inspects_command_failed_for_missed_classifications() {
528        // The constructor would have caught this, but a hand-built
529        // CommandFailed (e.g. constructed by older code or by a
530        // caller not going through the helper) is still inspectable.
531        let e = Error::CommandFailed {
532            command: "claude --print".into(),
533            exit_code: 1,
534            stdout: String::new(),
535            stderr: "401 Unauthorized".into(),
536            working_dir: None,
537        };
538        assert_eq!(e.auth_kind(), Some(AuthErrorKind::InvalidCredentials));
539    }
540
541    #[test]
542    fn auth_kind_returns_none_for_non_command_errors() {
543        assert_eq!(Error::NotFound.auth_kind(), None);
544        assert_eq!(Error::Timeout { timeout_seconds: 5 }.auth_kind(), None);
545    }
546
547    // -- max-turns classification (#641) ----------------------------
548
549    // Exact shape of a --max-turns cap-hit result event, from the
550    // field (claude 2.1.173, --output-format json).
551    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)"]}"#;
552
553    #[test]
554    fn from_command_failure_max_turns_yields_typed_variant() {
555        let e = Error::from_command_failure(
556            "claude --print --max-turns 1".into(),
557            1,
558            MAX_TURNS_STDOUT.into(),
559            String::new(),
560            None,
561        );
562        match e {
563            Error::MaxTurnsExceeded {
564                max_turns,
565                exit_code,
566                cost_usd,
567                num_turns,
568                session_id,
569                ..
570            } => {
571                assert_eq!(max_turns, Some(1));
572                assert_eq!(exit_code, 1);
573                assert_eq!(cost_usd, Some(0.08));
574                assert_eq!(num_turns, Some(2));
575                assert_eq!(session_id.as_deref(), Some("s1"));
576            }
577            other => panic!("expected MaxTurnsExceeded, got {other:?}"),
578        }
579    }
580
581    #[test]
582    fn max_turns_detected_without_parseable_cap() {
583        let stdout = r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#;
584        let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
585        match e {
586            Error::MaxTurnsExceeded {
587                max_turns,
588                cost_usd,
589                num_turns,
590                session_id,
591                ..
592            } => {
593                assert_eq!(max_turns, None);
594                assert_eq!(cost_usd, None);
595                assert_eq!(num_turns, None);
596                assert_eq!(session_id, None);
597            }
598            other => panic!("expected MaxTurnsExceeded, got {other:?}"),
599        }
600    }
601
602    #[test]
603    fn non_max_turns_failure_stays_command_failed() {
604        let e =
605            Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
606        assert!(matches!(e, Error::CommandFailed { .. }));
607    }
608
609    #[test]
610    fn max_turns_check_does_not_swallow_auth() {
611        // A genuine auth failure (no error_max_turns) still classifies
612        // as Auth -- the max-turns guard precedes but doesn't shadow it.
613        let e = Error::from_command_failure(
614            "c".into(),
615            1,
616            String::new(),
617            "Not authenticated. Run `claude login`.".into(),
618            None,
619        );
620        assert!(matches!(e, Error::Auth { .. }));
621    }
622
623    #[test]
624    fn parse_max_turns_cap_variants() {
625        assert_eq!(
626            parse_max_turns_cap("Reached maximum number of turns (3)"),
627            Some(3)
628        );
629        assert_eq!(parse_max_turns_cap(MAX_TURNS_STDOUT), Some(1));
630        assert_eq!(parse_max_turns_cap("no such phrase"), None);
631        assert_eq!(parse_max_turns_cap("maximum number of turns (nope)"), None);
632    }
633
634    #[test]
635    fn max_turns_display_includes_cap() {
636        let s = Error::MaxTurnsExceeded {
637            command: "claude --print".into(),
638            exit_code: 1,
639            max_turns: Some(5),
640            cost_usd: None,
641            num_turns: None,
642            session_id: None,
643        }
644        .to_string();
645        assert!(s.contains("--max-turns"), "got: {s}");
646        assert!(s.contains("of 5"), "got: {s}");
647    }
648
649    // -- max-budget-usd classification (#664) -----------------------
650
651    // Shape of a --max-budget-usd cap-hit result event, from the field
652    // (claude 2.1.186, --output-format stream-json). The cap was $0.01
653    // but actual spend was $0.127 -- detection is post-hoc, so `max_usd`
654    // reports the cap and `cost_usd` the spend.
655    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"}"#;
656
657    #[test]
658    fn from_command_failure_max_budget_yields_typed_variant() {
659        let e = Error::from_command_failure(
660            "claude --print --max-budget-usd 0.01".into(),
661            1,
662            MAX_BUDGET_STDOUT.into(),
663            String::new(),
664            None,
665        );
666        match e {
667            Error::MaxBudgetExceeded {
668                max_usd,
669                exit_code,
670                cost_usd,
671                num_turns,
672                session_id,
673                ..
674            } => {
675                assert_eq!(max_usd, Some(0.01));
676                assert_eq!(exit_code, 1);
677                assert_eq!(cost_usd, Some(0.1273986));
678                assert_eq!(num_turns, Some(1));
679                assert_eq!(session_id.as_deref(), Some("s1"));
680            }
681            other => panic!("expected MaxBudgetExceeded, got {other:?}"),
682        }
683    }
684
685    #[test]
686    fn max_budget_detected_without_parseable_cap() {
687        let stdout = r#"{"type":"result","subtype":"error_max_budget_usd","is_error":true}"#;
688        let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
689        match e {
690            Error::MaxBudgetExceeded {
691                max_usd,
692                cost_usd,
693                num_turns,
694                session_id,
695                ..
696            } => {
697                assert_eq!(max_usd, None);
698                assert_eq!(cost_usd, None);
699                assert_eq!(num_turns, None);
700                assert_eq!(session_id, None);
701            }
702            other => panic!("expected MaxBudgetExceeded, got {other:?}"),
703        }
704    }
705
706    #[test]
707    fn non_max_budget_failure_stays_command_failed() {
708        let e =
709            Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
710        assert!(matches!(e, Error::CommandFailed { .. }));
711    }
712
713    #[test]
714    fn max_budget_check_does_not_swallow_auth() {
715        // A genuine auth failure (no error_max_budget_usd) still
716        // classifies as Auth -- the budget guard precedes but doesn't
717        // shadow it.
718        let e = Error::from_command_failure(
719            "c".into(),
720            1,
721            String::new(),
722            "Not authenticated. Run `claude login`.".into(),
723            None,
724        );
725        assert!(matches!(e, Error::Auth { .. }));
726    }
727
728    #[test]
729    fn parse_max_budget_cap_variants() {
730        assert_eq!(
731            parse_max_budget_cap("Reached maximum budget ($0.01)"),
732            Some(0.01)
733        );
734        assert_eq!(parse_max_budget_cap(MAX_BUDGET_STDOUT), Some(0.01));
735        assert_eq!(
736            parse_max_budget_cap("Reached maximum budget ($5)"),
737            Some(5.0)
738        );
739        assert_eq!(parse_max_budget_cap("no such phrase"), None);
740        assert_eq!(parse_max_budget_cap("maximum budget ($nope)"), None);
741    }
742
743    #[test]
744    fn max_budget_display_includes_cap() {
745        let s = Error::MaxBudgetExceeded {
746            command: "claude --print".into(),
747            exit_code: 1,
748            max_usd: Some(0.01),
749            cost_usd: None,
750            num_turns: None,
751            session_id: None,
752        }
753        .to_string();
754        assert!(s.contains("--max-budget-usd"), "got: {s}");
755        assert!(s.contains("of $0.01"), "got: {s}");
756    }
757
758    // -- result-event spend-field extraction (#668) ------------------
759
760    #[test]
761    fn parse_result_number_variants() {
762        assert_eq!(
763            parse_result_number::<f64>(MAX_TURNS_STDOUT, "total_cost_usd"),
764            Some(0.08)
765        );
766        assert_eq!(
767            parse_result_number::<u32>(MAX_TURNS_STDOUT, "num_turns"),
768            Some(2)
769        );
770        // Terminal field (closed by `}` rather than `,`).
771        assert_eq!(
772            parse_result_number::<u32>(r#"{"num_turns":3}"#, "num_turns"),
773            Some(3)
774        );
775        assert_eq!(
776            parse_result_number::<f64>("no json here", "total_cost_usd"),
777            None
778        );
779        assert_eq!(
780            parse_result_number::<u32>(r#"{"num_turns":"nope"}"#, "num_turns"),
781            None
782        );
783    }
784
785    #[test]
786    fn parse_result_string_variants() {
787        assert_eq!(
788            parse_result_string(MAX_TURNS_STDOUT, "session_id").as_deref(),
789            Some("s1")
790        );
791        assert_eq!(parse_result_string("no json here", "session_id"), None);
792        // A non-string value is not misread as a string.
793        assert_eq!(
794            parse_result_string(r#"{"session_id":42}"#, "session_id"),
795            None
796        );
797    }
798}