use crate::agent::AgentError;
use crate::attachment::Attachment;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
pub(crate) struct TempAttachmentDir {
path: PathBuf,
keep: bool,
}
impl TempAttachmentDir {
pub fn new(base_dir: &Path, keep: bool) -> std::io::Result<Self> {
let session_id = generate_session_id();
let dir_name = format!("llm-toolkit-attachments-{}", session_id);
let path = base_dir.join(dir_name);
std::fs::create_dir_all(&path)?;
debug!(
target: "llm_toolkit::agent::cli_attachment",
"Created temp attachment directory: {}", path.display()
);
Ok(Self { path, keep })
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempAttachmentDir {
fn drop(&mut self) {
if !self.keep {
if let Err(e) = std::fs::remove_dir_all(&self.path) {
warn!(
target: "llm_toolkit::agent::cli_attachment",
"Failed to clean up temp attachment dir {}: {}",
self.path.display(),
e
);
} else {
debug!(
target: "llm_toolkit::agent::cli_attachment",
"Cleaned up temp attachment directory: {}", self.path.display()
);
}
} else {
debug!(
target: "llm_toolkit::agent::cli_attachment",
"Keeping temp attachment directory: {}", self.path.display()
);
}
}
}
fn generate_session_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
format!("{:x}_{:x}_{:x}", timestamp, pid, counter)
}
fn generate_temp_filename(attachment: &Attachment, index: usize) -> String {
let base_name = attachment
.file_name()
.as_ref()
.and_then(|name| {
Path::new(name.as_str())
.file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.unwrap_or_else(|| format!("attachment_{}", index));
let extension = attachment
.file_name()
.as_ref()
.and_then(|name| {
Path::new(name.as_str())
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
})
.unwrap_or_else(|| {
match attachment.mime_type().as_deref() {
Some("image/png") => ".png",
Some("image/jpeg") | Some("image/jpg") => ".jpg",
Some("application/pdf") => ".pdf",
Some("application/json") => ".json",
Some("text/plain") => ".txt",
_ => "",
}
.to_string()
});
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let unique_id = format!("{:x}", timestamp % 0xFFFFFFFF);
let short_id = &unique_id[..unique_id.len().min(8)];
format!("{}_{}{}", base_name, short_id, extension)
}
pub async fn process_attachments(
attachments: &[&Attachment],
temp_dir: &Path,
) -> Result<Vec<PathBuf>, AgentError> {
let mut paths = Vec::new();
for (index, attachment) in attachments.iter().enumerate() {
match **attachment {
Attachment::InMemory { ref bytes, .. } => {
let filename = generate_temp_filename(attachment, index);
let path = temp_dir.join(&filename);
debug!(
target: "llm_toolkit::agent::cli_attachment",
"Writing InMemory attachment to: {}", path.display()
);
tokio::fs::write(&path, bytes).await.map_err(|e| {
AgentError::Other(format!("Failed to write attachment {}: {}", filename, e))
})?;
paths.push(path);
}
Attachment::Local(ref source) => {
let filename = generate_temp_filename(attachment, index);
let dest = temp_dir.join(&filename);
debug!(
target: "llm_toolkit::agent::cli_attachment",
"Copying Local attachment from {} to {}", source.display(), dest.display()
);
tokio::fs::copy(source, &dest).await.map_err(|e| {
AgentError::Other(format!(
"Failed to copy attachment from {}: {}",
source.display(),
e
))
})?;
paths.push(dest);
}
Attachment::Remote(ref url) => {
warn!(
target: "llm_toolkit::agent::cli_attachment",
"Remote attachments are not yet supported, skipping: {}", url
);
}
}
}
Ok(paths)
}
pub fn format_prompt_with_attachments(prompt: &str, paths: &[PathBuf]) -> String {
if paths.is_empty() {
return prompt.to_string();
}
let mut result = prompt.to_string();
result.push_str("\n\nAttachments:\n");
for path in paths {
let mime = path
.extension()
.and_then(|ext| match ext.to_string_lossy().as_ref() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"pdf" => Some("application/pdf"),
"json" => Some("application/json"),
"txt" => Some("text/plain"),
"md" => Some("text/markdown"),
"csv" => Some("text/csv"),
"xml" => Some("application/xml"),
"html" | "htm" => Some("text/html"),
_ => None,
})
.unwrap_or("application/octet-stream");
result.push_str(&format!("- {} ({})\n", path.display(), mime));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_session_id() {
let id1 = generate_session_id();
let id2 = generate_session_id();
assert!(!id1.is_empty());
assert!(!id2.is_empty());
assert!(id1.contains('_'));
assert!(id2.contains('_'));
let parts: Vec<&str> = id1.split('_').collect();
assert_eq!(parts.len(), 3);
assert!(u128::from_str_radix(parts[0], 16).is_ok());
assert!(u32::from_str_radix(parts[1], 16).is_ok());
assert!(u64::from_str_radix(parts[2], 16).is_ok());
}
#[test]
fn test_generate_temp_filename_with_name() {
let attachment = Attachment::local("test_image.png");
let filename = generate_temp_filename(&attachment, 0);
assert!(filename.starts_with("test_image_"));
assert!(filename.ends_with(".png"));
}
#[test]
fn test_generate_temp_filename_without_name() {
let attachment =
Attachment::in_memory_with_meta(vec![1, 2, 3], None, Some("image/png".to_string()));
let filename = generate_temp_filename(&attachment, 5);
assert!(filename.starts_with("attachment_5_"));
assert!(filename.ends_with(".png"));
}
#[test]
fn test_generate_temp_filename_mime_type_fallback() {
let attachment = Attachment::in_memory_with_meta(
vec![1, 2, 3],
None,
Some("application/pdf".to_string()),
);
let filename = generate_temp_filename(&attachment, 0);
assert!(filename.ends_with(".pdf"));
}
#[test]
fn test_format_prompt_with_attachments_empty() {
let prompt = "Test prompt";
let paths = vec![];
let result = format_prompt_with_attachments(prompt, &paths);
assert_eq!(result, "Test prompt");
}
#[test]
fn test_format_prompt_with_attachments_single() {
let prompt = "Test prompt";
let paths = vec![PathBuf::from("/tmp/test.png")];
let result = format_prompt_with_attachments(prompt, &paths);
assert!(result.contains("Test prompt"));
assert!(result.contains("Attachments:"));
assert!(result.contains("/tmp/test.png"));
assert!(result.contains("image/png"));
}
#[test]
fn test_format_prompt_with_attachments_multiple() {
let prompt = "Test prompt";
let paths = vec![
PathBuf::from("/tmp/test.png"),
PathBuf::from("/tmp/doc.pdf"),
];
let result = format_prompt_with_attachments(prompt, &paths);
assert!(result.contains("Test prompt"));
assert!(result.contains("Attachments:"));
assert!(result.contains("/tmp/test.png"));
assert!(result.contains("image/png"));
assert!(result.contains("/tmp/doc.pdf"));
assert!(result.contains("application/pdf"));
}
#[test]
fn test_format_prompt_with_attachments_unknown_mime() {
let prompt = "Test prompt";
let paths = vec![PathBuf::from("/tmp/test.xyz")];
let result = format_prompt_with_attachments(prompt, &paths);
assert!(result.contains("application/octet-stream"));
}
#[tokio::test]
async fn test_temp_attachment_dir_creation() {
let temp = std::env::temp_dir();
let dir = TempAttachmentDir::new(&temp, false).unwrap();
assert!(dir.path().exists());
assert!(dir.path().is_dir());
assert!(
dir.path()
.to_string_lossy()
.contains("llm-toolkit-attachments")
);
let path = dir.path().to_path_buf();
drop(dir);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
assert!(!path.exists());
}
#[tokio::test]
async fn test_temp_attachment_dir_keep() {
let temp = std::env::temp_dir();
let path = {
let dir = TempAttachmentDir::new(&temp, true).unwrap();
let path = dir.path().to_path_buf();
assert!(path.exists());
path
};
assert!(path.exists());
std::fs::remove_dir_all(&path).ok();
}
#[tokio::test]
async fn test_process_attachments_in_memory() {
let temp = std::env::temp_dir();
let dir = TempAttachmentDir::new(&temp, false).unwrap();
let attachments = [
Attachment::in_memory(b"test data 1".to_vec()),
Attachment::in_memory(b"test data 2".to_vec()),
];
let attachment_refs: Vec<&Attachment> = attachments.iter().collect();
let paths = process_attachments(&attachment_refs, dir.path())
.await
.unwrap();
assert_eq!(paths.len(), 2);
assert!(paths[0].exists());
assert!(paths[1].exists());
let content1 = tokio::fs::read(&paths[0]).await.unwrap();
assert_eq!(content1, b"test data 1");
let content2 = tokio::fs::read(&paths[1]).await.unwrap();
assert_eq!(content2, b"test data 2");
}
}