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