gemini-cli-sdk 0.1.0

Rust SDK wrapping Google's Gemini CLI as a subprocess via JSON-RPC 2.0
Documentation
//! 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"
        );
    }
}