forge_sdk/error.rs
1//! Error type for the forge-sdk crate.
2//!
3//! Single typed error in a single `thiserror` enum.
4//! Every fallible public API returns `Result<T, Error>`.
5
6use std::io;
7use thiserror::Error;
8
9/// All errors surfaced by `forge-sdk`.
10#[derive(Debug, Error)]
11pub enum Error {
12 /// The `claude` binary (or a custom replacement) was not found on `PATH`.
13 ///
14 /// Wraps the CLI's `CLINotFoundError`.
15 #[error("claude CLI binary `{binary}` not found on PATH")]
16 CliNotFound {
17 /// The binary path or name that was attempted.
18 binary: String,
19 },
20
21 /// The subprocess's working directory does not exist.
22 ///
23 /// Separate from [`Error::CliNotFound`] because a `spawn` that
24 /// cannot enter its `cwd` fails with the same `ENOENT` as one that
25 /// cannot find the binary.
26 #[error("claude subprocess working directory `{path}` does not exist")]
27 CwdNotFound {
28 /// The working directory that was attempted.
29 path: String,
30 },
31
32 /// The subprocess exited with a non-zero status or was terminated by a signal.
33 ///
34 /// Wraps the CLI's `ProcessError`.
35 #[error("claude subprocess failed (exit code {exit_code:?}): {stderr}")]
36 Process {
37 /// Exit status, if the process exited normally.
38 exit_code: Option<i32>,
39 /// Captured stderr content (may be truncated).
40 stderr: String,
41 },
42
43 /// Could not establish or maintain the stdio connection to the subprocess.
44 ///
45 /// Wraps the CLI's `CLIConnectionError`.
46 #[error("connection to claude subprocess failed: {reason}")]
47 Connection {
48 /// Human-readable explanation.
49 reason: String,
50 },
51
52 /// A line of stream-json from the subprocess could not be parsed as JSON.
53 ///
54 /// Wraps the CLI's `CLIJSONDecodeError`.
55 #[error("stream-json decode failed at line {line}: {source}")]
56 JsonDecode {
57 /// 1-based line number of the offending line.
58 line: u64,
59 /// The underlying serde error.
60 #[source]
61 source: serde_json::Error,
62 },
63
64 /// A stream-json message parsed as JSON but did not match any known message schema.
65 ///
66 /// Wraps the CLI's `MessageParseError` (`_errors.py:48-55`), including
67 /// the optional `data` field that carries the offending payload when
68 /// one is available. the CLI reads this as a `dict[str, Any] | None`;
69 /// forge-sdk types it as `Option<serde_json::Value>` so callers
70 /// inspecting the failure can recover the decoded JSON without
71 /// re-parsing the error message.
72 #[error("stream-json message has unknown or invalid shape: {reason}")]
73 MessageParse {
74 /// Human-readable explanation (often includes the offending JSON excerpt).
75 reason: String,
76 /// Decoded CLI payload the failure originated from, when the
77 /// call site had it in hand. `None` for failures surfaced
78 /// before a payload is parsed (e.g. envelope encode failures).
79 data: Option<serde_json::Value>,
80 },
81
82 /// Forge-sdk failed to JSON-encode a local payload (initialize
83 /// envelope, `control_request` body, hook response, session
84 /// mutation entry, etc.). Distinct from
85 /// [`Error::MessageParse`], which is for parse failures on
86 /// data the CLI sent us - `Encode` is when *we* couldn't
87 /// serialise something locally and the bug is on the SDK or
88 /// caller side, not the CLI's. `context` names what we were
89 /// trying to encode (e.g. "agents map", "initialize body",
90 /// "user prompt").
91 #[error("forge-sdk failed to encode local {context}: {source}")]
92 Encode {
93 /// What we were trying to encode.
94 context: String,
95 /// The underlying serde error.
96 #[source]
97 source: serde_json::Error,
98 },
99
100 /// Wrapping `std::io::Error` for convenience.
101 #[error("I/O error: {0}")]
102 Io(#[from] io::Error),
103}
104
105impl Error {
106 /// Construct a [`Error::MessageParse`] carrying only a reason string
107 /// (most call sites). Equivalent to the struct-literal form with
108 /// `data: None`.
109 pub fn message_parse(reason: impl Into<String>) -> Self {
110 Self::MessageParse { reason: reason.into(), data: None }
111 }
112
113 /// Construct an [`Error::Encode`] for a local JSON encode
114 /// failure. Use this for `serde_json::to_string`/`to_value`
115 /// failures on data the SDK constructed, NOT for parse
116 /// failures on incoming CLI bytes.
117 pub fn encode(context: impl Into<String>, source: serde_json::Error) -> Self {
118 Self::Encode { context: context.into(), source }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn cli_not_found_display() {
128 let err = Error::CliNotFound { binary: "claude".into() };
129 let rendered = format!("{err}");
130 assert!(rendered.contains("claude"), "expected binary in message, got: {rendered}");
131 assert!(
132 rendered.to_lowercase().contains("not found"),
133 "expected 'not found' in message, got: {rendered}"
134 );
135 }
136
137 #[test]
138 fn cwd_not_found_display_names_the_directory() {
139 let err = Error::CwdNotFound { path: "/repo/.claude/worktrees/reviewer".into() };
140 let rendered = format!("{err}");
141 assert!(
142 rendered.contains("/repo/.claude/worktrees/reviewer"),
143 "expected the directory in the message, got: {rendered}"
144 );
145 assert!(
146 rendered.to_lowercase().contains("does not exist"),
147 "expected the reason in the message, got: {rendered}"
148 );
149 }
150
151 #[test]
152 fn process_error_display_includes_exit_code() {
153 let err = Error::Process { exit_code: Some(17), stderr: "permission denied".into() };
154 let rendered = format!("{err}");
155 assert!(rendered.contains("17"), "expected exit code 17, got: {rendered}");
156 assert!(rendered.contains("permission denied"), "expected stderr, got: {rendered}");
157 }
158
159 #[test]
160 fn json_decode_error_display_includes_line_number() {
161 let raw_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
162 let err = Error::JsonDecode { line: 42, source: raw_err };
163 let rendered = format!("{err}");
164 assert!(rendered.contains("42"), "expected line 42, got: {rendered}");
165 }
166}