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