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