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