1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::path::PathBuf;
11
12use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct UserMessage {
18 pub text: String,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub attachments: Vec<Attachment>,
21}
22
23impl UserMessage {
24 pub fn text(text: impl Into<String>) -> Self {
26 Self {
27 text: text.into(),
28 attachments: Vec::new(),
29 }
30 }
31}
32
33#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum Attachment {
39 Image { path: PathBuf },
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AttachmentErrorKind {
49 NotFound,
50 UnsupportedExtension,
51 TooLarge,
52 UnreadableImage,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum AttachmentError {
58 NotFound { path: PathBuf },
59 UnsupportedExtension { path: PathBuf, ext: String },
60 TooLarge { path: PathBuf, size: u64 },
61 UnreadableImage { path: PathBuf },
62}
63
64impl AttachmentError {
65 pub fn kind(&self) -> AttachmentErrorKind {
66 match self {
67 Self::NotFound { .. } => AttachmentErrorKind::NotFound,
68 Self::UnsupportedExtension { .. } => AttachmentErrorKind::UnsupportedExtension,
69 Self::TooLarge { .. } => AttachmentErrorKind::TooLarge,
70 Self::UnreadableImage { .. } => AttachmentErrorKind::UnreadableImage,
71 }
72 }
73}
74
75impl std::fmt::Display for AttachmentError {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::NotFound { path } => {
79 write!(f, "image not found: {}", path.display())
80 }
81 Self::UnsupportedExtension { path, ext } => write!(
82 f,
83 "image has unsupported extension '.{}': {} (supported: png, jpg, jpeg, gif, webp)",
84 ext,
85 path.display(),
86 ),
87 Self::TooLarge { path, size } => write!(
88 f,
89 "image is {} bytes; capo caps images at 5 MiB (5242880 bytes): {}",
90 size,
91 path.display(),
92 ),
93 Self::UnreadableImage { path } => {
94 write!(
95 f,
96 "image could not be read or recognised: {}",
97 path.display()
98 )
99 }
100 }
101 }
102}
103
104impl std::error::Error for AttachmentError {}
105
106pub(crate) const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
108
109const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
110
111pub(crate) fn sniff_mime(bytes: &[u8], extension: &str) -> Option<&'static str> {
117 if bytes.len() >= 12 {
118 if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
120 return Some("image/png");
121 }
122 if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
124 return Some("image/jpeg");
125 }
126 if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
128 return Some("image/gif");
129 }
130 if bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
132 return Some("image/webp");
133 }
134 }
135 match extension.to_ascii_lowercase().as_str() {
137 "png" => Some("image/png"),
138 "jpg" | "jpeg" => Some("image/jpeg"),
139 "gif" => Some("image/gif"),
140 "webp" => Some("image/webp"),
141 _ => None,
142 }
143}
144
145pub(crate) fn prepare_user_message(
152 msg: &UserMessage,
153) -> Result<motosan_agent_loop::Message, AttachmentError> {
154 use motosan_agent_loop::ContentPart;
155
156 let mut parts: Vec<ContentPart> = Vec::with_capacity(msg.attachments.len() + 1);
157 if !msg.text.is_empty() {
158 parts.push(ContentPart::text(&msg.text));
159 }
160
161 for att in &msg.attachments {
162 match att {
163 Attachment::Image { path } => {
164 let metadata = match std::fs::metadata(path) {
166 Ok(m) if m.is_file() => m,
167 Ok(_) | Err(_) => {
168 return Err(AttachmentError::NotFound { path: path.clone() });
169 }
170 };
171
172 let ext = path
174 .extension()
175 .and_then(|s| s.to_str())
176 .map(|s| s.to_ascii_lowercase())
177 .unwrap_or_default();
178 if !SUPPORTED_EXTENSIONS.contains(&ext.as_str()) {
179 return Err(AttachmentError::UnsupportedExtension {
180 path: path.clone(),
181 ext,
182 });
183 }
184
185 let size = metadata.len();
187 if size > MAX_IMAGE_BYTES {
188 return Err(AttachmentError::TooLarge {
189 path: path.clone(),
190 size,
191 });
192 }
193
194 let bytes = std::fs::read(path)
196 .map_err(|_| AttachmentError::UnreadableImage { path: path.clone() })?;
197 let mime = sniff_mime(&bytes, &ext)
198 .ok_or_else(|| AttachmentError::UnreadableImage { path: path.clone() })?;
199 let data = B64.encode(&bytes);
200
201 parts.push(ContentPart::image_base64(mime.to_string(), data));
202 }
203 }
204 }
205
206 Ok(motosan_agent_loop::Message::user_with_parts(parts))
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 use motosan_agent_loop::{ContentPart, Message, Role};
214 use std::io::Write;
215
216 fn tempfile_with(extension: &str, bytes: &[u8]) -> PathBuf {
219 let mut path = std::env::temp_dir();
220 let name = format!(
221 "capo-v06-test-{}-{}.{}",
222 std::process::id(),
223 uuid_like_suffix(),
224 extension,
225 );
226 path.push(name);
227 let mut f = std::fs::File::create(&path).expect("create tempfile");
228 f.write_all(bytes).expect("write tempfile");
229 path
230 }
231
232 fn uuid_like_suffix() -> String {
233 use std::sync::atomic::{AtomicU64, Ordering};
234 static COUNTER: AtomicU64 = AtomicU64::new(0);
235 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
236 format!("{n:016x}")
237 }
238
239 fn png_header_bytes() -> Vec<u8> {
240 let mut v = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
243 v.extend_from_slice(&[0u8; 64]); v
245 }
246
247 fn parts(msg: &Message) -> &[ContentPart] {
249 match msg {
250 Message::User { content, .. } => content.as_slice(),
251 other => panic!("expected User message, got {other:?}"),
252 }
253 }
254
255 #[test]
256 fn user_message_text_only_serializes_without_attachments_key() {
257 let msg = UserMessage::text("hi");
258 let json = serde_json::to_string(&msg).expect("serialize");
259 assert_eq!(json, r#"{"text":"hi"}"#);
260 }
261
262 #[test]
263 fn user_message_text_only_deserializes_when_attachments_absent() {
264 let msg: UserMessage = serde_json::from_str(r#"{"text":"hi"}"#).expect("deserialize");
265 assert_eq!(msg, UserMessage::text("hi"));
266 }
267
268 #[test]
269 fn user_message_with_image_attachment_round_trips() {
270 let msg = UserMessage {
271 text: "look".into(),
272 attachments: vec![Attachment::Image {
273 path: PathBuf::from("/tmp/foo.png"),
274 }],
275 };
276 let json = serde_json::to_string(&msg).expect("serialize");
277 assert_eq!(
278 json,
279 r#"{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}"#
280 );
281 let back: UserMessage = serde_json::from_str(&json).expect("deserialize");
282 assert_eq!(back, msg);
283 }
284
285 #[test]
286 fn attachment_error_kind_serializes_to_stable_wire_strings() {
287 let cases = [
288 (AttachmentErrorKind::NotFound, "\"not_found\""),
289 (
290 AttachmentErrorKind::UnsupportedExtension,
291 "\"unsupported_extension\"",
292 ),
293 (AttachmentErrorKind::TooLarge, "\"too_large\""),
294 (AttachmentErrorKind::UnreadableImage, "\"unreadable_image\""),
295 ];
296 for (kind, expected) in cases {
297 let got = serde_json::to_string(&kind).expect("serialize");
298 assert_eq!(got, expected, "kind {kind:?} wire form");
299 }
300 }
301
302 #[test]
303 fn attachment_unknown_type_is_rejected() {
304 let err = serde_json::from_str::<Attachment>(r#"{"type":"img","path":"/tmp/x.png"}"#);
307 assert!(err.is_err(), "unknown type tag must fail to deserialize");
308 }
309
310 #[test]
311 fn prepare_text_only_produces_single_text_part() {
312 let msg = UserMessage::text("hello");
313 let out = prepare_user_message(&msg).expect("ok");
314 assert_eq!(out.role(), Role::User);
315 let p = parts(&out);
316 assert_eq!(p.len(), 1);
317 assert!(matches!(&p[0], ContentPart::Text { text, .. } if text == "hello"));
318 }
319
320 #[test]
321 fn prepare_text_plus_one_image_produces_text_then_image() {
322 let path = tempfile_with("png", &png_header_bytes());
323 let msg = UserMessage {
324 text: "look".into(),
325 attachments: vec![Attachment::Image { path }],
326 };
327 let out = prepare_user_message(&msg).expect("ok");
328 let p = parts(&out);
329 assert_eq!(p.len(), 2);
330 assert!(matches!(&p[0], ContentPart::Text { text, .. } if text == "look"));
331 assert!(matches!(&p[1], ContentPart::Image { .. }));
332 }
333
334 #[test]
335 fn prepare_text_plus_two_images_preserves_declared_order() {
336 let p1 = tempfile_with("png", &png_header_bytes());
337 let p2 = tempfile_with("png", &png_header_bytes());
338 let msg = UserMessage {
339 text: "compare".into(),
340 attachments: vec![
341 Attachment::Image { path: p1 },
342 Attachment::Image { path: p2 },
343 ],
344 };
345 let out = prepare_user_message(&msg).expect("ok");
346 let p = parts(&out);
347 assert_eq!(p.len(), 3);
348 assert!(matches!(&p[0], ContentPart::Text { .. }));
349 assert!(matches!(&p[1], ContentPart::Image { .. }));
350 assert!(matches!(&p[2], ContentPart::Image { .. }));
351 }
352
353 #[test]
354 fn prepare_empty_text_plus_one_image_omits_text_part() {
355 let path = tempfile_with("png", &png_header_bytes());
356 let msg = UserMessage {
357 text: "".into(),
358 attachments: vec![Attachment::Image { path }],
359 };
360 let out = prepare_user_message(&msg).expect("ok");
361 let p = parts(&out);
362 assert_eq!(p.len(), 1, "no leading empty text part");
363 assert!(matches!(&p[0], ContentPart::Image { .. }));
364 }
365
366 #[test]
367 fn prepare_user_message_rejects_missing_path() {
368 let msg = UserMessage {
369 text: "look".into(),
370 attachments: vec![Attachment::Image {
371 path: PathBuf::from("/tmp/definitely-does-not-exist-capo-v06.png"),
372 }],
373 };
374 let err = prepare_user_message(&msg).expect_err("should fail");
375 assert!(matches!(err, AttachmentError::NotFound { .. }));
376 assert_eq!(err.kind(), AttachmentErrorKind::NotFound);
377 }
378
379 #[test]
380 fn prepare_user_message_rejects_unsupported_extension() {
381 let path = tempfile_with("txt", b"hello world");
382 let msg = UserMessage {
383 text: "".into(),
384 attachments: vec![Attachment::Image { path }],
385 };
386 let err = prepare_user_message(&msg).expect_err("should fail");
387 assert!(
388 matches!(err, AttachmentError::UnsupportedExtension { ref ext, .. } if ext == "txt")
389 );
390 }
391
392 #[test]
393 fn prepare_user_message_rejects_oversize_file() {
394 let big = vec![0u8; 5 * 1024 * 1024 + 1]; let path = tempfile_with("png", &big);
396 let msg = UserMessage {
397 text: "".into(),
398 attachments: vec![Attachment::Image { path }],
399 };
400 let err = prepare_user_message(&msg).expect_err("should fail");
401 assert!(
402 matches!(err, AttachmentError::TooLarge { size, .. } if size == 5 * 1024 * 1024 + 1)
403 );
404 }
405
406 #[test]
407 fn sniff_mime_recognises_png_magic_bytes() {
408 let png_header: [u8; 12] = [
409 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x00,
410 ];
411 let got = sniff_mime(&png_header, "png");
412 assert_eq!(got, Some("image/png"));
413 }
414
415 #[test]
416 fn sniff_mime_recognises_jpeg_magic_bytes() {
417 let mut bytes = [0u8; 12];
418 bytes[..3].copy_from_slice(&[0xFF, 0xD8, 0xFF]);
419 let got = sniff_mime(&bytes, "jpg");
420 assert_eq!(got, Some("image/jpeg"));
421 }
422
423 #[test]
424 fn sniff_mime_recognises_gif_magic_bytes() {
425 let bytes = b"GIF87a\0\0\0\0\0\0";
427 let got = sniff_mime(bytes, "gif");
428 assert_eq!(got, Some("image/gif"));
429 }
430
431 #[test]
432 fn sniff_mime_recognises_webp_magic_bytes() {
433 let bytes = b"RIFF\0\0\0\0WEBP";
435 let got = sniff_mime(bytes, "webp");
436 assert_eq!(got, Some("image/webp"));
437 }
438
439 #[test]
440 fn sniff_mime_falls_back_to_extension_when_magic_inconclusive() {
441 let bytes = [0u8; 12];
443 assert_eq!(sniff_mime(&bytes, "png"), Some("image/png"));
444 assert_eq!(sniff_mime(&bytes, "JPG"), Some("image/jpeg"));
445 assert_eq!(sniff_mime(&bytes, "Jpeg"), Some("image/jpeg"));
446 assert_eq!(sniff_mime(&bytes, "gif"), Some("image/gif"));
447 assert_eq!(sniff_mime(&bytes, "webp"), Some("image/webp"));
448 }
449
450 #[test]
451 fn sniff_mime_handles_files_shorter_than_12_bytes_via_extension_only() {
452 let bytes = [0u8; 4];
453 assert_eq!(sniff_mime(&bytes, "png"), Some("image/png"));
455 }
456
457 #[test]
458 fn sniff_mime_returns_none_when_both_magic_and_extension_unknown() {
459 let bytes = [0u8; 12];
460 assert_eq!(sniff_mime(&bytes, "txt"), None);
461 assert_eq!(sniff_mime(&[], "bmp"), None);
462 }
463}