1use anyhow::{Context, Result, bail};
10use std::path::{Path, PathBuf};
11
12const MAX_ATTACHMENT_SIZE: u64 = 10 * 1024 * 1024;
14
15const MAX_INLINE_SIZE: u64 = 50 * 1024;
18
19#[derive(Debug)]
21pub enum AttachmentContent {
22 Text(String),
24 Reference,
26}
27
28#[derive(Debug)]
30pub struct Attachment {
31 pub path: PathBuf,
32 pub filename: String,
33 pub mime_type: String,
34 pub size: u64,
35 pub content: AttachmentContent,
36}
37
38impl Attachment {
39 pub fn from_path(path: &Path) -> Result<Self> {
44 let path = path
45 .canonicalize()
46 .with_context(|| format!("File not found: {}", path.display()))?;
47
48 let metadata = std::fs::metadata(&path)
49 .with_context(|| format!("Cannot read file metadata: {}", path.display()))?;
50
51 let size = metadata.len();
52 if size > MAX_ATTACHMENT_SIZE {
53 bail!(
54 "File too large: {} ({} bytes, max {} bytes)",
55 path.display(),
56 size,
57 MAX_ATTACHMENT_SIZE
58 );
59 }
60
61 let filename = path
62 .file_name()
63 .map(|n| n.to_string_lossy().to_string())
64 .unwrap_or_else(|| "unknown".to_string());
65
66 let ext = path
67 .extension()
68 .map(|e| e.to_string_lossy().to_lowercase())
69 .unwrap_or_default();
70
71 let mime_type = mime_from_extension(&ext).to_string();
72 let is_text = is_text_mime(&mime_type);
73
74 let content = if is_text && size <= MAX_INLINE_SIZE {
75 let text = std::fs::read_to_string(&path)
76 .with_context(|| format!("Failed to read text file: {}", path.display()))?;
77 AttachmentContent::Text(text)
78 } else {
79 AttachmentContent::Reference
80 };
81
82 Ok(Self {
83 path,
84 filename,
85 mime_type,
86 size,
87 content,
88 })
89 }
90}
91
92pub fn format_attachments_prefix(attachments: &[Attachment]) -> String {
94 let mut out = String::from("<attached-files>\n");
95 for att in attachments {
96 match &att.content {
97 AttachmentContent::Text(text) => {
98 out.push_str(&format!(
99 "<file name=\"{}\" path=\"{}\" mime=\"{}\" size=\"{}\" encoding=\"utf-8\">\n{}\n</file>\n",
100 att.filename,
101 att.path.display(),
102 att.mime_type,
103 att.size,
104 text,
105 ));
106 }
107 AttachmentContent::Reference => {
108 let encoding = if is_text_mime(&att.mime_type) {
109 "utf-8"
110 } else {
111 "binary"
112 };
113 out.push_str(&format!(
114 "<file name=\"{}\" path=\"{}\" mime=\"{}\" size=\"{}\" encoding=\"{}\">\n\
115 (content not included, use @{} to access this file)\n</file>\n",
116 att.filename,
117 att.path.display(),
118 att.mime_type,
119 att.size,
120 encoding,
121 att.path.display(),
122 ));
123 }
124 }
125 }
126 out.push_str("</attached-files>\n\n");
127 out
128}
129
130fn mime_from_extension(ext: &str) -> &'static str {
132 match ext {
133 "txt" | "text" => "text/plain",
135 "md" | "markdown" => "text/markdown",
136 "html" | "htm" => "text/html",
137 "css" => "text/css",
138 "csv" => "text/csv",
139 "xml" => "text/xml",
140 "svg" => "image/svg+xml",
141 "rs" => "text/x-rust",
143 "py" => "text/x-python",
144 "js" | "mjs" | "cjs" => "text/javascript",
145 "ts" | "mts" | "cts" => "text/typescript",
146 "tsx" | "jsx" => "text/typescript",
147 "java" => "text/x-java",
148 "kt" | "kts" => "text/x-kotlin",
149 "swift" => "text/x-swift",
150 "cs" => "text/x-csharp",
151 "go" => "text/x-go",
152 "rb" => "text/x-ruby",
153 "php" => "text/x-php",
154 "c" | "h" => "text/x-c",
155 "cpp" | "cc" | "cxx" | "hpp" | "hh" => "text/x-c++",
156 "sh" | "bash" | "zsh" | "fish" => "text/x-shellscript",
157 "sql" => "text/x-sql",
158 "r" => "text/x-r",
159 "lua" => "text/x-lua",
160 "pl" | "pm" => "text/x-perl",
161 "scala" => "text/x-scala",
162 "zig" => "text/x-zig",
163 "hs" => "text/x-haskell",
164 "ex" | "exs" => "text/x-elixir",
165 "dart" => "text/x-dart",
166 "v" | "sv" => "text/x-verilog",
167 "vhd" | "vhdl" => "text/x-vhdl",
168 "json" => "application/json",
170 "jsonl" | "ndjson" => "application/x-ndjson",
171 "yaml" | "yml" => "application/yaml",
172 "toml" => "application/toml",
173 "ini" | "cfg" | "conf" => "text/plain",
174 "env" => "text/plain",
175 "lock" => "text/plain",
176 "log" => "text/plain",
177 "pdf" => "application/pdf",
179 "doc" | "docx" => "application/msword",
180 "xls" | "xlsx" => "application/vnd.ms-excel",
181 "ppt" | "pptx" => "application/vnd.ms-powerpoint",
182 "png" => "image/png",
184 "jpg" | "jpeg" => "image/jpeg",
185 "gif" => "image/gif",
186 "webp" => "image/webp",
187 "bmp" => "image/bmp",
188 "ico" => "image/x-icon",
189 "tiff" | "tif" => "image/tiff",
190 "mp3" => "audio/mpeg",
192 "wav" => "audio/wav",
193 "ogg" => "audio/ogg",
194 "mp4" => "video/mp4",
195 "webm" => "video/webm",
196 "avi" => "video/x-msvideo",
197 "zip" => "application/zip",
199 "gz" | "gzip" => "application/gzip",
200 "tar" => "application/x-tar",
201 "bz2" => "application/x-bzip2",
202 "xz" => "application/x-xz",
203 "7z" => "application/x-7z-compressed",
204 "wasm" => "application/wasm",
206 "exe" | "dll" | "so" | "dylib" => "application/octet-stream",
207 _ => "application/octet-stream",
209 }
210}
211
212fn is_text_mime(mime: &str) -> bool {
214 mime.starts_with("text/")
215 || mime == "application/json"
216 || mime == "application/x-ndjson"
217 || mime == "application/yaml"
218 || mime == "application/toml"
219 || mime == "application/xml"
220 || mime == "image/svg+xml"
221}
222
223#[cfg(test)]
224#[path = "attachment_tests.rs"]
225mod tests;