use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Parts(Vec<ContentPart>),
}
impl MessageContent {
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(text) => Some(text),
Self::Parts(_) => None,
}
}
pub fn to_text(&self) -> String {
match self {
Self::Text(text) => text.clone(),
Self::Parts(parts) => parts
.iter()
.filter_map(ContentPart::as_text)
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn parts(&self) -> impl Iterator<Item = &ContentPart> {
let iter: Box<dyn Iterator<Item = &ContentPart>> = match self {
Self::Text(_) => Box::new(std::iter::empty()),
Self::Parts(parts) => Box::new(parts.iter()),
};
iter
}
pub fn has_media(&self) -> bool {
self.parts()
.any(|part| !matches!(part, ContentPart::Text { .. }))
}
}
impl From<String> for MessageContent {
fn from(text: String) -> Self {
Self::Text(text)
}
}
impl From<&String> for MessageContent {
fn from(text: &String) -> Self {
Self::Text(text.clone())
}
}
impl From<&str> for MessageContent {
fn from(text: &str) -> Self {
Self::Text(text.to_string())
}
}
impl From<Vec<ContentPart>> for MessageContent {
fn from(parts: Vec<ContentPart>) -> Self {
Self::Parts(parts)
}
}
impl From<ContentPart> for MessageContent {
fn from(part: ContentPart) -> Self {
Self::Parts(vec![part])
}
}
impl std::fmt::Debug for MessageContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Text(text) => f.debug_tuple("Text").field(text).finish(),
Self::Parts(parts) => f.debug_tuple("Parts").field(parts).finish(),
}
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ContentPart {
Text { text: String },
Blob {
mime_type: String,
#[serde(with = "base64_bytes")]
data: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
Uri {
mime_type: String,
uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
pub fn blob(mime_type: impl Into<String>, data: Vec<u8>) -> Self {
Self::Blob {
mime_type: mime_type.into(),
data,
name: None,
}
}
pub fn uri(mime_type: impl Into<String>, uri: impl Into<String>) -> Self {
Self::Uri {
mime_type: mime_type.into(),
uri: uri.into(),
name: None,
}
}
pub fn named(mut self, file_name: impl Into<String>) -> Self {
match &mut self {
Self::Text { .. } => {}
Self::Blob { name, .. } | Self::Uri { name, .. } => *name = Some(file_name.into()),
}
self
}
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text),
_ => None,
}
}
pub fn mime_type(&self) -> Option<&str> {
match self {
Self::Text { .. } => None,
Self::Blob { mime_type, .. } | Self::Uri { mime_type, .. } => Some(mime_type),
}
}
pub fn name(&self) -> Option<&str> {
match self {
Self::Text { .. } => None,
Self::Blob { name, .. } | Self::Uri { name, .. } => name.as_deref(),
}
}
pub(crate) fn describe(&self) -> String {
match (self.name(), self.mime_type()) {
(Some(name), Some(mime)) => format!("{name} ({mime})"),
(Some(name), None) => name.to_string(),
(None, Some(mime)) => format!("unnamed {mime} file"),
(None, None) => "unnamed file".to_string(),
}
}
}
impl std::fmt::Debug for ContentPart {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Text { text } => f.debug_struct("Text").field("text", text).finish(),
Self::Blob {
mime_type,
data,
name,
} => f
.debug_struct("Blob")
.field("mime_type", mime_type)
.field("name", name)
.field("data", &format_args!("{} bytes", data.len()))
.finish(),
Self::Uri {
mime_type,
uri,
name,
} => f
.debug_struct("Uri")
.field("mime_type", mime_type)
.field("uri", uri)
.field("name", name)
.finish(),
}
}
}
mod base64_bytes {
use base64::Engine as _;
use serde::{Deserialize, Deserializer, Serializer, de::Error as _};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let encoded = String::deserialize(deserializer)?;
base64::engine::general_purpose::STANDARD
.decode(encoded.as_bytes())
.map_err(D::Error::custom)
}
}
pub(crate) fn encode_base64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub(crate) fn data_url(mime_type: &str, bytes: &[u8]) -> String {
format!("data:{mime_type};base64,{}", encode_base64(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_content_is_a_bare_string_on_the_wire() {
let content = MessageContent::from("hei");
assert_eq!(
serde_json::to_value(&content).expect("serializes"),
serde_json::json!("hei")
);
let back: MessageContent =
serde_json::from_value(serde_json::json!("hei")).expect("deserializes");
assert_eq!(back, content);
}
#[test]
fn a_blob_is_base64_on_the_wire() {
let content = MessageContent::from(vec![
ContentPart::text("what is this"),
ContentPart::blob("image/png", vec![0, 1, 2]).named("shot.png"),
]);
let json = serde_json::to_value(&content).expect("serializes");
assert_eq!(
json,
serde_json::json!([
{ "type": "text", "text": "what is this" },
{ "type": "blob", "mime_type": "image/png", "data": "AAEC", "name": "shot.png" },
])
);
let back: MessageContent = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, content);
}
#[test]
fn as_text_speaks_only_for_a_whole_text_message() {
assert_eq!(MessageContent::from("hei").as_text(), Some("hei"));
let parts = MessageContent::from(vec![ContentPart::text("hei")]);
assert_eq!(parts.as_text(), None);
assert_eq!(parts.to_text(), "hei");
}
#[test]
fn media_is_what_a_text_channel_cannot_carry() {
assert!(!MessageContent::from("hei").has_media());
assert!(!MessageContent::from(vec![ContentPart::text("hei")]).has_media());
assert!(MessageContent::from(ContentPart::blob("image/png", vec![1])).has_media());
assert!(MessageContent::from(ContentPart::uri("image/png", "https://x/y.png")).has_media());
}
#[test]
fn debug_does_not_print_the_bytes() {
let content = MessageContent::from(ContentPart::blob("application/pdf", vec![7; 4096]));
let rendered = format!("{content:?}");
assert!(rendered.contains("4096 bytes"), "{rendered}");
assert!(!rendered.contains(", 7,"), "{rendered}");
}
}