1use std::path::PathBuf;
7use url::Url;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Attachment {
17 Local(PathBuf),
19
20 Remote(Url),
25
26 InMemory {
28 bytes: Vec<u8>,
30 file_name: Option<String>,
32 mime_type: Option<String>,
34 },
35}
36
37impl Attachment {
38 pub fn local(path: impl Into<PathBuf>) -> Self {
49 Self::Local(path.into())
50 }
51
52 pub fn remote(url: &str) -> Self {
67 Self::Remote(Url::parse(url).expect("Invalid URL"))
68 }
69
70 pub fn in_memory(bytes: Vec<u8>) -> Self {
81 Self::InMemory {
82 bytes,
83 file_name: None,
84 mime_type: None,
85 }
86 }
87
88 pub fn in_memory_with_meta(
103 bytes: Vec<u8>,
104 file_name: Option<String>,
105 mime_type: Option<String>,
106 ) -> Self {
107 Self::InMemory {
108 bytes,
109 file_name,
110 mime_type,
111 }
112 }
113
114 pub fn file_name(&self) -> Option<String> {
130 match self {
131 Self::Local(path) => path
132 .file_name()
133 .and_then(|n| n.to_str())
134 .map(|s| s.to_string()),
135 Self::Remote(url) => url
136 .path_segments()
137 .and_then(|mut segments| segments.next_back())
138 .filter(|s| !s.is_empty())
139 .map(|s| s.to_string()),
140 Self::InMemory { file_name, .. } => file_name.clone(),
141 }
142 }
143
144 pub fn mime_type(&self) -> Option<String> {
160 match self {
161 Self::InMemory { mime_type, .. } => mime_type.clone(),
162 Self::Local(path) => Self::infer_mime_type_from_path(path),
163 Self::Remote(_) => None,
164 }
165 }
166
167 fn infer_mime_type_from_path(path: &std::path::Path) -> Option<String> {
169 mime_guess::from_path(path)
170 .first()
171 .map(|mime| mime.to_string())
172 }
173
174 #[cfg(feature = "agent")]
202 pub async fn load_bytes(&self) -> Result<Vec<u8>, std::io::Error> {
203 match self {
204 Self::Local(path) => tokio::fs::read(path).await,
205 Self::InMemory { bytes, .. } => Ok(bytes.clone()),
206 Self::Remote(_url) => Err(std::io::Error::new(
207 std::io::ErrorKind::Unsupported,
208 "Remote attachment loading not yet implemented",
209 )),
210 }
211 }
212}
213
214pub trait ToAttachments {
243 fn to_attachments(&self) -> Vec<(String, Attachment)>;
249}
250
251pub trait AttachmentSchema {
301 fn attachment_keys() -> &'static [&'static str];
303}
304
305impl ToAttachments for Vec<u8> {
308 fn to_attachments(&self) -> Vec<(String, Attachment)> {
309 vec![("data".to_string(), Attachment::in_memory(self.clone()))]
310 }
311}
312
313impl ToAttachments for PathBuf {
314 fn to_attachments(&self) -> Vec<(String, Attachment)> {
315 vec![("file".to_string(), Attachment::local(self.clone()))]
316 }
317}
318
319impl ToAttachments for Attachment {
320 fn to_attachments(&self) -> Vec<(String, Attachment)> {
321 vec![("attachment".to_string(), self.clone())]
322 }
323}
324
325impl<T: ToAttachments> ToAttachments for Option<T> {
326 fn to_attachments(&self) -> Vec<(String, Attachment)> {
327 match self {
328 Some(inner) => inner.to_attachments(),
329 None => Vec::new(),
330 }
331 }
332}
333
334impl<T: ToAttachments> ToAttachments for Vec<T> {
335 fn to_attachments(&self) -> Vec<(String, Attachment)> {
336 self.iter()
337 .enumerate()
338 .flat_map(|(i, item)| {
339 item.to_attachments()
340 .into_iter()
341 .map(move |(key, attachment)| (format!("{}_{}", key, i), attachment))
342 })
343 .collect()
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
354 fn test_local_attachment_creation() {
355 let path = PathBuf::from("/path/to/file.png");
356 let attachment = Attachment::local(path.clone());
357
358 match attachment {
359 Attachment::Local(p) => assert_eq!(p, path),
360 _ => panic!("Expected Local variant"),
361 }
362 }
363
364 #[test]
365 fn test_remote_attachment_creation() {
366 let url = "https://example.com/image.png";
367 let attachment = Attachment::remote(url);
368
369 match attachment {
370 Attachment::Remote(u) => assert_eq!(u.as_str(), url),
371 _ => panic!("Expected Remote variant"),
372 }
373 }
374
375 #[test]
376 fn test_in_memory_attachment_creation() {
377 let data = vec![1, 2, 3, 4];
378 let attachment = Attachment::in_memory(data.clone());
379
380 match attachment {
381 Attachment::InMemory {
382 bytes,
383 file_name,
384 mime_type,
385 } => {
386 assert_eq!(bytes, data);
387 assert_eq!(file_name, None);
388 assert_eq!(mime_type, None);
389 }
390 _ => panic!("Expected InMemory variant"),
391 }
392 }
393
394 #[test]
395 fn test_in_memory_attachment_with_metadata() {
396 let data = vec![1, 2, 3, 4];
397 let name = Some("test.png".to_string());
398 let mime = Some("image/png".to_string());
399
400 let attachment = Attachment::in_memory_with_meta(data.clone(), name.clone(), mime.clone());
401
402 match attachment {
403 Attachment::InMemory {
404 bytes,
405 file_name,
406 mime_type,
407 } => {
408 assert_eq!(bytes, data);
409 assert_eq!(file_name, name);
410 assert_eq!(mime_type, mime);
411 }
412 _ => panic!("Expected InMemory variant"),
413 }
414 }
415
416 #[test]
417 fn test_file_name_extraction_local() {
418 let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
419 assert_eq!(attachment.file_name(), Some("file.png".to_string()));
420 }
421
422 #[test]
423 fn test_file_name_extraction_local_no_extension() {
424 let attachment = Attachment::local(PathBuf::from("/path/to/file"));
425 assert_eq!(attachment.file_name(), Some("file".to_string()));
426 }
427
428 #[test]
429 fn test_file_name_extraction_remote() {
430 let attachment = Attachment::remote("https://example.com/path/to/image.jpg");
431 assert_eq!(attachment.file_name(), Some("image.jpg".to_string()));
432 }
433
434 #[test]
435 fn test_file_name_extraction_remote_trailing_slash() {
436 let attachment = Attachment::remote("https://example.com/path/to/");
438 assert_eq!(attachment.file_name(), None);
439 }
440
441 #[test]
442 fn test_file_name_extraction_in_memory() {
443 let attachment =
444 Attachment::in_memory_with_meta(vec![1, 2, 3], Some("chart.png".to_string()), None);
445 assert_eq!(attachment.file_name(), Some("chart.png".to_string()));
446 }
447
448 #[test]
449 fn test_file_name_extraction_in_memory_none() {
450 let attachment = Attachment::in_memory(vec![1, 2, 3]);
451 assert_eq!(attachment.file_name(), None);
452 }
453
454 #[test]
455 fn test_mime_type_inference_png() {
456 let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
457 assert_eq!(attachment.mime_type(), Some("image/png".to_string()));
458 }
459
460 #[test]
461 fn test_mime_type_inference_jpg() {
462 let attachment = Attachment::local(PathBuf::from("/path/to/file.jpg"));
463 assert_eq!(attachment.mime_type(), Some("image/jpeg".to_string()));
464 }
465
466 #[test]
467 fn test_mime_type_inference_jpeg() {
468 let attachment = Attachment::local(PathBuf::from("/path/to/file.jpeg"));
469 assert_eq!(attachment.mime_type(), Some("image/jpeg".to_string()));
470 }
471
472 #[test]
473 fn test_mime_type_inference_pdf() {
474 let attachment = Attachment::local(PathBuf::from("/path/to/document.pdf"));
475 assert_eq!(attachment.mime_type(), Some("application/pdf".to_string()));
476 }
477
478 #[test]
479 fn test_mime_type_inference_json() {
480 let attachment = Attachment::local(PathBuf::from("/path/to/data.json"));
481 assert_eq!(attachment.mime_type(), Some("application/json".to_string()));
482 }
483
484 #[test]
485 fn test_mime_type_inference_unknown_extension() {
486 let attachment = Attachment::local(PathBuf::from("/path/to/file.unknown"));
487 assert_eq!(attachment.mime_type(), None);
488 }
489
490 #[test]
491 fn test_mime_type_inference_no_extension() {
492 let attachment = Attachment::local(PathBuf::from("/path/to/file"));
493 assert_eq!(attachment.mime_type(), None);
494 }
495
496 #[test]
497 fn test_mime_type_in_memory_with_type() {
498 let attachment = Attachment::in_memory_with_meta(
499 vec![1, 2, 3],
500 None,
501 Some("application/octet-stream".to_string()),
502 );
503 assert_eq!(
504 attachment.mime_type(),
505 Some("application/octet-stream".to_string())
506 );
507 }
508
509 #[test]
510 fn test_mime_type_in_memory_without_type() {
511 let attachment = Attachment::in_memory(vec![1, 2, 3]);
512 assert_eq!(attachment.mime_type(), None);
513 }
514
515 #[test]
516 fn test_mime_type_remote() {
517 let attachment = Attachment::remote("https://example.com/file.png");
518 assert_eq!(attachment.mime_type(), None);
519 }
520
521 #[cfg(feature = "agent")]
522 #[tokio::test]
523 async fn test_load_bytes_in_memory() {
524 let data = vec![1, 2, 3, 4, 5];
525 let attachment = Attachment::in_memory(data.clone());
526
527 let loaded = attachment.load_bytes().await.unwrap();
528 assert_eq!(loaded, data);
529 }
530
531 #[cfg(feature = "agent")]
532 #[tokio::test]
533 async fn test_load_bytes_remote_unsupported() {
534 let attachment = Attachment::remote("https://example.com/file.png");
535
536 let result = attachment.load_bytes().await;
537 assert!(result.is_err());
538 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
539 }
540
541 #[test]
542 fn test_attachment_clone() {
543 let attachment = Attachment::in_memory_with_meta(
544 vec![1, 2, 3],
545 Some("test.bin".to_string()),
546 Some("application/octet-stream".to_string()),
547 );
548
549 let cloned = attachment.clone();
550 assert_eq!(attachment, cloned);
551 }
552
553 #[test]
554 fn test_attachment_debug() {
555 let attachment = Attachment::local(PathBuf::from("/test/path.txt"));
556 let debug_str = format!("{:?}", attachment);
557 assert!(debug_str.contains("Local"));
558 assert!(debug_str.contains("path.txt"));
559 }
560
561 #[test]
564 fn test_to_attachments_vec_u8() {
565 let data = vec![1, 2, 3, 4, 5];
566 let attachments = data.to_attachments();
567
568 assert_eq!(attachments.len(), 1);
569 assert_eq!(attachments[0].0, "data");
570 match &attachments[0].1 {
571 Attachment::InMemory { bytes, .. } => assert_eq!(bytes, &data),
572 _ => panic!("Expected InMemory attachment"),
573 }
574 }
575
576 #[test]
577 fn test_to_attachments_pathbuf() {
578 let path = PathBuf::from("/test/file.txt");
579 let attachments = path.to_attachments();
580
581 assert_eq!(attachments.len(), 1);
582 assert_eq!(attachments[0].0, "file");
583 match &attachments[0].1 {
584 Attachment::Local(p) => assert_eq!(p, &path),
585 _ => panic!("Expected Local attachment"),
586 }
587 }
588
589 #[test]
590 fn test_to_attachments_attachment() {
591 let attachment = Attachment::remote("https://example.com/file.pdf");
592 let attachments = attachment.to_attachments();
593
594 assert_eq!(attachments.len(), 1);
595 assert_eq!(attachments[0].0, "attachment");
596 }
597
598 #[test]
599 fn test_to_attachments_option_some() {
600 let data = Some(vec![1, 2, 3]);
601 let attachments = data.to_attachments();
602
603 assert_eq!(attachments.len(), 1);
604 assert_eq!(attachments[0].0, "data");
605 }
606
607 #[test]
608 fn test_to_attachments_option_none() {
609 let data: Option<Vec<u8>> = None;
610 let attachments = data.to_attachments();
611
612 assert_eq!(attachments.len(), 0);
613 }
614
615 #[test]
616 fn test_to_attachments_vec() {
617 let items = vec![vec![1, 2, 3], vec![4, 5, 6]];
618 let attachments = items.to_attachments();
619
620 assert_eq!(attachments.len(), 2);
621 assert_eq!(attachments[0].0, "data_0");
622 assert_eq!(attachments[1].0, "data_1");
623 }
624
625 #[test]
626 fn test_to_attachments_custom_implementation() {
627 struct MyOutput {
628 chart: Vec<u8>,
629 thumbnail: Vec<u8>,
630 }
631
632 impl ToAttachments for MyOutput {
633 fn to_attachments(&self) -> Vec<(String, Attachment)> {
634 vec![
635 (
636 "chart".to_string(),
637 Attachment::in_memory(self.chart.clone()),
638 ),
639 (
640 "thumbnail".to_string(),
641 Attachment::in_memory(self.thumbnail.clone()),
642 ),
643 ]
644 }
645 }
646
647 let output = MyOutput {
648 chart: vec![1, 2, 3],
649 thumbnail: vec![4, 5, 6],
650 };
651
652 let attachments = output.to_attachments();
653 assert_eq!(attachments.len(), 2);
654 assert_eq!(attachments[0].0, "chart");
655 assert_eq!(attachments[1].0, "thumbnail");
656 }
657
658 #[test]
661 fn test_attachment_schema_keys() {
662 struct TestOutput;
663
664 impl AttachmentSchema for TestOutput {
665 fn attachment_keys() -> &'static [&'static str] {
666 &["image", "data"]
667 }
668 }
669
670 let keys = TestOutput::attachment_keys();
671 assert_eq!(keys.len(), 2);
672 assert_eq!(keys[0], "image");
673 assert_eq!(keys[1], "data");
674 }
675
676 #[test]
677 fn test_attachment_schema_empty_keys() {
678 struct EmptyOutput;
679
680 impl AttachmentSchema for EmptyOutput {
681 fn attachment_keys() -> &'static [&'static str] {
682 &[]
683 }
684 }
685
686 let keys = EmptyOutput::attachment_keys();
687 assert_eq!(keys.len(), 0);
688 }
689}