use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Message {
pub role: MessageRole,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parts: Vec<Part>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl Message {
#[must_use]
pub const fn new(role: MessageRole) -> Self {
Self {
role,
parts: Vec::new(),
metadata: BTreeMap::new(),
}
}
#[must_use]
pub fn with_part(mut self, part: impl Into<Part>) -> Self {
self.parts.push(part.into());
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
#[default]
User,
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Part {
Text(TextPart),
File(FilePart),
Data(DataPart),
}
impl From<TextPart> for Part {
fn from(part: TextPart) -> Self {
Self::Text(part)
}
}
impl From<FilePart> for Part {
fn from(part: FilePart) -> Self {
Self::File(part)
}
}
impl From<DataPart> for Part {
fn from(part: DataPart) -> Self {
Self::Data(part)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextPart {
pub text: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilePart {
pub file: FileContent,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileContent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DataPart {
pub data: serde_json::Value,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{DataPart, FileContent, FilePart, Message, MessageRole, Part, TextPart};
type TestError = Box<dyn std::error::Error>;
fn metadata_with(key: &str, value: serde_json::Value) -> BTreeMap<String, serde_json::Value> {
let mut map = BTreeMap::new();
map.insert(key.to_owned(), value);
map
}
#[test]
fn message_serialises_with_role_and_parts() -> Result<(), TestError> {
let message = Message::new(MessageRole::User).with_part(TextPart {
text: "Hello.".to_owned(),
metadata: BTreeMap::new(),
});
let value = serde_json::to_value(&message)?;
let obj = value
.as_object()
.ok_or("Message must serialise to a JSON object")?;
assert_eq!(
obj.get("role").and_then(serde_json::Value::as_str),
Some("user")
);
let parts = obj
.get("parts")
.and_then(serde_json::Value::as_array)
.ok_or("`parts` must be a JSON array")?;
assert_eq!(parts.len(), 1);
Ok(())
}
#[test]
fn message_with_no_metadata_omits_the_field() -> Result<(), TestError> {
let message = Message::new(MessageRole::Agent);
let value = serde_json::to_value(&message)?;
let obj = value
.as_object()
.ok_or("Message must serialise to a JSON object")?;
assert!(!obj.contains_key("metadata"));
assert!(!obj.contains_key("parts"));
Ok(())
}
#[test]
fn message_round_trips_with_role_and_metadata() -> Result<(), TestError> {
let message = Message {
role: MessageRole::Agent,
parts: vec![],
metadata: metadata_with("trace_id", serde_json::json!("abc-123")),
};
let json = serde_json::to_string(&message)?;
let parsed: Message = serde_json::from_str(&json)?;
assert_eq!(parsed, message);
Ok(())
}
#[test]
fn text_part_serialises_with_text_field() -> Result<(), TestError> {
let part = Part::Text(TextPart {
text: "Hello.".to_owned(),
metadata: BTreeMap::new(),
});
let value = serde_json::to_value(&part)?;
let obj = value
.as_object()
.ok_or("a Part must serialise to a JSON object")?;
assert_eq!(
obj.get("text").and_then(serde_json::Value::as_str),
Some("Hello.")
);
assert!(!obj.contains_key("file"));
assert!(!obj.contains_key("data"));
Ok(())
}
#[test]
fn file_part_serialises_with_file_field() -> Result<(), TestError> {
let part = Part::File(FilePart {
file: FileContent {
name: Some("hello.txt".to_owned()),
mime_type: Some("text/plain".to_owned()),
bytes: Some("SGVsbG8=".to_owned()),
uri: None,
},
metadata: BTreeMap::new(),
});
let value = serde_json::to_value(&part)?;
let obj = value
.as_object()
.ok_or("a Part must serialise to a JSON object")?;
let file = obj
.get("file")
.and_then(serde_json::Value::as_object)
.ok_or("`file` must be a JSON object")?;
assert_eq!(
file.get("name").and_then(serde_json::Value::as_str),
Some("hello.txt")
);
assert_eq!(
file.get("mimeType").and_then(serde_json::Value::as_str),
Some("text/plain")
);
assert_eq!(
file.get("bytes").and_then(serde_json::Value::as_str),
Some("SGVsbG8=")
);
assert!(!file.contains_key("uri"));
Ok(())
}
#[test]
fn data_part_serialises_with_data_field() -> Result<(), TestError> {
let part = Part::Data(DataPart {
data: serde_json::json!({ "answer": 42 }),
metadata: BTreeMap::new(),
});
let value = serde_json::to_value(&part)?;
let obj = value
.as_object()
.ok_or("a Part must serialise to a JSON object")?;
let data = obj
.get("data")
.and_then(serde_json::Value::as_object)
.ok_or("`data` must be a JSON object")?;
assert_eq!(
data.get("answer").and_then(serde_json::Value::as_i64),
Some(42)
);
Ok(())
}
#[test]
fn parts_are_unambiguous_on_deserialise() -> Result<(), TestError> {
let text_json = r#"{"text":"hi"}"#;
let file_json = r#"{"file":{"name":"x"}}"#;
let data_json = r#"{"data":{"a":1}}"#;
let text: Part = serde_json::from_str(text_json)
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let file: Part = serde_json::from_str(file_json)
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let data: Part = serde_json::from_str(data_json)
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
assert!(matches!(text, Part::Text(_)));
assert!(matches!(file, Part::File(_)));
assert!(matches!(data, Part::Data(_)));
Ok(())
}
#[test]
fn text_part_with_metadata_round_trips() -> Result<(), TestError> {
let part = Part::Text(TextPart {
text: "Hello.".to_owned(),
metadata: metadata_with("lang", serde_json::json!("en")),
});
let json = serde_json::to_string(&part)?;
let parsed: Part = serde_json::from_str(&json)?;
assert_eq!(parsed, part);
assert!(json.contains(r#""metadata":{"lang":"en"}"#));
Ok(())
}
}