use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text {
text: String,
},
Image {
source: ImageSource,
},
Document {
source: DocumentSource,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
},
Audio {
source: AudioSource,
},
Video {
source: VideoSource,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: Vec<ContentBlock>,
},
Thinking {
thinking: String,
},
}
impl ContentBlock {
pub fn text(s: impl Into<String>) -> Self {
Self::Text { text: s.into() }
}
pub fn image_url(url: impl Into<String>) -> Self {
Self::Image {
source: ImageSource::Url { url: url.into() },
}
}
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
Self::Image {
source: ImageSource::Base64 {
media_type: media_type.into(),
data: data.into(),
},
}
}
pub fn audio_url(url: impl Into<String>) -> Self {
Self::Audio {
source: AudioSource::Url { url: url.into() },
}
}
pub fn audio_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
Self::Audio {
source: AudioSource::Base64 {
media_type: media_type.into(),
data: data.into(),
},
}
}
pub fn video_url(url: impl Into<String>) -> Self {
Self::Video {
source: VideoSource::Url { url: url.into() },
}
}
pub fn video_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
Self::Video {
source: VideoSource::Base64 {
media_type: media_type.into(),
data: data.into(),
},
}
}
pub fn document_url(url: impl Into<String>, title: Option<String>) -> Self {
Self::Document {
source: DocumentSource::Url { url: url.into() },
title,
}
}
pub fn document_base64(
media_type: impl Into<String>,
data: impl Into<String>,
title: Option<String>,
) -> Self {
Self::Document {
source: DocumentSource::Base64 {
media_type: media_type.into(),
data: data.into(),
},
title,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageSource {
Base64 { media_type: String, data: String },
Url { url: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DocumentSource {
Base64 { media_type: String, data: String },
Url { url: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AudioSource {
Base64 { media_type: String, data: String },
Url { url: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum VideoSource {
Base64 { media_type: String, data: String },
Url { url: String },
}
pub fn extract_text(blocks: &[ContentBlock]) -> String {
blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn text_block_serde_roundtrip() {
let block = ContentBlock::text("hello");
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json, json!({"type": "text", "text": "hello"}));
let parsed: ContentBlock = serde_json::from_value(json).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn image_url_block_serde_roundtrip() {
let block = ContentBlock::image_url("https://example.com/img.png");
let json = serde_json::to_value(&block).unwrap();
let parsed: ContentBlock = serde_json::from_value(json).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn document_block_serde_roundtrip() {
let block =
ContentBlock::document_base64("application/pdf", "JVBER", Some("report.pdf".into()));
let json = serde_json::to_value(&block).unwrap();
let parsed: ContentBlock = serde_json::from_value(json).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn extract_text_concatenates_text_blocks() {
let blocks = vec![
ContentBlock::text("hello "),
ContentBlock::image_url("img.png"),
ContentBlock::text("world"),
];
assert_eq!(extract_text(&blocks), "hello world");
}
#[test]
fn extract_text_empty_for_no_text_blocks() {
let blocks = vec![ContentBlock::image_url("img.png")];
assert_eq!(extract_text(&blocks), "");
}
#[test]
fn extract_text_empty_for_empty_vec() {
assert_eq!(extract_text(&[]), "");
}
#[test]
fn content_blocks_array_serde_roundtrip() {
let blocks = vec![
ContentBlock::text("Look:"),
ContentBlock::image_url("https://example.com/img.png"),
];
let json = serde_json::to_value(&blocks).unwrap();
assert!(json.is_array());
let parsed: Vec<ContentBlock> = serde_json::from_value(json).unwrap();
assert_eq!(parsed, blocks);
}
#[test]
fn thinking_block_serde_roundtrip() {
let block = ContentBlock::Thinking {
thinking: "Let me consider...".into(),
};
let json_val = serde_json::to_value(&block).unwrap();
assert_eq!(json_val["type"], "thinking");
assert_eq!(json_val["thinking"], "Let me consider...");
let parsed: ContentBlock = serde_json::from_value(json_val).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn tool_use_block_serde_roundtrip() {
let block = ContentBlock::ToolUse {
id: "call_1".into(),
name: "search".into(),
input: json!({"query": "rust"}),
};
let json_val = serde_json::to_value(&block).unwrap();
assert_eq!(json_val["type"], "tool_use");
assert_eq!(json_val["id"], "call_1");
assert_eq!(json_val["name"], "search");
let parsed: ContentBlock = serde_json::from_value(json_val).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn tool_result_block_serde_roundtrip() {
let block = ContentBlock::ToolResult {
tool_use_id: "call_1".into(),
content: vec![ContentBlock::text("Result: 42")],
};
let json_val = serde_json::to_value(&block).unwrap();
assert_eq!(json_val["type"], "tool_result");
assert_eq!(json_val["tool_use_id"], "call_1");
let parsed: ContentBlock = serde_json::from_value(json_val).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn image_base64_block_serde_roundtrip() {
let block = ContentBlock::image_base64("image/png", "iVBORw0KGgo=");
let json_val = serde_json::to_value(&block).unwrap();
assert_eq!(json_val["type"], "image");
assert_eq!(json_val["source"]["type"], "base64");
assert_eq!(json_val["source"]["media_type"], "image/png");
let parsed: ContentBlock = serde_json::from_value(json_val).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn document_block_without_title_omits_field() {
let block = ContentBlock::document_base64("application/pdf", "JVBER", None);
let json_val = serde_json::to_value(&block).unwrap();
assert!(json_val.get("title").is_none());
let parsed: ContentBlock = serde_json::from_value(json_val).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn mixed_content_blocks_roundtrip() {
let blocks = vec![
ContentBlock::text("Here is the result:"),
ContentBlock::ToolUse {
id: "c1".into(),
name: "calc".into(),
input: json!({"expr": "2+2"}),
},
ContentBlock::ToolResult {
tool_use_id: "c1".into(),
content: vec![ContentBlock::text("4")],
},
ContentBlock::Thinking {
thinking: "hmm".into(),
},
];
let json_val = serde_json::to_value(&blocks).unwrap();
let parsed: Vec<ContentBlock> = serde_json::from_value(json_val).unwrap();
assert_eq!(parsed, blocks);
}
#[test]
fn content_block_debug_output() {
let block = ContentBlock::text("hi");
let debug = format!("{:?}", block);
assert!(debug.contains("Text"));
assert!(debug.contains("hi"));
}
#[test]
fn audio_url_block_serde_roundtrip() {
let block = ContentBlock::audio_url("https://example.com/audio.mp3");
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "audio");
assert_eq!(json["source"]["type"], "url");
assert_eq!(json["source"]["url"], "https://example.com/audio.mp3");
let parsed: ContentBlock = serde_json::from_value(json).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn audio_base64_block_serde_roundtrip() {
let block = ContentBlock::audio_base64("audio/mpeg", "SGVsbG8=");
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "audio");
assert_eq!(json["source"]["type"], "base64");
assert_eq!(json["source"]["media_type"], "audio/mpeg");
let parsed: ContentBlock = serde_json::from_value(json).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn video_url_block_serde_roundtrip() {
let block = ContentBlock::video_url("https://example.com/video.mp4");
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "video");
assert_eq!(json["source"]["type"], "url");
assert_eq!(json["source"]["url"], "https://example.com/video.mp4");
let parsed: ContentBlock = serde_json::from_value(json).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn video_base64_block_serde_roundtrip() {
let block = ContentBlock::video_base64("video/mp4", "AAAA");
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "video");
assert_eq!(json["source"]["type"], "base64");
assert_eq!(json["source"]["media_type"], "video/mp4");
let parsed: ContentBlock = serde_json::from_value(json).unwrap();
assert_eq!(parsed, block);
}
#[test]
fn content_block_clone() {
let block = ContentBlock::text("hello");
let cloned = block.clone();
assert_eq!(block, cloned);
}
}