use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
EmptyContent {
role: &'static str,
},
EmptyText {
role: &'static str,
},
UnknownTool {
tool_call_id: String,
tool_name: String,
},
UnmatchedToolResult {
tool_result_id: String,
},
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationError::EmptyContent { role } => {
write!(f, "{} message has empty content", role)
}
ValidationError::EmptyText { role } => {
write!(f, "{} message contains empty or whitespace-only text", role)
}
ValidationError::UnknownTool {
tool_call_id,
tool_name,
} => {
write!(
f,
"tool call '{}' references unknown tool '{}'",
tool_call_id, tool_name
)
}
ValidationError::UnmatchedToolResult { tool_result_id } => {
write!(
f,
"tool result '{}' does not match any previous tool call",
tool_result_id
)
}
}
}
}
impl std::error::Error for ValidationError {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "role", rename_all = "lowercase")]
pub enum Message {
User {
content: Vec<UserContent>,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
Assistant {
content: Vec<AssistantContent>,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning: Option<String>,
},
}
impl Message {
pub fn user(text: impl Into<String>) -> Self {
Message::User {
content: vec![UserContent::Text { text: text.into() }],
id: None,
}
}
pub fn user_with_id(text: impl Into<String>, id: impl Into<String>) -> Self {
Message::User {
content: vec![UserContent::Text { text: text.into() }],
id: Some(id.into()),
}
}
pub fn assistant(text: impl Into<String>) -> Self {
Message::Assistant {
content: vec![AssistantContent::Text { text: text.into() }],
id: None,
reasoning: None,
}
}
pub fn assistant_with_id(text: impl Into<String>, id: impl Into<String>) -> Self {
Message::Assistant {
content: vec![AssistantContent::Text { text: text.into() }],
id: Some(id.into()),
reasoning: None,
}
}
pub fn assistant_with_reasoning(text: impl Into<String>, reasoning: impl Into<String>) -> Self {
Message::Assistant {
content: vec![AssistantContent::Text { text: text.into() }],
id: None,
reasoning: Some(reasoning.into()),
}
}
pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Message::User {
content: vec![UserContent::ToolResult {
id: tool_call_id.into(),
content: content.into(),
}],
id: None,
}
}
pub fn id(&self) -> Option<&str> {
match self {
Message::User { id, .. } => id.as_deref(),
Message::Assistant { id, .. } => id.as_deref(),
}
}
pub fn with_id(mut self, new_id: impl Into<String>) -> Self {
match &mut self {
Message::User { id, .. } => *id = Some(new_id.into()),
Message::Assistant { id, .. } => *id = Some(new_id.into()),
}
self
}
pub fn is_user(&self) -> bool {
matches!(self, Message::User { .. })
}
pub fn is_assistant(&self) -> bool {
matches!(self, Message::Assistant { .. })
}
pub fn role(&self) -> &'static str {
match self {
Message::User { .. } => "user",
Message::Assistant { .. } => "assistant",
}
}
pub fn text(&self) -> String {
match self {
Message::User { content, .. } => content
.iter()
.filter_map(|c| match c {
UserContent::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
Message::Assistant { content, .. } => content
.iter()
.filter_map(|c| match c {
AssistantContent::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn content_length(&self) -> usize {
self.text().len()
}
pub fn prepend_text(&mut self, prefix: &str) {
match self {
Message::User { content, .. } => {
if let Some(UserContent::Text { text }) = content.first_mut() {
*text = format!("{}{}", prefix, text);
} else {
content.insert(
0,
UserContent::Text {
text: prefix.to_string(),
},
);
}
}
Message::Assistant { content, .. } => {
if let Some(AssistantContent::Text { text }) = content.first_mut() {
*text = format!("{}{}", prefix, text);
} else {
content.insert(
0,
AssistantContent::Text {
text: prefix.to_string(),
},
);
}
}
}
}
pub fn append_text(&mut self, suffix: &str) {
match self {
Message::User { content, .. } => {
if let Some(UserContent::Text { text }) = content.last_mut() {
text.push_str(suffix);
} else {
content.push(UserContent::Text {
text: suffix.to_string(),
});
}
}
Message::Assistant { content, .. } => {
if let Some(AssistantContent::Text { text }) = content.last_mut() {
text.push_str(suffix);
} else {
content.push(AssistantContent::Text {
text: suffix.to_string(),
});
}
}
}
}
pub fn with_reasoning(&self, reasoning_content: &str) -> Self {
match self {
Message::Assistant { content, id, .. } => Message::Assistant {
content: content.clone(),
id: id.clone(),
reasoning: if reasoning_content.is_empty() {
None
} else {
Some(reasoning_content.to_string())
},
},
_ => self.clone(),
}
}
pub fn reasoning(&self) -> Option<&str> {
match self {
Message::Assistant { reasoning, .. } => reasoning.as_deref(),
_ => None,
}
}
pub fn set_reasoning(&mut self, reasoning_content: Option<String>) {
if let Message::Assistant { reasoning, .. } = self {
*reasoning = reasoning_content;
}
}
pub fn tool_calls(&self) -> Vec<&ToolCall> {
match self {
Message::Assistant { content, .. } => content
.iter()
.filter_map(|c| match c {
AssistantContent::ToolCall(tc) => Some(tc),
_ => None,
})
.collect(),
_ => vec![],
}
}
pub fn has_tool_calls(&self) -> bool {
!self.tool_calls().is_empty()
}
pub fn remap_tool_call_ids<F>(&mut self, mut id_generator: F)
where
F: FnMut(&str) -> String,
{
if let Message::Assistant { content, .. } = self {
for item in content.iter_mut() {
if let AssistantContent::ToolCall(tc) = item {
tc.id = id_generator(&tc.id);
}
}
}
}
pub fn tool_result_ids(&self) -> Vec<&str> {
match self {
Message::User { content, .. } => content
.iter()
.filter_map(|c| match c {
UserContent::ToolResult { id, .. } => Some(id.as_str()),
_ => None,
})
.collect(),
_ => vec![],
}
}
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
match self {
Message::User { content, .. } => {
if content.is_empty() {
errors.push(ValidationError::EmptyContent { role: "user" });
}
for item in content {
if let UserContent::Text { text } = item {
if text.trim().is_empty() {
errors.push(ValidationError::EmptyText { role: "user" });
break;
}
}
}
}
Message::Assistant { content, .. } => {
if content.is_empty() {
errors.push(ValidationError::EmptyContent { role: "assistant" });
}
for item in content {
if let AssistantContent::Text { text } = item {
if text.trim().is_empty() {
errors.push(ValidationError::EmptyText { role: "assistant" });
break;
}
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn validate_tool_calls(
&self,
registered_tools: &HashSet<&str>,
) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
for tc in self.tool_calls() {
if !registered_tools.contains(tc.function.name.as_str()) {
errors.push(ValidationError::UnknownTool {
tool_call_id: tc.id.clone(),
tool_name: tc.function.name.clone(),
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
pub fn validate_tool_result_ids(
message: &Message,
history: &[Message],
) -> Result<(), Vec<ValidationError>> {
let tool_call_ids: HashSet<&str> = history
.iter()
.flat_map(|msg| msg.tool_calls())
.map(|tc| tc.id.as_str())
.collect();
let mut errors = Vec::new();
for result_id in message.tool_result_ids() {
if !tool_call_ids.contains(result_id) {
errors.push(ValidationError::UnmatchedToolResult {
tool_result_id: result_id.to_string(),
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
impl From<&str> for Message {
fn from(s: &str) -> Self {
Message::user(s)
}
}
impl From<String> for Message {
fn from(s: String) -> Self {
Message::user(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UserContent {
Text {
text: String,
},
ToolResult {
id: String,
content: String,
},
Image {
data: String,
media_type: String,
},
Document {
data: String,
media_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AssistantContent {
Text {
text: String,
},
ToolCall(ToolCall),
Reasoning {
reasoning: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
pub id: String,
pub function: ToolCallFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolCallFunction {
pub name: String,
pub arguments: String,
}
impl ToolCall {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: impl Into<String>,
) -> Self {
Self {
id: id.into(),
function: ToolCallFunction {
name: name.into(),
arguments: arguments.into(),
},
}
}
pub fn parse_arguments<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
serde_json::from_str(&self.function.arguments)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_message() {
let msg = Message::user("Hello, world!");
assert!(msg.is_user());
assert!(!msg.is_assistant());
assert_eq!(msg.text(), "Hello, world!");
}
#[test]
fn test_assistant_message() {
let msg = Message::assistant("Hello back!");
assert!(msg.is_assistant());
assert!(!msg.is_user());
assert_eq!(msg.text(), "Hello back!");
}
#[test]
fn test_message_with_id() {
let msg = Message::user_with_id("Test", "msg-123");
assert_eq!(msg.id(), Some("msg-123"));
}
#[test]
fn test_prepend_text() {
let mut msg = Message::user("world");
msg.prepend_text("Hello, ");
assert_eq!(msg.text(), "Hello, world");
}
#[test]
fn test_append_text() {
let mut msg = Message::user("Hello");
msg.append_text(", world!");
assert_eq!(msg.text(), "Hello, world!");
}
#[test]
fn test_message_serialization() {
let msg = Message::user("Test message");
let json = serde_json::to_string(&msg).unwrap();
let parsed: Message = serde_json::from_str(&json).unwrap();
assert_eq!(msg, parsed);
}
#[test]
fn test_tool_call() {
let tc = ToolCall::new("call-1", "get_weather", r#"{"city": "NYC"}"#);
assert_eq!(tc.id, "call-1");
assert_eq!(tc.function.name, "get_weather");
#[derive(Deserialize)]
struct Args {
city: String,
}
let args: Args = tc.parse_arguments().unwrap();
assert_eq!(args.city, "NYC");
}
#[test]
fn test_validate_valid_user_message() {
let msg = Message::user("Hello, world!");
assert!(msg.validate().is_ok());
}
#[test]
fn test_validate_valid_assistant_message() {
let msg = Message::assistant("Hello back!");
assert!(msg.validate().is_ok());
}
#[test]
fn test_validate_empty_user_content() {
let msg = Message::User {
content: vec![],
id: None,
};
let result = msg.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
assert!(matches!(
&errors[0],
ValidationError::EmptyContent { role: "user" }
));
}
#[test]
fn test_validate_empty_assistant_content() {
let msg = Message::Assistant {
content: vec![],
id: None,
reasoning: None,
};
let result = msg.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(matches!(
&errors[0],
ValidationError::EmptyContent { role: "assistant" }
));
}
#[test]
fn test_validate_whitespace_only_text() {
let msg = Message::User {
content: vec![UserContent::Text {
text: " \t\n ".to_string(),
}],
id: None,
};
let result = msg.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(matches!(
&errors[0],
ValidationError::EmptyText { role: "user" }
));
}
#[test]
fn test_validate_tool_calls_known_tool() {
let msg = Message::Assistant {
content: vec![AssistantContent::ToolCall(ToolCall::new(
"call-1",
"get_weather",
"{}",
))],
id: None,
reasoning: None,
};
let tools: HashSet<&str> = ["get_weather", "search"].into_iter().collect();
assert!(msg.validate_tool_calls(&tools).is_ok());
}
#[test]
fn test_validate_tool_calls_unknown_tool() {
let msg = Message::Assistant {
content: vec![AssistantContent::ToolCall(ToolCall::new(
"call-1",
"unknown_tool",
"{}",
))],
id: None,
reasoning: None,
};
let tools: HashSet<&str> = ["get_weather", "search"].into_iter().collect();
let result = msg.validate_tool_calls(&tools);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(matches!(
&errors[0],
ValidationError::UnknownTool { tool_name, .. } if tool_name == "unknown_tool"
));
}
#[test]
fn test_validate_tool_result_ids_matching() {
let history = vec![Message::Assistant {
content: vec![AssistantContent::ToolCall(ToolCall::new(
"call-123",
"get_weather",
"{}",
))],
id: None,
reasoning: None,
}];
let msg = Message::tool_result("call-123", "Sunny");
assert!(validate_tool_result_ids(&msg, &history).is_ok());
}
#[test]
fn test_validate_tool_result_ids_unmatched() {
let history = vec![];
let msg = Message::tool_result("call-unknown", "Result");
let result = validate_tool_result_ids(&msg, &history);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(matches!(
&errors[0],
ValidationError::UnmatchedToolResult { tool_result_id } if tool_result_id == "call-unknown"
));
}
#[test]
fn test_tool_result_ids_extraction() {
let msg = Message::tool_result("call-123", "Result");
let ids = msg.tool_result_ids();
assert_eq!(ids, vec!["call-123"]);
}
#[test]
fn test_validation_error_display() {
let err = ValidationError::EmptyContent { role: "user" };
assert_eq!(err.to_string(), "user message has empty content");
let err = ValidationError::EmptyText { role: "assistant" };
assert_eq!(
err.to_string(),
"assistant message contains empty or whitespace-only text"
);
let err = ValidationError::UnknownTool {
tool_call_id: "call-1".to_string(),
tool_name: "foo".to_string(),
};
assert!(err.to_string().contains("call-1"));
assert!(err.to_string().contains("foo"));
let err = ValidationError::UnmatchedToolResult {
tool_result_id: "result-1".to_string(),
};
assert!(err.to_string().contains("result-1"));
}
#[test]
fn test_remap_tool_call_ids() {
let mut msg = Message::Assistant {
content: vec![
AssistantContent::Text {
text: "Let me help".to_string(),
},
AssistantContent::ToolCall(ToolCall::new(
"call_abc123xyz",
"get_weather",
r#"{"city": "NYC"}"#,
)),
AssistantContent::ToolCall(ToolCall::new(
"call_def456uvw",
"get_time",
r#"{"timezone": "EST"}"#,
)),
],
id: None,
reasoning: None,
};
let mut counter = 0;
msg.remap_tool_call_ids(|_old_id| {
counter += 1;
format!("tool_call_{}", counter)
});
let tool_calls = msg.tool_calls();
assert_eq!(tool_calls.len(), 2);
assert_eq!(tool_calls[0].id, "tool_call_1");
assert_eq!(tool_calls[1].id, "tool_call_2");
assert_eq!(msg.text(), "Let me help");
}
#[test]
fn test_remap_tool_call_ids_user_message_noop() {
let mut msg = Message::user("Hello");
msg.remap_tool_call_ids(|_| "should_not_be_called".to_string());
assert!(msg.is_user());
assert_eq!(msg.text(), "Hello");
}
#[test]
fn test_remap_tool_call_ids_no_tool_calls() {
let mut msg = Message::assistant("Just text");
msg.remap_tool_call_ids(|_| "should_not_be_called".to_string());
assert_eq!(msg.text(), "Just text");
assert!(msg.tool_calls().is_empty());
}
}