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}