gemini-cli-sdk 0.1.0

Rust SDK wrapping Google's Gemini CLI as a subprocess via JSON-RPC 2.0
Documentation
//! Public content block types — identical structure to claude-cli-sdk.

use serde::{Deserialize, Serialize};
use serde_json::Value;

// ── ContentBlock ──────────────────────────────────────────────────────────────

/// A block of content within a message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
    Text(TextBlock),
    ToolUse(ToolUseBlock),
    ToolResult(ToolResultBlock),
    Thinking(ThinkingBlock),
    Image(ImageBlock),
}

impl ContentBlock {
    /// Extract text content if this is a `Text` block.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use gemini_cli_sdk::types::content::{ContentBlock, TextBlock};
    ///
    /// let block = ContentBlock::Text(TextBlock::new("hello"));
    /// assert_eq!(block.as_text(), Some("hello"));
    /// ```
    pub fn as_text(&self) -> Option<&str> {
        match self {
            ContentBlock::Text(t) => Some(&t.text),
            _ => None,
        }
    }

    /// Returns `true` if this is a thinking/reasoning block.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use gemini_cli_sdk::types::content::{ContentBlock, ThinkingBlock};
    ///
    /// let block = ContentBlock::Thinking(ThinkingBlock::new("reasoning"));
    /// assert!(block.is_thinking());
    /// ```
    pub fn is_thinking(&self) -> bool {
        matches!(self, ContentBlock::Thinking(_))
    }
}

// ── Text ──────────────────────────────────────────────────────────────────────

/// A plain-text content block.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextBlock {
    pub text: String,
    #[serde(flatten)]
    pub extra: Value,
}

impl TextBlock {
    /// Construct a `TextBlock` with no extra fields.
    pub fn new(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            extra: Value::Object(Default::default()),
        }
    }
}

// ── Tool Use ──────────────────────────────────────────────────────────────────

/// A tool invocation block emitted by the model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolUseBlock {
    /// Unique identifier for this tool call, used to correlate results.
    pub id: String,
    /// Name of the tool being called.
    pub name: String,
    /// JSON input arguments for the tool.
    #[serde(default)]
    pub input: Value,
    #[serde(flatten)]
    pub extra: Value,
}

// ── Tool Result ───────────────────────────────────────────────────────────────

/// The result of a tool call, sent back to the model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolResultBlock {
    /// Must match the `id` field of the corresponding [`ToolUseBlock`].
    pub tool_use_id: String,
    /// One or more content items produced by the tool.
    #[serde(default)]
    pub content: Vec<ToolResultContent>,
    /// Set to `true` if the tool encountered an error.
    #[serde(default)]
    pub is_error: bool,
    #[serde(flatten)]
    pub extra: Value,
}

/// Content produced by a tool — either text or an image.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolResultContent {
    Text { text: String },
    Image { source: ImageSource },
}

// ── Thinking ──────────────────────────────────────────────────────────────────

/// An extended-thinking / reasoning block.
///
/// Maps from Gemini's `agent_thought_chunk` — kept separate from text output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThinkingBlock {
    /// The reasoning text produced before the final answer.
    #[serde(default)]
    pub thinking: String,
    #[serde(flatten)]
    pub extra: Value,
}

impl ThinkingBlock {
    /// Construct a `ThinkingBlock` with no extra fields.
    pub fn new(thinking: impl Into<String>) -> Self {
        Self {
            thinking: thinking.into(),
            extra: Value::Object(Default::default()),
        }
    }
}

// ── Image ─────────────────────────────────────────────────────────────────────

/// An image content block.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageBlock {
    pub source: ImageSource,
    #[serde(flatten)]
    pub extra: Value,
}

/// The source of an image — either inline base-64 data or a remote URL.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageSource {
    Base64(Base64ImageSource),
    Url(UrlImageSource),
}

/// Inline base-64 encoded image data.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Base64ImageSource {
    /// MIME type, e.g. `"image/png"`.
    pub media_type: String,
    /// Base-64 encoded bytes.
    pub data: String,
}

/// A remotely-hosted image referenced by URL.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UrlImageSource {
    pub url: String,
}

// ── UserContent ───────────────────────────────────────────────────────────────

/// Content that can be sent in a user message.
///
/// Use the convenience constructors rather than constructing variants directly
/// to keep call-sites free of boilerplate.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UserContent {
    Text { text: String },
    Image { source: ImageSource },
}

impl UserContent {
    /// Create a text user-content item.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use gemini_cli_sdk::types::content::UserContent;
    ///
    /// let uc = UserContent::text("Hello, Gemini!");
    /// ```
    pub fn text(text: impl Into<String>) -> Self {
        UserContent::Text { text: text.into() }
    }

