Skip to main content

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}