use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
const MAX_ATTACHMENT_SIZE: u64 = 10 * 1024 * 1024;
const MAX_INLINE_SIZE: u64 = 50 * 1024;
#[derive(Debug)]
pub enum AttachmentContent {
Text(String),
Reference,
}
#[derive(Debug)]
pub struct Attachment {
pub path: PathBuf,
pub filename: String,
pub mime_type: String,
pub size: u64,
pub content: AttachmentContent,
}
impl Attachment {
pub fn from_path(path: &Path) -> Result<Self> {
let path = path
.canonicalize()
.with_context(|| format!("File not found: {}", path.display()))?;
let metadata = std::fs::metadata(&path)
.with_context(|| format!("Cannot read file metadata: {}", path.display()))?;
let size = metadata.len();
if size > MAX_ATTACHMENT_SIZE {
bail!(
"File too large: {} ({} bytes, max {} bytes)",
path.display(),
size,
MAX_ATTACHMENT_SIZE
);
}
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let mime_type = mime_from_extension(&ext).to_string();
let is_text = is_text_mime(&mime_type);
let content = if is_text && size <= MAX_INLINE_SIZE {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read text file: {}", path.display()))?;
AttachmentContent::Text(text)
} else {
AttachmentContent::Reference
};
Ok(Self {
path,
filename,
mime_type,
size,
content,
})
}
}
pub fn format_attachments_prefix(attachments: &[Attachment]) -> String {
let mut out = String::from("<attached-files>\n");
for att in attachments {
match &att.content {
AttachmentContent::Text(text) => {
out.push_str(&format!(
"<file name=\"{}\" path=\"{}\" mime=\"{}\" size=\"{}\" encoding=\"utf-8\">\n{}\n</file>\n",
att.filename,
att.path.display(),
att.mime_type,
att.size,
text,
));
}
AttachmentContent::Reference => {
let encoding = if is_text_mime(&att.mime_type) {
"utf-8"
} else {
"binary"
};
out.push_str(&format!(
"<file name=\"{}\" path=\"{}\" mime=\"{}\" size=\"{}\" encoding=\"{}\">\n\
(content not included, use @{} to access this file)\n</file>\n",
att.filename,
att.path.display(),
att.mime_type,
att.size,
encoding,
att.path.display(),
));
}
}
}
out.push_str("</attached-files>\n\n");
out
}
fn mime_from_extension(ext: &str) -> &'static str {
match ext {
"txt" | "text" => "text/plain",
"md" | "markdown" => "text/markdown",
"html" | "htm" => "text/html",
"css" => "text/css",
"csv" => "text/csv",
"xml" => "text/xml",
"svg" => "image/svg+xml",
"rs" => "text/x-rust",
"py" => "text/x-python",
"js" | "mjs" | "cjs" => "text/javascript",
"ts" | "mts" | "cts" => "text/typescript",
"tsx" | "jsx" => "text/typescript",
"java" => "text/x-java",
"kt" | "kts" => "text/x-kotlin",
"swift" => "text/x-swift",
"cs" => "text/x-csharp",
"go" => "text/x-go",
"rb" => "text/x-ruby",
"php" => "text/x-php",
"c" | "h" => "text/x-c",
"cpp" | "cc" | "cxx" | "hpp" | "hh" => "text/x-c++",
"sh" | "bash" | "zsh" | "fish" => "text/x-shellscript",
"sql" => "text/x-sql",
"r" => "text/x-r",
"lua" => "text/x-lua",
"pl" | "pm" => "text/x-perl",
"scala" => "text/x-scala",
"zig" => "text/x-zig",
"hs" => "text/x-haskell",
"ex" | "exs" => "text/x-elixir",
"dart" => "text/x-dart",
"v" | "sv" => "text/x-verilog",
"vhd" | "vhdl" => "text/x-vhdl",
"json" => "application/json",
"jsonl" | "ndjson" => "application/x-ndjson",
"yaml" | "yml" => "application/yaml",
"toml" => "application/toml",
"ini" | "cfg" | "conf" => "text/plain",
"env" => "text/plain",
"lock" => "text/plain",
"log" => "text/plain",
"pdf" => "application/pdf",
"doc" | "docx" => "application/msword",
"xls" | "xlsx" => "application/vnd.ms-excel",
"ppt" | "pptx" => "application/vnd.ms-powerpoint",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"tiff" | "tif" => "image/tiff",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"mp4" => "video/mp4",
"webm" => "video/webm",
"avi" => "video/x-msvideo",
"zip" => "application/zip",
"gz" | "gzip" => "application/gzip",
"tar" => "application/x-tar",
"bz2" => "application/x-bzip2",
"xz" => "application/x-xz",
"7z" => "application/x-7z-compressed",
"wasm" => "application/wasm",
"exe" | "dll" | "so" | "dylib" => "application/octet-stream",
_ => "application/octet-stream",
}
}
fn is_text_mime(mime: &str) -> bool {
mime.starts_with("text/")
|| mime == "application/json"
|| mime == "application/x-ndjson"
|| mime == "application/yaml"
|| mime == "application/toml"
|| mime == "application/xml"
|| mime == "image/svg+xml"
}
#[cfg(test)]
#[path = "attachment_tests.rs"]
mod tests;