use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatMessageDto {
pub role: String,
pub content: MessageContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallDto>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Multipart(Vec<ContentPart>),
}
impl MessageContent {
pub fn as_text(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Multipart(parts) => parts
.iter()
.filter(|p| p.is_text_kind())
.map(|p| p.text.clone())
.collect::<Vec<_>>()
.join(" "),
}
}
pub fn is_empty(&self) -> bool {
self.as_text().is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContentPart {
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub text: String,
}
impl ContentPart {
pub fn is_text_kind(&self) -> bool {
self.kind == "text"
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCallDto {
#[serde(default)]
pub id: String,
#[serde(default, rename = "type")]
pub kind: String,
pub function: ToolCallFunction,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCallFunction {
pub name: String,
#[serde(default)]
pub arguments: String,
}
pub type EnrichmentMessages = Vec<ChatMessageDto>;
pub fn enrichment_messages_from_json(values: &[Value]) -> EnrichmentMessages {
values
.iter()
.map(|v| {
let role = v
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.to_string();
let content = parse_content(v.get("content"));
let tool_calls = v
.get("tool_calls")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(parse_tool_call).collect());
ChatMessageDto {
role,
content,
tool_calls,
}
})
.collect()
}
pub fn enrichment_messages_to_json(messages: &EnrichmentMessages) -> Vec<Value> {
messages
.iter()
.map(|m| {
let content = match &m.content {
MessageContent::Text(s) => Value::String(s.clone()),
MessageContent::Multipart(parts) => Value::Array(
parts
.iter()
.map(|p| serde_json::json!({"type": p.kind, "text": p.text}))
.collect(),
),
};
let mut obj = serde_json::Map::new();
obj.insert("role".to_string(), Value::String(m.role.clone()));
obj.insert("content".to_string(), content);
if let Some(tool_calls) = &m.tool_calls {
let arr: Vec<Value> = tool_calls
.iter()
.map(|tc| serde_json::to_value(tc).unwrap_or(Value::Null))
.collect();
obj.insert("tool_calls".to_string(), Value::Array(arr));
}
Value::Object(obj)
})
.collect()
}
fn parse_content(value: Option<&Value>) -> MessageContent {
let Some(value) = value else {
return MessageContent::Text(String::new());
};
match value {
Value::String(s) => MessageContent::Text(s.clone()),
Value::Array(parts) => MessageContent::Multipart(
parts
.iter()
.filter_map(|p| {
let kind = p
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if kind != "text" {
return None;
}
let text = p
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
Some(ContentPart { kind, text })
})
.collect(),
),
_ => MessageContent::Text(String::new()),
}
}
fn parse_tool_call(v: &Value) -> Option<ToolCallDto> {
let function = v.get("function")?;
let id = v
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let kind = v
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.to_string();
let name = function
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let arguments = match function.get("arguments") {
Some(Value::String(raw)) => raw.clone(),
Some(other) => serde_json::to_string(other).unwrap_or_else(|_| "null".into()),
None => String::new(),
};
Some(ToolCallDto {
id,
kind,
function: ToolCallFunction { name, arguments },
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn from_json_values_parses_string_content() {
let raw = vec![json!({"role": "user", "content": "hello"})];
let msgs = enrichment_messages_from_json(&raw);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[0].content.as_text(), "hello");
}
#[test]
fn from_json_values_parses_multipart_content() {
let raw = vec![json!({
"role": "user",
"content": [
{"type": "text", "text": "alpha"},
{"type": "image_url", "image_url": "..."},
{"type": "text", "text": "beta"},
]
})];
let msgs = enrichment_messages_from_json(&raw);
assert_eq!(msgs[0].content.as_text(), "alpha beta");
}
#[test]
fn from_json_values_defaults_missing_role_to_user() {
let raw = vec![json!({"content": "hi"})];
let msgs = enrichment_messages_from_json(&raw);
assert_eq!(msgs[0].role, "user");
}
#[test]
fn from_json_values_treats_missing_content_as_empty() {
let raw = vec![json!({"role": "system"})];
let msgs = enrichment_messages_from_json(&raw);
assert!(msgs[0].content.is_empty());
}
#[test]
fn from_json_values_parses_tool_calls() {
let raw = vec![json!({
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": "{\"path\":\"a\"}"}
}]
})];
let msgs = enrichment_messages_from_json(&raw);
let tc = msgs[0].tool_calls.as_ref().expect("tool_calls parsed");
assert_eq!(tc.len(), 1);
assert_eq!(tc[0].id, "call_1");
assert_eq!(tc[0].function.name, "read_file");
assert_eq!(tc[0].function.arguments, r#"{"path":"a"}"#);
}
#[test]
fn to_json_values_round_trips_string_content() {
let msgs: EnrichmentMessages = vec![ChatMessageDto {
role: "user".into(),
content: MessageContent::Text("hi".into()),
tool_calls: None,
}];
let out = enrichment_messages_to_json(&msgs);
assert_eq!(out[0]["role"], "user");
assert_eq!(out[0]["content"], "hi");
assert!(out[0].get("tool_calls").is_none());
}
#[test]
fn to_json_values_emits_tool_calls_when_present() {
let msgs: EnrichmentMessages = vec![ChatMessageDto {
role: "assistant".into(),
content: MessageContent::Text(String::new()),
tool_calls: Some(vec![ToolCallDto {
id: "call_1".into(),
kind: "function".into(),
function: ToolCallFunction {
name: "search".into(),
arguments: "{\"q\":\"rust\"}".into(),
},
}]),
}];
let out = enrichment_messages_to_json(&msgs);
assert_eq!(out[0]["tool_calls"][0]["function"]["name"], "search");
}
#[test]
fn roundtrip_preserves_user_message_verbatim() {
let raw = vec![json!({"role": "user", "content": "regression-sentinel-7c4a8d1e"})];
let msgs = enrichment_messages_from_json(&raw);
let back = enrichment_messages_to_json(&msgs);
assert_eq!(back[0]["content"], "regression-sentinel-7c4a8d1e");
assert_eq!(back[0]["role"], "user");
}
#[test]
fn message_content_as_text_handles_empty_multipart() {
let content = MessageContent::Multipart(Vec::new());
assert!(content.as_text().is_empty());
assert!(content.is_empty());
}
#[test]
fn message_content_as_text_skips_non_text_parts() {
let content = MessageContent::Multipart(vec![
ContentPart {
kind: "image_url".into(),
text: String::new(),
},
ContentPart {
kind: "text".into(),
text: "kept".into(),
},
]);
assert_eq!(content.as_text(), "kept");
}
}