use std::fmt::Write as FmtWrite;
use std::io::Write;
use std::path::PathBuf;
use base64::Engine;
use tracing::{debug, warn};
use crate::types::{ChatMessage, ImagePart, MessageRole, RunnerError};
pub struct PreparedPrompt {
pub prompt: String,
pub image_dir: Option<tempfile::TempDir>,
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "bin",
}
}
fn write_image_file(
dir: &std::path::Path,
image: &ImagePart,
index: usize,
) -> Result<PathBuf, RunnerError> {
let ext = mime_to_extension(&image.mime_type);
let path = dir.join(format!("{index}.{ext}"));
let decoded = base64::engine::general_purpose::STANDARD
.decode(&image.data)
.map_err(|e| RunnerError::internal(format!("failed to decode base64 image data: {e}")))?;
let mut file = std::fs::File::create(&path)
.map_err(|e| RunnerError::internal(format!("failed to create temp image file: {e}")))?;
file.write_all(&decoded)
.map_err(|e| RunnerError::internal(format!("failed to write temp image file: {e}")))?;
debug!(path = %path.display(), size = decoded.len(), mime = %image.mime_type, "Materialized image to temp file");
Ok(path)
}
fn materialize_images(
messages: &[ChatMessage],
) -> Result<(Vec<ChatMessage>, Option<tempfile::TempDir>), RunnerError> {
let has_any_images = messages
.iter()
.any(|m| m.images.as_ref().is_some_and(|imgs| !imgs.is_empty()));
if !has_any_images {
return Ok((messages.to_vec(), None));
}
let temp_dir = tempfile::Builder::new()
.prefix("embacle-images-")
.tempdir()
.map_err(|e| RunnerError::internal(format!("failed to create temp dir for images: {e}")))?;
let mut rewritten = Vec::with_capacity(messages.len());
let mut file_index: usize = 0;
for msg in messages {
let images = msg.images.as_ref().filter(|imgs| !imgs.is_empty());
if msg.role != MessageRole::User || images.is_none() {
rewritten.push(msg.clone());
continue;
}
let images = images.expect("checked above");
let mut file_refs = Vec::with_capacity(images.len());
for image in images {
let path = write_image_file(temp_dir.path(), image, file_index)?;
file_refs.push(path);
file_index += 1;
}
let mut content = msg.content.clone();
content.push_str("\n\n[Attached images — read these files to view them]");
for path in &file_refs {
let _ = write!(content, "\n- {}", path.display());
}
let mut rewritten_msg = ChatMessage::user(content);
rewritten_msg.images.clone_from(&msg.images);
rewritten.push(rewritten_msg);
debug!(
image_count = file_refs.len(),
dir = %temp_dir.path().display(),
"Materialized images for user message"
);
}
Ok((rewritten, Some(temp_dir)))
}
#[must_use]
pub fn build_prompt(messages: &[ChatMessage]) -> String {
let mut parts: Vec<String> = Vec::with_capacity(messages.len());
for msg in messages {
let label = match msg.role {
MessageRole::System => "[system]",
MessageRole::User => "[user]",
MessageRole::Assistant => "[assistant]",
MessageRole::Tool => "[tool]",
};
parts.push(format!("{label}\n{}", msg.content));
}
let prompt = parts.join("\n\n");
debug!(
message_count = messages.len(),
prompt_len = prompt.len(),
has_system = messages.iter().any(|m| m.role == MessageRole::System),
"Built prompt from messages"
);
prompt
}
pub fn prepare_prompt(messages: &[ChatMessage]) -> Result<PreparedPrompt, RunnerError> {
let (rewritten, image_dir) = materialize_images(messages)?;
let prompt = build_prompt(&rewritten);
if image_dir.is_some() {
debug!("Built prompt with materialized images");
}
Ok(PreparedPrompt { prompt, image_dir })
}
pub fn prepare_user_prompt(messages: &[ChatMessage]) -> Result<PreparedPrompt, RunnerError> {
let (rewritten, image_dir) = materialize_images(messages)?;
let prompt = build_user_prompt(&rewritten);
if image_dir.is_some() {
debug!("Built user prompt with materialized images");
}
Ok(PreparedPrompt { prompt, image_dir })
}
#[must_use]
pub fn extract_system_message(messages: &[ChatMessage]) -> Option<&str> {
let result = messages
.iter()
.find(|m| m.role == MessageRole::System)
.map(|m| m.content.as_str());
debug!(
found = result.is_some(),
len = result.map_or(0, str::len),
"Extracting system message"
);
result
}
#[must_use]
pub fn build_user_prompt(messages: &[ChatMessage]) -> String {
let non_system: Vec<&ChatMessage> = messages
.iter()
.filter(|m| m.role != MessageRole::System)
.collect();
let mut parts: Vec<String> = Vec::with_capacity(non_system.len());
for msg in &non_system {
let label = match msg.role {
MessageRole::User => "[user]",
MessageRole::Assistant => "[assistant]",
MessageRole::Tool => "[tool]",
MessageRole::System => unreachable!(),
};
parts.push(format!("{label}\n{}", msg.content));
}
let prompt = parts.join("\n\n");
debug!(
total_messages = messages.len(),
user_messages = non_system.len(),
prompt_len = prompt.len(),
"Built user prompt (system messages excluded)"
);
prompt
}
pub fn warn_images_via_tempfile(runner_name: &str, image_count: usize) {
warn!(
runner = runner_name,
image_count, "Images materialized to temp files for CLI runner (no native vision support)"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_prompt_single_user_message() {
let messages = vec![ChatMessage::user("Hello")];
assert_eq!(build_prompt(&messages), "[user]\nHello");
}
#[test]
fn test_build_prompt_multi_role_conversation() {
let messages = vec![
ChatMessage::system("Be concise"),
ChatMessage::user("What is Rust?"),
ChatMessage::assistant("A systems language."),
];
let result = build_prompt(&messages);
assert_eq!(
result,
"[system]\nBe concise\n\n[user]\nWhat is Rust?\n\n[assistant]\nA systems language."
);
}
#[test]
fn test_build_prompt_empty_messages() {
let messages: Vec<ChatMessage> = Vec::new();
assert_eq!(build_prompt(&messages), "");
}
#[test]
fn test_extract_system_message_present() {
let messages = vec![
ChatMessage::system("You are helpful"),
ChatMessage::user("Hi"),
];
assert_eq!(extract_system_message(&messages), Some("You are helpful"));
}
#[test]
fn test_extract_system_message_absent() {
let messages = vec![ChatMessage::user("Hi")];
assert_eq!(extract_system_message(&messages), None);
}
#[test]
fn test_extract_system_message_returns_first() {
let messages = vec![ChatMessage::system("First"), ChatMessage::system("Second")];
assert_eq!(extract_system_message(&messages), Some("First"));
}
#[test]
fn test_build_user_prompt_excludes_system() {
let messages = vec![
ChatMessage::system("System instructions"),
ChatMessage::user("User question"),
ChatMessage::assistant("Response"),
];
let result = build_user_prompt(&messages);
assert_eq!(result, "[user]\nUser question\n\n[assistant]\nResponse");
assert!(!result.contains("[system]"));
}
#[test]
fn test_build_user_prompt_only_system_messages() {
let messages = vec![ChatMessage::system("Only system")];
assert_eq!(build_user_prompt(&messages), "");
}
#[test]
fn test_prepare_prompt_no_images() {
let messages = vec![ChatMessage::user("Hello")];
let prepared = prepare_prompt(&messages).unwrap();
assert_eq!(prepared.prompt, "[user]\nHello");
assert!(prepared.image_dir.is_none());
}
#[test]
fn test_prepare_prompt_with_images() {
let png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let image = ImagePart::new(png_b64, "image/png").unwrap();
let messages = vec![ChatMessage::user_with_images("Describe this", vec![image])];
let prepared = prepare_prompt(&messages).unwrap();
assert!(prepared.prompt.contains("Describe this"));
assert!(prepared.prompt.contains("[Attached images"));
assert!(prepared.prompt.contains(".png"));
assert!(prepared.image_dir.is_some());
let dir = prepared.image_dir.as_ref().unwrap();
let image_file = dir.path().join("0.png");
assert!(image_file.exists());
let data = std::fs::read(&image_file).unwrap();
assert_eq!(&data[..4], b"\x89PNG");
}
#[test]
fn test_prepare_user_prompt_with_images_excludes_system() {
let png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let image = ImagePart::new(png_b64, "image/png").unwrap();
let messages = vec![
ChatMessage::system("System prompt"),
ChatMessage::user_with_images("Look at this", vec![image]),
];
let prepared = prepare_user_prompt(&messages).unwrap();
assert!(!prepared.prompt.contains("[system]"));
assert!(prepared.prompt.contains("Look at this"));
assert!(prepared.prompt.contains("[Attached images"));
}
#[test]
fn test_prepare_prompt_multiple_images() {
let png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let img1 = ImagePart::new(png_b64, "image/png").unwrap();
let img2 = ImagePart::new(png_b64, "image/jpeg").unwrap();
let messages = vec![ChatMessage::user_with_images(
"Two images",
vec![img1, img2],
)];
let prepared = prepare_prompt(&messages).unwrap();
assert!(prepared.prompt.contains("0.png"));
assert!(prepared.prompt.contains("1.jpg"));
}
#[test]
fn test_prepare_prompt_assistant_images_ignored() {
let mut msg = ChatMessage::assistant("Response");
msg.images = Some(vec![ImagePart::new(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
"image/png",
)
.unwrap()]);
let messages = vec![msg];
let prepared = prepare_prompt(&messages).unwrap();
assert!(!prepared.prompt.contains("[Attached images"));
}
#[test]
fn test_mime_to_extension() {
assert_eq!(mime_to_extension("image/png"), "png");
assert_eq!(mime_to_extension("image/jpeg"), "jpg");
assert_eq!(mime_to_extension("image/webp"), "webp");
assert_eq!(mime_to_extension("image/gif"), "gif");
assert_eq!(mime_to_extension("image/bmp"), "bin");
}
}