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}