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