gemini_cli_sdk/errors.rs
1//! Error types for `gemini-cli-sdk`.
2//!
3//! All fallible operations in this crate return [`Result<T>`], which is an
4//! alias for `std::result::Result<T, Error>`.
5
6use serde_json::Value;
7
8/// All errors that can occur when using `gemini-cli-sdk`.
9///
10/// The enum is `#[non_exhaustive]` so that new variants can be added in minor
11/// releases without breaking downstream match arms.
12#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
14pub enum Error {
15 /// The `gemini` binary could not be located on `PATH`.
16 #[error("Gemini CLI not found. Install from https://github.com/google-gemini/gemini-cli")]
17 CliNotFound,
18
19 /// The located binary is older than the minimum required version.
20 #[error("CLI version {found} below minimum {required}")]
21 VersionMismatch { found: String, required: String },
22
23 /// The CLI binary does not accept `--experimental-acp` (JSON-RPC mode).
24 #[error(
25 "CLI does not support JSON-RPC mode (--experimental-acp). \
26 Update to latest version."
27 )]
28 JsonRpcModeNotSupported,
29
30 /// `tokio::process::Command::spawn` failed.
31 ///
32 /// This variant carries the underlying [`std::io::Error`] as a *source*
33 /// (accessible via `std::error::Error::source`) but does **not** generate a
34 /// blanket `From<std::io::Error>` impl — that is reserved for [`Error::Io`].
35 #[error("Failed to spawn Gemini process: {0}")]
36 SpawnFailed(#[source] std::io::Error),
37
38 /// The Gemini subprocess terminated unexpectedly.
39 #[error("Gemini process exited with code {code:?}: {stderr}")]
40 ProcessExited { code: Option<i32>, stderr: String },
41
42 /// A line from the subprocess could not be parsed as valid JSON.
43 #[error("Failed to parse JSON: {message} (line: {line})")]
44 ParseError { message: String, line: String },
45
46 /// The server returned a JSON-RPC error object.
47 #[error("JSON-RPC error (code {code}): {message}")]
48 JsonRpcError {
49 code: i64,
50 message: String,
51 data: Option<Value>,
52 },
53
54 /// The wire protocol was violated (unexpected message shape, missing
55 /// required field, etc.).
56 #[error("Wire protocol error: {0}")]
57 ProtocolError(String),
58
59 /// The CLI requires authentication before the JSON-RPC server starts.
60 #[error("Authentication required: {0}")]
61 AuthRequired(String),
62
63 /// Authentication was attempted but the credentials were rejected.
64 #[error("Authentication failed: {0}")]
65 AuthFailed(String),
66
67 /// A low-level I/O error from reading or writing to the subprocess pipes.
68 ///
69 /// This is the blanket conversion target for `std::io::Error` via `?`.
70 #[error("I/O error: {0}")]
71 Io(#[from] std::io::Error),
72
73 /// A JSON serialisation/deserialisation error.
74 #[error("JSON error: {0}")]
75 Json(#[from] serde_json::Error),
76
77 /// A method was called before [`Client::connect`] completed successfully.
78 #[error("Client not connected. Call connect() first.")]
79 NotConnected,
80
81 /// An error originating in the transport layer (framing, flushing, etc.).
82 #[error("Transport error: {0}")]
83 Transport(String),
84
85 /// The supplied configuration is invalid.
86 #[error("Invalid configuration: {0}")]
87 Config(String),
88
89 /// An image path or content failed validation.
90 #[error("Image validation error: {0}")]
91 ImageValidation(String),
92
93 /// An operation exceeded its allotted time.
94 #[error("Operation timed out: {0}")]
95 Timeout(String),
96
97 /// A `send_content` call was made while a previous turn is still streaming.
98 ///
99 /// The Gemini CLI uses a single shared notification stream per session.
100 /// Concurrent `send_content` calls would contend on the internal `Mutex`,
101 /// causing the second call to silently hang until the first completes.
102 /// This variant surfaces the conflict immediately instead.
103 #[error("A turn is already in progress. Await the current stream before sending again.")]
104 TurnInProgress,
105}
106
107/// Convenience alias used throughout `gemini-cli-sdk`.
108pub type Result<T> = std::result::Result<T, Error>;
109
110impl Error {
111 /// Returns `true` if this error indicates that the Gemini subprocess has
112 /// exited.
113 ///
114 /// # Examples
115 ///
116 /// ```rust
117 /// use gemini_cli_sdk::errors::Error;
118 ///
119 /// let err = Error::ProcessExited { code: Some(1), stderr: "fatal".into() };
120 /// assert!(err.is_process_exit());
121 ///
122 /// assert!(!Error::NotConnected.is_process_exit());
123 /// ```
124 #[inline]
125 pub fn is_process_exit(&self) -> bool {
126 matches!(self, Error::ProcessExited { .. })
127 }
128
129 /// Returns `true` if the operation that produced this error is safe to
130 /// retry without modification.
131 ///
132 /// Retriable errors are transient I/O failures, timeouts, and transport
133 /// disruptions.
134 ///
135 /// # Examples
136 ///
137 /// ```rust
138 /// use gemini_cli_sdk::errors::Error;
139 ///
140 /// assert!(Error::Timeout("read".into()).is_retriable());
141 /// assert!(!Error::CliNotFound.is_retriable());
142 /// ```
143 #[inline]
144 pub fn is_retriable(&self) -> bool {
145 matches!(
146 self,
147 Error::Io(_) | Error::Timeout(_) | Error::Transport(_)
148 )
149 }
150
151 /// Returns `true` if this is an authentication-related error.
152 ///
153 /// # Examples
154 ///
155 /// ```rust
156 /// use gemini_cli_sdk::errors::Error;
157 ///
158 /// assert!(Error::AuthRequired("login needed".into()).is_auth_error());
159 /// assert!(Error::AuthFailed("bad token".into()).is_auth_error());
160 /// assert!(!Error::NotConnected.is_auth_error());
161 /// ```
162 #[inline]
163 pub fn is_auth_error(&self) -> bool {
164 matches!(self, Error::AuthRequired(_) | Error::AuthFailed(_))
165 }
166
167 /// Returns `true` if this error originated in the JSON-RPC protocol layer.
168 ///
169 /// # Examples
170 ///
171 /// ```rust
172 /// use gemini_cli_sdk::errors::Error;
173 ///
174 /// let rpc = Error::JsonRpcError { code: -32600, message: "Invalid Request".into(), data: None };
175 /// assert!(rpc.is_jsonrpc_error());
176 ///
177 /// assert!(Error::ProtocolError("bad frame".into()).is_jsonrpc_error());
178 /// assert!(!Error::NotConnected.is_jsonrpc_error());
179 /// ```
180 #[inline]
181 pub fn is_jsonrpc_error(&self) -> bool {
182 matches!(self, Error::JsonRpcError { .. } | Error::ProtocolError(_))
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use std::io;
189
190 use super::*;
191
192 // ── Display ──────────────────────────────────────────────────────────────
193
194 #[test]
195 fn test_error_display() {
196 assert_eq!(
197 Error::CliNotFound.to_string(),
198 "Gemini CLI not found. Install from https://github.com/google-gemini/gemini-cli"
199 );
200
201 assert_eq!(
202 Error::VersionMismatch {
203 found: "0.1.0".into(),
204 required: "0.2.0".into(),
205 }
206 .to_string(),
207 "CLI version 0.1.0 below minimum 0.2.0"
208 );
209
210 assert_eq!(
211 Error::ProcessExited {
212 code: Some(1),
213 stderr: "fatal error".into(),
214 }
215 .to_string(),
216 "Gemini process exited with code Some(1): fatal error"
217 );
218
219 assert_eq!(
220 Error::JsonRpcError {
221 code: -32600,
222 message: "Invalid Request".into(),
223 data: None,
224 }
225 .to_string(),
226 "JSON-RPC error (code -32600): Invalid Request"
227 );
228
229 assert_eq!(
230 Error::NotConnected.to_string(),
231 "Client not connected. Call connect() first."
232 );
233
234 assert_eq!(
235 Error::Timeout("read response".into()).to_string(),
236 "Operation timed out: read response"
237 );
238 }
239
240 // ── Helper predicates ────────────────────────────────────────────────────
241
242 #[test]
243 fn test_error_helpers_is_process_exit() {
244 assert!(Error::ProcessExited {
245 code: None,
246 stderr: String::new()
247 }
248 .is_process_exit());
249
250 assert!(!Error::CliNotFound.is_process_exit());
251 assert!(!Error::NotConnected.is_process_exit());
252 assert!(!Error::Transport("x".into()).is_process_exit());
253 }
254
255 #[test]
256 fn test_error_helpers() {
257 // is_retriable
258 assert!(
259 Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, "reset")).is_retriable()
260 );
261 assert!(Error::Timeout("recv".into()).is_retriable());
262 assert!(Error::Transport("pipe broke".into()).is_retriable());
263 assert!(!Error::CliNotFound.is_retriable());
264 assert!(!Error::NotConnected.is_retriable());
265 assert!(!Error::AuthRequired("login".into()).is_retriable());
266
267 // is_auth_error
268 assert!(Error::AuthRequired("please log in".into()).is_auth_error());
269 assert!(Error::AuthFailed("invalid token".into()).is_auth_error());
270 assert!(!Error::NotConnected.is_auth_error());
271 assert!(!Error::CliNotFound.is_auth_error());
272
273 // is_jsonrpc_error
274 assert!(Error::JsonRpcError {
275 code: -32700,
276 message: "Parse error".into(),
277 data: None,
278 }
279 .is_jsonrpc_error());
280 assert!(Error::ProtocolError("unexpected field".into()).is_jsonrpc_error());
281 assert!(!Error::NotConnected.is_jsonrpc_error());
282 assert!(!Error::CliNotFound.is_jsonrpc_error());
283 }
284
285 // ── From conversions ─────────────────────────────────────────────────────
286
287 #[test]
288 fn test_from_io_error() {
289 let io_err = io::Error::new(io::ErrorKind::NotFound, "x");
290 let err = Error::from(io_err);
291 assert!(matches!(err, Error::Io(_)), "expected Error::Io, got {err:?}");
292 assert!(err.is_retriable());
293 }
294
295 #[test]
296 fn test_from_json_error() {
297 // The ? operator exercises the From<serde_json::Error> impl.
298 fn parse() -> Result<i32> {
299 let v: i32 = serde_json::from_str("bad")?;
300 Ok(v)
301 }
302
303 let err = parse().unwrap_err();
304 assert!(
305 matches!(err, Error::Json(_)),
306 "expected Error::Json, got {err:?}"
307 );
308 }
309
310 // ── Source chain ─────────────────────────────────────────────────────────
311
312 #[test]
313 fn test_spawn_failed_source_chain() {
314 use std::error::Error as StdError;
315
316 let inner = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
317 let err = Error::SpawnFailed(inner);
318
319 // Display message belongs to SpawnFailed, not Io.
320 assert!(
321 err.to_string().starts_with("Failed to spawn Gemini process:"),
322 "unexpected display: {err}"
323 );
324
325 // The underlying io::Error is reachable via the source chain.
326 assert!(
327 err.source().is_some(),
328 "SpawnFailed should expose its source"
329 );
330 }
331}