    /// Create an inline base-64 image user-content item.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use gemini_cli_sdk::types::content::UserContent;
    ///
    /// let uc = UserContent::image_base64("image/png", "iVBORw0KGgo=");
    /// ```
    pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
        UserContent::Image {
            source: ImageSource::Base64(Base64ImageSource {
                media_type: media_type.into(),
                data: data.into(),
            }),
        }
    }

    /// Create a URL-referenced image user-content item.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use gemini_cli_sdk::types::content::UserContent;
    ///
    /// let uc = UserContent::image_url("https://example.com/img.png");
    /// ```
    pub fn image_url(url: impl Into<String>) -> Self {
        UserContent::Image {
            source: ImageSource::Url(UrlImageSource { url: url.into() }),
        }
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    // ── ContentBlock serde ────────────────────────────────────────────────────

    #[test]
    fn test_content_block_text_serde_roundtrip() {
        let block = ContentBlock::Text(TextBlock::new("hello world"));
        let json = serde_json::to_value(&block).expect("serialize");
        assert_eq!(json["type"], "text");
        assert_eq!(json["text"], "hello world");

        let restored: ContentBlock = serde_json::from_value(json).expect("deserialize");
        assert_eq!(restored, block);
    }

    #[test]
    fn test_content_block_tool_use_serde_roundtrip() {
        let block = ContentBlock::ToolUse(ToolUseBlock {
            id: "call_123".into(),
            name: "read_file".into(),
            input: json!({"path": "/tmp/foo.txt"}),
            extra: Value::Object(Default::default()),
        });
        let json = serde_json::to_value(&block).expect("serialize");
        assert_eq!(json["type"], "tool_use");
        assert_eq!(json["id"], "call_123");
        assert_eq!(json["name"], "read_file");

        let restored: ContentBlock = serde_json::from_value(json).expect("deserialize");
        assert_eq!(restored, block);
    }

    // ── ContentBlock helpers ──────────────────────────────────────────────────

    #[test]
    fn test_content_block_as_text_some() {
        let block = ContentBlock::Text(TextBlock::new("greetings"));
        assert_eq!(block.as_text(), Some("greetings"));
    }

    #[test]
    fn test_content_block_as_text_none() {
        let block = ContentBlock::ToolUse(ToolUseBlock {
            id: "id".into(),
            name: "tool".into(),
            input: Value::Null,
            extra: Value::Object(Default::default()),
        });
        assert_eq!(block.as_text(), None);
    }

    #[test]
    fn test_content_block_is_thinking() {
        let thinking = ContentBlock::Thinking(ThinkingBlock::new("deep thought"));
        assert!(thinking.is_thinking());

        let text = ContentBlock::Text(TextBlock::new("plain text"));
        assert!(!text.is_thinking());
    }

    // ── Constructor helpers ───────────────────────────────────────────────────

    #[test]
    fn test_text_block_new() {
        let tb = TextBlock::new("sample");
        assert_eq!(tb.text, "sample");
        assert_eq!(tb.extra, Value::Object(Default::default()));
    }

    #[test]
    fn test_thinking_block_new() {
        let tb = ThinkingBlock::new("pondering");
        assert_eq!(tb.thinking, "pondering");
        assert_eq!(tb.extra, Value::Object(Default::default()));
    }

    // ── UserContent ───────────────────────────────────────────────────────────

    #[test]
    fn test_user_content_text() {
        let uc = UserContent::text("hi there");
        assert_eq!(uc, UserContent::Text { text: "hi there".into() });

        let json = serde_json::to_value(&uc).expect("serialize");
        assert_eq!(json["type"], "text");
        assert_eq!(json["text"], "hi there");

        let restored: UserContent = serde_json::from_value(json).expect("deserialize");
        assert_eq!(restored, uc);
    }

    #[test]
    fn test_user_content_image_base64() {
        let uc = UserContent::image_base64("image/png", "abc123==");
        let expected = UserContent::Image {
            source: ImageSource::Base64(Base64ImageSource {
                media_type: "image/png".into(),
                data: "abc123==".into(),
            }),
        };
        assert_eq!(uc, expected);

        let json = serde_json::to_value(&uc).expect("serialize");
        assert_eq!(json["type"], "image");
        assert_eq!(json["source"]["type"], "base64");
        assert_eq!(json["source"]["media_type"], "image/png");
        assert_eq!(json["source"]["data"], "abc123==");
    }

    #[test]
    fn test_user_content_image_url() {
        let uc = UserContent::image_url("https://example.com/photo.jpg");
        let expected = UserContent::Image {
            source: ImageSource::Url(UrlImageSource {
                url: "https://example.com/photo.jpg".into(),
            }),
        };
        assert_eq!(uc, expected);

        let json = serde_json::to_value(&uc).expect("serialize");
        assert_eq!(json["type"], "image");
        assert_eq!(json["source"]["type"], "url");
        assert_eq!(json["source"]["url"], "https://example.com/photo.jpg");
    }
}