use serde::{Deserialize, Serialize};
use crate::task::{ContextId, TaskId};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MessageId(pub String);
impl MessageId {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for MessageId {
#[inline]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for MessageId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for MessageId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for MessageId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MessageRole {
#[serde(rename = "unspecified", alias = "ROLE_UNSPECIFIED")]
Unspecified,
#[serde(rename = "user", alias = "ROLE_USER")]
User,
#[serde(rename = "agent", alias = "ROLE_AGENT")]
Agent,
}
impl std::fmt::Display for MessageRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Unspecified => "unspecified",
Self::User => "user",
Self::Agent => "agent",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Message {
#[serde(rename = "messageId")]
pub id: MessageId,
pub role: MessageRole,
pub parts: Vec<Part>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_id: Option<TaskId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_id: Option<ContextId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_task_ids: Option<Vec<TaskId>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Part {
#[serde(flatten)]
pub content: PartContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
impl Part {
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
content: PartContent::Text { text: text.into() },
metadata: None,
}
}
#[must_use]
pub fn file_bytes(bytes: impl Into<String>) -> Self {
Self {
content: PartContent::File {
file: FileContent {
name: None,
mime_type: None,
bytes: Some(bytes.into()),
uri: None,
},
},
metadata: None,
}
}
#[must_use]
pub fn file_uri(uri: impl Into<String>) -> Self {
Self {
content: PartContent::File {
file: FileContent {
name: None,
mime_type: None,
bytes: None,
uri: Some(uri.into()),
},
},
metadata: None,
}
}
#[must_use]
pub const fn file(file: FileContent) -> Self {
Self {
content: PartContent::File { file },
metadata: None,
}
}
#[must_use]
pub const fn data(data: serde_json::Value) -> Self {
Self {
content: PartContent::Data { data },
metadata: None,
}
}
#[must_use]
pub fn raw(raw: impl Into<String>) -> Self {
Self::file_bytes(raw)
}
#[must_use]
pub fn url(url: impl Into<String>) -> Self {
Self::file_uri(url)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileContent {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
}
impl FileContent {
#[must_use]
pub fn from_bytes(bytes: impl Into<String>) -> Self {
Self {
name: None,
mime_type: None,
bytes: Some(bytes.into()),
uri: None,
}
}
#[must_use]
pub fn from_uri(uri: impl Into<String>) -> Self {
Self {
name: None,
mime_type: None,
bytes: None,
uri: Some(uri.into()),
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
pub const fn validate(&self) -> Result<(), &'static str> {
if self.bytes.is_none() && self.uri.is_none() {
Err("FileContent must have at least one of 'bytes' or 'uri' set")
} else {
Ok(())
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PartContent {
#[serde(rename = "text")]
Text {
text: String,
},
#[serde(rename = "file")]
File {
file: FileContent,
},
#[serde(rename = "data")]
Data {
data: serde_json::Value,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn make_message() -> Message {
Message {
id: MessageId::new("msg-1"),
role: MessageRole::User,
parts: vec![Part::text("Hello")],
task_id: None,
context_id: None,
reference_task_ids: None,
extensions: None,
metadata: None,
}
}
#[test]
fn message_roundtrip() {
let msg = make_message();
let json = serde_json::to_string(&msg).expect("serialize");
assert!(json.contains("\"messageId\":\"msg-1\""));
assert!(json.contains("\"role\":\"user\""));
let back: Message = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.id, MessageId::new("msg-1"));
assert_eq!(back.role, MessageRole::User);
}
#[test]
fn text_part_has_type_discriminator() {
let part = Part::text("hello world");
let json = serde_json::to_string(&part).expect("serialize");
assert!(
json.contains("\"type\":\"text\""),
"should have type discriminator: {json}"
);
assert!(json.contains("\"text\":\"hello world\""));
let back: Part = serde_json::from_str(&json).expect("deserialize");
assert!(matches!(back.content, PartContent::Text { ref text } if text == "hello world"));
}
#[test]
fn file_bytes_part_roundtrip() {
let part = Part::file(
FileContent::from_bytes("aGVsbG8=")
.with_name("test.png")
.with_mime_type("image/png"),
);
let json = serde_json::to_string(&part).expect("serialize");
assert!(
json.contains("\"type\":\"file\""),
"should have type discriminator: {json}"
);
assert!(json.contains("\"file\""));
assert!(json.contains("\"name\":\"test.png\""));
assert!(json.contains("\"mimeType\":\"image/png\""));
let back: Part = serde_json::from_str(&json).expect("deserialize");
match back.content {
PartContent::File { file } => {
assert_eq!(file.name.as_deref(), Some("test.png"));
assert_eq!(file.mime_type.as_deref(), Some("image/png"));
assert_eq!(file.bytes.as_deref(), Some("aGVsbG8="));
}
_ => panic!("expected File variant"),
}
}
#[test]
fn file_uri_part_roundtrip() {
let part = Part::file_uri("https://example.com/file.pdf");
let json = serde_json::to_string(&part).expect("serialize");
assert!(json.contains("\"type\":\"file\""));
assert!(json.contains("\"uri\":\"https://example.com/file.pdf\""));
let back: Part = serde_json::from_str(&json).expect("deserialize");
match back.content {
PartContent::File { file } => {
assert_eq!(file.uri.as_deref(), Some("https://example.com/file.pdf"));
}
_ => panic!("expected File variant"),
}
}
#[test]
fn data_part_has_type_discriminator() {
let part = Part::data(serde_json::json!({"key": "value"}));
let json = serde_json::to_string(&part).expect("serialize");
assert!(
json.contains("\"type\":\"data\""),
"should have type discriminator: {json}"
);
assert!(json.contains("\"data\""));
let back: Part = serde_json::from_str(&json).expect("deserialize");
match &back.content {
PartContent::Data { data } => assert_eq!(data["key"], "value"),
_ => panic!("expected Data variant"),
}
}
#[test]
fn none_fields_omitted() {
let msg = make_message();
let json = serde_json::to_string(&msg).expect("serialize");
assert!(
!json.contains("\"taskId\""),
"taskId should be omitted: {json}"
);
assert!(
!json.contains("\"metadata\""),
"metadata should be omitted: {json}"
);
}
#[test]
fn wire_format_role_unspecified_roundtrip() {
let json = serde_json::to_string(&MessageRole::Unspecified).unwrap();
assert_eq!(json, "\"unspecified\"");
let back: MessageRole = serde_json::from_str("\"ROLE_UNSPECIFIED\"").unwrap();
assert_eq!(back, MessageRole::Unspecified);
}
#[test]
fn message_role_display_trait() {
assert_eq!(MessageRole::User.to_string(), "user");
assert_eq!(MessageRole::Agent.to_string(), "agent");
assert_eq!(MessageRole::Unspecified.to_string(), "unspecified");
}
#[test]
fn mixed_part_message_roundtrip() {
let msg = Message {
id: MessageId::new("msg-mixed"),
role: MessageRole::Agent,
parts: vec![
Part::text("Here is the result"),
Part::file_bytes("aGVsbG8="),
Part::file_uri("https://example.com/output.pdf"),
],
task_id: None,
context_id: None,
reference_task_ids: None,
extensions: None,
metadata: None,
};
let json = serde_json::to_string(&msg).expect("serialize mixed-part message");
assert!(json.contains("\"text\":\"Here is the result\""));
assert!(json.contains("\"type\":\"file\""));
let back: Message = serde_json::from_str(&json).expect("deserialize mixed-part message");
assert_eq!(back.parts.len(), 3);
assert!(
matches!(&back.parts[0].content, PartContent::Text { text } if text == "Here is the result")
);
assert!(matches!(&back.parts[1].content, PartContent::File { .. }));
assert!(matches!(&back.parts[2].content, PartContent::File { .. }));
}
#[test]
fn message_with_reference_task_ids() {
use crate::task::TaskId;
let msg = Message {
id: MessageId::new("msg-ref"),
role: MessageRole::User,
parts: vec![Part::text("check these tasks")],
task_id: None,
context_id: None,
reference_task_ids: Some(vec![TaskId::new("task-100"), TaskId::new("task-200")]),
extensions: None,
metadata: None,
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(
json.contains("\"referenceTaskIds\""),
"referenceTaskIds should be present: {json}"
);
assert!(json.contains("\"task-100\""));
assert!(json.contains("\"task-200\""));
let back: Message = serde_json::from_str(&json).expect("deserialize");
let refs = back
.reference_task_ids
.expect("should have reference_task_ids");
assert_eq!(refs.len(), 2);
assert_eq!(refs[0], TaskId::new("task-100"));
assert_eq!(refs[1], TaskId::new("task-200"));
}
#[test]
fn backward_compat_raw_constructor() {
let part = Part::raw("aGVsbG8=");
let json = serde_json::to_string(&part).expect("serialize");
assert!(json.contains("\"type\":\"file\""));
assert!(json.contains("\"bytes\":\"aGVsbG8=\""));
}
#[test]
fn backward_compat_url_constructor() {
let part = Part::url("https://example.com/file.pdf");
let json = serde_json::to_string(&part).expect("serialize");
assert!(json.contains("\"type\":\"file\""));
assert!(json.contains("\"uri\":\"https://example.com/file.pdf\""));
}
#[test]
fn file_content_from_bytes_sets_bytes_only() {
let fc = FileContent::from_bytes("base64data");
assert_eq!(fc.bytes.as_deref(), Some("base64data"));
assert!(fc.uri.is_none());
assert!(fc.name.is_none());
assert!(fc.mime_type.is_none());
}
#[test]
fn file_content_from_uri_sets_uri_only() {
let fc = FileContent::from_uri("https://example.com/f.txt");
assert_eq!(fc.uri.as_deref(), Some("https://example.com/f.txt"));
assert!(fc.bytes.is_none());
assert!(fc.name.is_none());
assert!(fc.mime_type.is_none());
}
#[test]
fn file_content_with_name_sets_name() {
let fc = FileContent::from_bytes("data").with_name("report.pdf");
assert_eq!(fc.name.as_deref(), Some("report.pdf"));
assert_eq!(fc.bytes.as_deref(), Some("data"));
}
#[test]
fn file_content_with_mime_type_sets_mime_type() {
let fc = FileContent::from_bytes("data").with_mime_type("application/pdf");
assert_eq!(fc.mime_type.as_deref(), Some("application/pdf"));
assert_eq!(fc.bytes.as_deref(), Some("data"));
}
#[test]
fn file_content_builder_chaining() {
let fc = FileContent::from_uri("https://example.com/img.png")
.with_name("img.png")
.with_mime_type("image/png");
assert_eq!(fc.uri.as_deref(), Some("https://example.com/img.png"));
assert_eq!(fc.name.as_deref(), Some("img.png"));
assert_eq!(fc.mime_type.as_deref(), Some("image/png"));
assert!(fc.bytes.is_none());
}
#[test]
fn message_id_display() {
let id = MessageId::new("msg-42");
assert_eq!(id.to_string(), "msg-42");
}
#[test]
fn message_id_as_ref() {
let id = MessageId::new("ref-test");
assert_eq!(id.as_ref(), "ref-test");
}
#[test]
fn message_id_from_impls() {
let from_str: MessageId = "str-id".into();
assert_eq!(from_str, MessageId::new("str-id"));
let from_string: MessageId = String::from("string-id").into();
assert_eq!(from_string, MessageId::new("string-id"));
}
#[test]
fn part_text_has_no_metadata() {
let p = Part::text("hi");
assert!(p.metadata.is_none());
assert!(matches!(p.content, PartContent::Text { text } if text == "hi"));
}
#[test]
fn part_file_bytes_sets_bytes_field() {
let p = Part::file_bytes("b64");
match &p.content {
PartContent::File { file } => {
assert_eq!(file.bytes.as_deref(), Some("b64"));
assert!(file.uri.is_none());
assert!(file.name.is_none());
assert!(file.mime_type.is_none());
}
_ => panic!("expected File variant"),
}
assert!(p.metadata.is_none());
}
#[test]
fn part_file_uri_sets_uri_field() {
let p = Part::file_uri("https://a.b/c");
match &p.content {
PartContent::File { file } => {
assert_eq!(file.uri.as_deref(), Some("https://a.b/c"));
assert!(file.bytes.is_none());
}
_ => panic!("expected File variant"),
}
}
#[test]
fn part_data_carries_value() {
let val = serde_json::json!({"key": 123});
let p = Part::data(val.clone());
match &p.content {
PartContent::Data { data } => assert_eq!(data, &val),
_ => panic!("expected Data variant"),
}
assert!(p.metadata.is_none());
}
#[test]
fn file_content_validate_ok_with_bytes() {
let fc = FileContent::from_bytes("data");
assert!(fc.validate().is_ok());
}
#[test]
fn file_content_validate_ok_with_uri() {
let fc = FileContent::from_uri("https://example.com/f.txt");
assert!(fc.validate().is_ok());
}
#[test]
fn file_content_validate_ok_with_both() {
let fc = FileContent {
name: None,
mime_type: None,
bytes: Some("data".into()),
uri: Some("https://example.com/f.txt".into()),
};
assert!(fc.validate().is_ok());
}
#[test]
fn file_content_validate_err_with_neither() {
let fc = FileContent {
name: Some("empty.txt".into()),
mime_type: Some("text/plain".into()),
bytes: None,
uri: None,
};
let err = fc.validate().unwrap_err();
assert!(err.contains("bytes"));
assert!(err.contains("uri"));
}
#[test]
fn part_file_constructor_preserves_all_fields() {
let fc = FileContent {
name: Some("n".into()),
mime_type: Some("m".into()),
bytes: Some("b".into()),
uri: Some("u".into()),
};
let p = Part::file(fc);
match &p.content {
PartContent::File { file } => {
assert_eq!(file.name.as_deref(), Some("n"));
assert_eq!(file.mime_type.as_deref(), Some("m"));
assert_eq!(file.bytes.as_deref(), Some("b"));
assert_eq!(file.uri.as_deref(), Some("u"));
}
_ => panic!("expected File variant"),
}
}
}