Skip to main content

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}