use serde::{Deserialize, Serialize};
use crate::{capabilities::MediaKind, media::MediaSource};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text {
text: String,
},
Image {
source: MediaSource,
},
Document {
source: MediaSource,
#[serde(skip_serializing_if = "Option::is_none", default)]
name: Option<String>,
},
Audio {
source: MediaSource,
},
Video {
source: MediaSource,
},
CacheBreakpoint,
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
#[must_use]
pub const fn image(source: MediaSource) -> Self {
Self::Image { source }
}
#[must_use]
pub const fn document(source: MediaSource, name: Option<String>) -> Self {
Self::Document { source, name }
}
#[must_use]
pub const fn audio(source: MediaSource) -> Self {
Self::Audio { source }
}
#[must_use]
pub const fn video(source: MediaSource) -> Self {
Self::Video { source }
}
#[must_use]
pub const fn cache_breakpoint() -> Self {
Self::CacheBreakpoint
}
#[must_use]
pub const fn is_cache_breakpoint(&self) -> bool {
matches!(self, Self::CacheBreakpoint)
}
#[must_use]
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text),
Self::Image { .. }
| Self::Document { .. }
| Self::Audio { .. }
| Self::Video { .. }
| Self::CacheBreakpoint => None,
}
}
#[must_use]
pub const fn media_kind(&self) -> Option<MediaKind> {
match self {
Self::Text { .. } | Self::CacheBreakpoint => None,
Self::Image { .. } => Some(MediaKind::Image),
Self::Document { .. } => Some(MediaKind::Document),
Self::Audio { .. } => Some(MediaKind::Audio),
Self::Video { .. } => Some(MediaKind::Video),
}
}
#[must_use]
pub const fn media_source(&self) -> Option<&MediaSource> {
match self {
Self::Text { .. } | Self::CacheBreakpoint => None,
Self::Image { source }
| Self::Document { source, .. }
| Self::Audio { source }
| Self::Video { source } => Some(source),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentPart>,
}
impl Message {
#[must_use]
pub const fn with_parts(role: Role, content: Vec<ContentPart>) -> Self {
Self { role, content }
}
pub fn system(text: impl Into<String>) -> Self {
Self {
role: Role::System,
content: vec![ContentPart::text(text)],
}
}
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: vec![ContentPart::text(text)],
}
}
pub fn assistant(text: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: vec![ContentPart::text(text)],
}
}
#[must_use]
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(ContentPart::as_text)
.collect::<Vec<_>>()
.join("")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::media::{HttpsUrl, MediaType, S3Uri};
fn https(url: &str) -> MediaSource {
MediaSource::Url {
url: HttpsUrl::parse(url).unwrap(),
}
}
fn png_bytes(data: Vec<u8>) -> MediaSource {
MediaSource::InlineBytes {
mime: MediaType::parse("image/png").unwrap(),
data,
}
}
#[test]
fn text_constructors_set_correct_role() {
assert_eq!(Message::system("hi").role, Role::System);
assert_eq!(Message::user("hi").role, Role::User);
assert_eq!(Message::assistant("hi").role, Role::Assistant);
}
#[test]
fn text_constructors_create_single_text_part() {
let msg = Message::user("hello");
assert_eq!(msg.content.len(), 1);
assert_eq!(msg.content[0].as_text(), Some("hello"));
}
#[test]
fn with_parts_preserves_mixed_content() {
let msg = Message::with_parts(
Role::User,
vec![
ContentPart::text("Describe this:"),
ContentPart::image(https("https://example.com/img.png")),
],
);
assert_eq!(msg.role, Role::User);
assert_eq!(msg.content.len(), 2);
assert_eq!(msg.content[0].as_text(), Some("Describe this:"));
assert_eq!(msg.content[1].as_text(), None);
assert_eq!(msg.content[1].media_kind(), Some(MediaKind::Image));
}
#[test]
fn text_method_concatenates_text_parts() {
let msg = Message::with_parts(
Role::User,
vec![
ContentPart::text("Hello "),
ContentPart::image(https("https://example.com/img.png")),
ContentPart::text("world"),
],
);
assert_eq!(msg.text(), "Hello world");
}
#[test]
fn text_method_returns_empty_for_no_text_parts() {
let msg = Message::with_parts(
Role::User,
vec![ContentPart::image(https("https://example.com/img.png"))],
);
assert_eq!(msg.text(), "");
}
#[test]
fn content_part_text_as_text() {
let part = ContentPart::text("hello");
assert_eq!(part.as_text(), Some("hello"));
assert_eq!(part.media_kind(), None);
assert!(part.media_source().is_none());
}
#[test]
fn content_part_image_kind_and_source() {
let src = https("https://example.com/img.png");
let part = ContentPart::image(src.clone());
assert_eq!(part.as_text(), None);
assert_eq!(part.media_kind(), Some(MediaKind::Image));
assert_eq!(part.media_source(), Some(&src));
}
#[test]
fn content_part_document_with_name() {
let src = MediaSource::S3 {
uri: S3Uri::parse("s3://b/k.pdf").unwrap(),
bucket_owner: None,
};
let part = ContentPart::document(src, Some("report.pdf".into()));
assert_eq!(part.media_kind(), Some(MediaKind::Document));
match &part {
ContentPart::Document { name, .. } => assert_eq!(name.as_deref(), Some("report.pdf")),
other => panic!("expected Document, got {other:?}"),
}
}
#[test]
fn message_serde_round_trip() {
let msg = Message::user("hello world");
let json = serde_json::to_string(&msg).unwrap();
let deserialized: Message = serde_json::from_str(&json).unwrap();
assert_eq!(msg, deserialized);
}
#[test]
fn mixed_content_serde_round_trip() {
let msg = Message::with_parts(
Role::User,
vec![
ContentPart::text("Look at this:"),
ContentPart::image(https("https://example.com/img.png")),
],
);
let json = serde_json::to_string(&msg).unwrap();
let deserialized: Message = serde_json::from_str(&json).unwrap();
assert_eq!(msg, deserialized);
}
#[test]
fn role_serializes_lowercase() {
let json = serde_json::to_string(&Role::System).unwrap();
assert_eq!(json, "\"system\"");
}
#[test]
fn text_part_serializes_with_type_tag() {
let part = ContentPart::text("hello");
let json = serde_json::to_string(&part).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["type"], "text");
assert_eq!(value["text"], "hello");
}
#[test]
fn image_part_serializes_with_type_tag_and_source() {
let part = ContentPart::image(https("https://example.com/img.png"));
let json = serde_json::to_string(&part).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["type"], "image");
assert_eq!(value["source"]["source"], "url");
assert_eq!(value["source"]["url"], "https://example.com/img.png");
}
#[test]
fn audio_video_serialize_with_correct_type_tags() {
let audio = ContentPart::audio(png_bytes(vec![0, 1, 2]));
let json = serde_json::to_value(&audio).unwrap();
assert_eq!(json["type"], "audio");
let video = ContentPart::video(png_bytes(vec![0, 1, 2]));
let json = serde_json::to_value(&video).unwrap();
assert_eq!(json["type"], "video");
}
#[test]
fn document_omits_name_when_none() {
let part = ContentPart::document(https("https://example.com/x.pdf"), None);
let value = serde_json::to_value(&part).unwrap();
assert!(value.get("name").is_none());
}
#[test]
fn document_includes_name_when_some() {
let part = ContentPart::document(
https("https://example.com/x.pdf"),
Some("report.pdf".into()),
);
let value = serde_json::to_value(&part).unwrap();
assert_eq!(value["name"], "report.pdf");
}
}