llm_toolkit/
attachment.rs

1//! Attachment types for multimodal workflows.
2//!
3//! This module provides the foundation for handling file-based outputs from agents
4//! that can be consumed by subsequent agents in a workflow.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use url::Url;
9
10/// Represents a resource that can be attached to a payload or produced by an agent.
11///
12/// Attachments provide a flexible way to handle various types of data sources:
13/// - Local files on the filesystem
14/// - Remote resources accessible via URLs
15/// - In-memory data with optional metadata
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub enum Attachment {
18    /// A file on the local filesystem.
19    Local(PathBuf),
20
21    /// A resource accessible via a URL (e.g., http://, https://, s3://).
22    ///
23    /// Note: Remote fetching is not yet implemented. This variant is reserved
24    /// for future functionality.
25    Remote(Url),
26
27    /// In-memory data with optional name and MIME type.
28    InMemory {
29        /// The raw bytes of the attachment.
30        bytes: Vec<u8>,
31        /// Optional file name for identification.
32        file_name: Option<String>,
33        /// Optional MIME type (e.g., "image/png", "application/pdf").
34        mime_type: Option<String>,
35    },
36}
37
38impl Attachment {
39    /// Creates a new local file attachment.
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// use llm_toolkit::attachment::Attachment;
45    /// use std::path::PathBuf;
46    ///
47    /// let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
48    /// ```
49    pub fn local(path: impl Into<PathBuf>) -> Self {
50        Self::Local(path.into())
51    }
52
53    /// Creates a new remote URL attachment.
54    ///
55    /// # Panics
56    ///
57    /// Panics if the URL string is invalid. For fallible construction, use `Url::parse()`
58    /// and construct `Attachment::Remote(url)` directly.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use llm_toolkit::attachment::Attachment;
64    ///
65    /// let attachment = Attachment::remote("https://example.com/image.png");
66    /// ```
67    pub fn remote(url: &str) -> Self {
68        Self::Remote(Url::parse(url).expect("Invalid URL"))
69    }
70
71    /// Creates a new in-memory attachment from raw bytes.
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// use llm_toolkit::attachment::Attachment;
77    ///
78    /// let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header
79    /// let attachment = Attachment::in_memory(data);
80    /// ```
81    pub fn in_memory(bytes: Vec<u8>) -> Self {
82        Self::InMemory {
83            bytes,
84            file_name: None,
85            mime_type: None,
86        }
87    }
88
89    /// Creates a new in-memory attachment with metadata.
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// use llm_toolkit::attachment::Attachment;
95    ///
96    /// let data = vec![0x89, 0x50, 0x4E, 0x47];
97    /// let attachment = Attachment::in_memory_with_meta(
98    ///     data,
99    ///     Some("chart.png".to_string()),
100    ///     Some("image/png".to_string()),
101    /// );
102    /// ```
103    pub fn in_memory_with_meta(
104        bytes: Vec<u8>,
105        file_name: Option<String>,
106        mime_type: Option<String>,
107    ) -> Self {
108        Self::InMemory {
109            bytes,
110            file_name,
111            mime_type,
112        }
113    }
114
115    /// Returns the file name if available.
116    ///
117    /// For local files, extracts the file name from the path.
118    /// For remote URLs, extracts the last path segment.
119    /// For in-memory attachments, returns the stored file name.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use llm_toolkit::attachment::Attachment;
125    /// use std::path::PathBuf;
126    ///
127    /// let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
128    /// assert_eq!(attachment.file_name(), Some("file.png".to_string()));
129    /// ```
130    pub fn file_name(&self) -> Option<String> {
131        match self {
132            Self::Local(path) => path
133                .file_name()
134                .and_then(|n| n.to_str())
135                .map(|s| s.to_string()),
136            Self::Remote(url) => url
137                .path_segments()
138                .and_then(|mut segments| segments.next_back())
139                .filter(|s| !s.is_empty())
140                .map(|s| s.to_string()),
141            Self::InMemory { file_name, .. } => file_name.clone(),
142        }
143    }
144
145    /// Returns the MIME type if available or can be inferred.
146    ///
147    /// For local files, attempts to infer the MIME type from the file extension.
148    /// For in-memory attachments, returns the stored MIME type.
149    /// For remote URLs, returns None.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use llm_toolkit::attachment::Attachment;
155    /// use std::path::PathBuf;
156    ///
157    /// let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
158    /// assert_eq!(attachment.mime_type(), Some("image/png".to_string()));
159    /// ```
160    pub fn mime_type(&self) -> Option<String> {
161        match self {
162            Self::InMemory { mime_type, .. } => mime_type.clone(),
163            Self::Local(path) => Self::infer_mime_type_from_path(path),
164            Self::Remote(_) => None,
165        }
166    }
167
168    /// Infers MIME type from file extension using the `mime_guess` crate.
169    fn infer_mime_type_from_path(path: &std::path::Path) -> Option<String> {
170        mime_guess::from_path(path)
171            .first()
172            .map(|mime| mime.to_string())
173    }
174
175    /// Loads the attachment data as bytes.
176    ///
177    /// For local files, reads the file from the filesystem.
178    /// For in-memory attachments, returns a clone of the stored bytes.
179    /// For remote URLs, returns an error (not yet implemented).
180    ///
181    /// This method is only available when the `agent` feature is enabled,
182    /// as it requires async runtime support.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if:
187    /// - The file cannot be read (for local attachments)
188    /// - Remote fetching is attempted (not yet supported)
189    ///
190    /// # Examples
191    ///
192    /// ```no_run
193    /// use llm_toolkit::attachment::Attachment;
194    ///
195    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
196    /// let attachment = Attachment::in_memory(vec![1, 2, 3]);
197    /// let bytes = attachment.load_bytes().await?;
198    /// assert_eq!(bytes, vec![1, 2, 3]);
199    /// # Ok(())
200    /// # }
201    /// ```
202    #[cfg(feature = "agent")]
203    pub async fn load_bytes(&self) -> Result<Vec<u8>, std::io::Error> {
204        match self {
205            Self::Local(path) => tokio::fs::read(path).await,
206            Self::InMemory { bytes, .. } => Ok(bytes.clone()),
207            Self::Remote(_url) => Err(std::io::Error::new(
208                std::io::ErrorKind::Unsupported,
209                "Remote attachment loading not yet implemented",
210            )),
211        }
212    }
213}
214
215/// Trait for types that can produce named attachments.
216///
217/// This trait is typically derived using `#[derive(ToAttachments)]`.
218/// Types implementing this trait can be used as agent outputs that produce
219/// file-based data that can be consumed by subsequent agents in a workflow.
220///
221/// # Examples
222///
223/// ```
224/// use llm_toolkit::attachment::{Attachment, ToAttachments};
225/// use std::path::PathBuf;
226///
227/// // Manual implementation
228/// struct MyOutput {
229///     data: Vec<u8>,
230/// }
231///
232/// impl ToAttachments for MyOutput {
233///     fn to_attachments(&self) -> Vec<(String, Attachment)> {
234///         vec![("data".to_string(), Attachment::in_memory(self.data.clone()))]
235///     }
236/// }
237///
238/// let output = MyOutput { data: vec![1, 2, 3] };
239/// let attachments = output.to_attachments();
240/// assert_eq!(attachments.len(), 1);
241/// assert_eq!(attachments[0].0, "data");
242/// ```
243pub trait ToAttachments {
244    /// Converts this type into a list of named attachments.
245    ///
246    /// Returns `Vec<(key, Attachment)>` where key identifies the attachment.
247    /// The key is used by the orchestrator to reference this attachment in
248    /// subsequent steps.
249    fn to_attachments(&self) -> Vec<(String, Attachment)>;
250}
251
252/// Trait for types that can declare their attachment schema at compile-time.
253///
254/// This trait is automatically implemented when deriving `ToAttachments`.
255/// It provides metadata about what attachment keys a type will produce,
256/// which is used by the Agent derive macro to augment the agent's expertise.
257///
258/// # Design Philosophy
259///
260/// This trait intentionally does **not** include a `descriptions()` method.
261/// Instead, types implementing `AttachmentSchema` should also implement
262/// `ToPrompt`, which already provides `prompt_schema()` that includes
263/// field descriptions from doc comments.
264///
265/// **Why not duplicate descriptions?**
266/// - `ToPrompt::prompt_schema()` already includes field descriptions
267/// - Adding `attachment_descriptions()` would be redundant
268/// - Users who need schema + descriptions should use `ToPrompt`
269///
270/// # Examples
271///
272/// ```
273/// use llm_toolkit::attachment::AttachmentSchema;
274///
275/// struct MyOutput;
276///
277/// impl AttachmentSchema for MyOutput {
278///     fn attachment_keys() -> &'static [&'static str] {
279///         &["chart", "thumbnail"]
280///     }
281/// }
282///
283/// assert_eq!(MyOutput::attachment_keys(), &["chart", "thumbnail"]);
284/// ```
285///
286/// # Integration with ToPrompt
287///
288/// For full schema with descriptions, implement both traits:
289///
290/// ```ignore
291/// #[derive(ToPrompt, ToAttachments)]
292/// struct ImageGeneratorOutput {
293///     /// Visual chart of the analysis results
294///     #[attachment(key = "analysis_chart")]
295///     pub chart_bytes: Vec<u8>,
296/// }
297///
298/// // AttachmentSchema::attachment_keys() returns: ["analysis_chart"]
299/// // ToPrompt::prompt_schema() returns full schema with descriptions
300/// ```
301pub trait AttachmentSchema {
302    /// Returns a static slice of attachment keys this type produces.
303    fn attachment_keys() -> &'static [&'static str];
304}
305
306// === Blanket implementations for common types ===
307
308impl ToAttachments for Vec<u8> {
309    fn to_attachments(&self) -> Vec<(String, Attachment)> {
310        vec![("data".to_string(), Attachment::in_memory(self.clone()))]
311    }
312}
313
314impl ToAttachments for PathBuf {
315    fn to_attachments(&self) -> Vec<(String, Attachment)> {
316        vec![("file".to_string(), Attachment::local(self.clone()))]
317    }
318}
319
320impl ToAttachments for Attachment {
321    fn to_attachments(&self) -> Vec<(String, Attachment)> {
322        vec![("attachment".to_string(), self.clone())]
323    }
324}
325
326impl<T: ToAttachments> ToAttachments for Option<T> {
327    fn to_attachments(&self) -> Vec<(String, Attachment)> {
328        match self {
329            Some(inner) => inner.to_attachments(),
330            None => Vec::new(),
331        }
332    }
333}
334
335impl<T: ToAttachments> ToAttachments for Vec<T> {
336    fn to_attachments(&self) -> Vec<(String, Attachment)> {
337        self.iter()
338            .enumerate()
339            .flat_map(|(i, item)| {
340                item.to_attachments()
341                    .into_iter()
342                    .map(move |(key, attachment)| (format!("{}_{}", key, i), attachment))
343            })
344            .collect()
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    // === Tests for Attachment ===
353
354    #[test]
355    fn test_local_attachment_creation() {
356        let path = PathBuf::from("/path/to/file.png");
357        let attachment = Attachment::local(path.clone());
358
359        match attachment {
360            Attachment::Local(p) => assert_eq!(p, path),
361            _ => panic!("Expected Local variant"),
362        }
363    }
364
365    #[test]
366    fn test_remote_attachment_creation() {
367        let url = "https://example.com/image.png";
368        let attachment = Attachment::remote(url);
369
370        match attachment {
371            Attachment::Remote(u) => assert_eq!(u.as_str(), url),
372            _ => panic!("Expected Remote variant"),
373        }
374    }
375
376    #[test]
377    fn test_in_memory_attachment_creation() {
378        let data = vec![1, 2, 3, 4];
379        let attachment = Attachment::in_memory(data.clone());
380
381        match attachment {
382            Attachment::InMemory {
383                bytes,
384                file_name,
385                mime_type,
386            } => {
387                assert_eq!(bytes, data);
388                assert_eq!(file_name, None);
389                assert_eq!(mime_type, None);
390            }
391            _ => panic!("Expected InMemory variant"),
392        }
393    }
394
395    #[test]
396    fn test_in_memory_attachment_with_metadata() {
397        let data = vec![1, 2, 3, 4];
398        let name = Some("test.png".to_string());
399        let mime = Some("image/png".to_string());
400
401        let attachment = Attachment::in_memory_with_meta(data.clone(), name.clone(), mime.clone());
402
403        match attachment {
404            Attachment::InMemory {
405                bytes,
406                file_name,
407                mime_type,
408            } => {
409                assert_eq!(bytes, data);
410                assert_eq!(file_name, name);
411                assert_eq!(mime_type, mime);
412            }
413            _ => panic!("Expected InMemory variant"),
414        }
415    }
416
417    #[test]
418    fn test_file_name_extraction_local() {
419        let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
420        assert_eq!(attachment.file_name(), Some("file.png".to_string()));
421    }
422
423    #[test]
424    fn test_file_name_extraction_local_no_extension() {
425        let attachment = Attachment::local(PathBuf::from("/path/to/file"));
426        assert_eq!(attachment.file_name(), Some("file".to_string()));
427    }
428
429    #[test]
430    fn test_file_name_extraction_remote() {
431        let attachment = Attachment::remote("https://example.com/path/to/image.jpg");
432        assert_eq!(attachment.file_name(), Some("image.jpg".to_string()));
433    }
434
435    #[test]
436    fn test_file_name_extraction_remote_trailing_slash() {
437        // Trailing slash indicates a directory, so no file name
438        let attachment = Attachment::remote("https://example.com/path/to/");
439        assert_eq!(attachment.file_name(), None);
440    }
441
442    #[test]
443    fn test_file_name_extraction_in_memory() {
444        let attachment =
445            Attachment::in_memory_with_meta(vec![1, 2, 3], Some("chart.png".to_string()), None);
446        assert_eq!(attachment.file_name(), Some("chart.png".to_string()));
447    }
448
449    #[test]
450    fn test_file_name_extraction_in_memory_none() {
451        let attachment = Attachment::in_memory(vec![1, 2, 3]);
452        assert_eq!(attachment.file_name(), None);
453    }
454
455    #[test]
456    fn test_mime_type_inference_png() {
457        let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
458        assert_eq!(attachment.mime_type(), Some("image/png".to_string()));
459    }
460
461    #[test]
462    fn test_mime_type_inference_jpg() {
463        let attachment = Attachment::local(PathBuf::from("/path/to/file.jpg"));
464        assert_eq!(attachment.mime_type(), Some("image/jpeg".to_string()));
465    }
466
467    #[test]
468    fn test_mime_type_inference_jpeg() {
469        let attachment = Attachment::local(PathBuf::from("/path/to/file.jpeg"));
470        assert_eq!(attachment.mime_type(), Some("image/jpeg".to_string()));
471    }
472
473    #[test]
474    fn test_mime_type_inference_pdf() {
475        let attachment = Attachment::local(PathBuf::from("/path/to/document.pdf"));
476        assert_eq!(attachment.mime_type(), Some("application/pdf".to_string()));
477    }
478
479    #[test]
480    fn test_mime_type_inference_json() {
481        let attachment = Attachment::local(PathBuf::from("/path/to/data.json"));
482        assert_eq!(attachment.mime_type(), Some("application/json".to_string()));
483    }
484
485    #[test]
486    fn test_mime_type_inference_unknown_extension() {
487        let attachment = Attachment::local(PathBuf::from("/path/to/file.unknown"));
488        assert_eq!(attachment.mime_type(), None);
489    }
490
491    #[test]
492    fn test_mime_type_inference_no_extension() {
493        let attachment = Attachment::local(PathBuf::from("/path/to/file"));
494        assert_eq!(attachment.mime_type(), None);
495    }
496
497    #[test]
498    fn test_mime_type_in_memory_with_type() {
499        let attachment = Attachment::in_memory_with_meta(
500            vec![1, 2, 3],
501            None,
502            Some("application/octet-stream".to_string()),
503        );
504        assert_eq!(
505            attachment.mime_type(),
506            Some("application/octet-stream".to_string())
507        );
508    }
509
510    #[test]
511    fn test_mime_type_in_memory_without_type() {
512        let attachment = Attachment::in_memory(vec![1, 2, 3]);
513        assert_eq!(attachment.mime_type(), None);
514    }
515
516    #[test]
517    fn test_mime_type_remote() {
518        let attachment = Attachment::remote("https://example.com/file.png");
519        assert_eq!(attachment.mime_type(), None);
520    }
521
522    #[cfg(feature = "agent")]
523    #[tokio::test]
524    async fn test_load_bytes_in_memory() {
525        let data = vec![1, 2, 3, 4, 5];
526        let attachment = Attachment::in_memory(data.clone());
527
528        let loaded = attachment.load_bytes().await.unwrap();
529        assert_eq!(loaded, data);
530    }
531
532    #[cfg(feature = "agent")]
533    #[tokio::test]
534    async fn test_load_bytes_remote_unsupported() {
535        let attachment = Attachment::remote("https://example.com/file.png");
536
537        let result = attachment.load_bytes().await;
538        assert!(result.is_err());
539        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
540    }
541
542    #[test]
543    fn test_attachment_clone() {
544        let attachment = Attachment::in_memory_with_meta(
545            vec![1, 2, 3],
546            Some("test.bin".to_string()),
547            Some("application/octet-stream".to_string()),
548        );
549
550        let cloned = attachment.clone();
551        assert_eq!(attachment, cloned);
552    }
553
554    #[test]
555    fn test_attachment_debug() {
556        let attachment = Attachment::local(PathBuf::from("/test/path.txt"));
557        let debug_str = format!("{:?}", attachment);
558        assert!(debug_str.contains("Local"));
559        assert!(debug_str.contains("path.txt"));
560    }
561
562    // === Tests for ToAttachments trait ===
563
564    #[test]
565    fn test_to_attachments_vec_u8() {
566        let data = vec![1, 2, 3, 4, 5];
567        let attachments = data.to_attachments();
568
569        assert_eq!(attachments.len(), 1);
570        assert_eq!(attachments[0].0, "data");
571        match &attachments[0].1 {
572            Attachment::InMemory { bytes, .. } => assert_eq!(bytes, &data),
573            _ => panic!("Expected InMemory attachment"),
574        }
575    }
576
577    #[test]
578    fn test_to_attachments_pathbuf() {
579        let path = PathBuf::from("/test/file.txt");
580        let attachments = path.to_attachments();
581
582        assert_eq!(attachments.len(), 1);
583        assert_eq!(attachments[0].0, "file");
584        match &attachments[0].1 {
585            Attachment::Local(p) => assert_eq!(p, &path),
586            _ => panic!("Expected Local attachment"),
587        }
588    }
589
590    #[test]
591    fn test_to_attachments_attachment() {
592        let attachment = Attachment::remote("https://example.com/file.pdf");
593        let attachments = attachment.to_attachments();
594
595        assert_eq!(attachments.len(), 1);
596        assert_eq!(attachments[0].0, "attachment");
597    }
598
599    #[test]
600    fn test_to_attachments_option_some() {
601        let data = Some(vec![1, 2, 3]);
602        let attachments = data.to_attachments();
603
604        assert_eq!(attachments.len(), 1);
605        assert_eq!(attachments[0].0, "data");
606    }
607
608    #[test]
609    fn test_to_attachments_option_none() {
610        let data: Option<Vec<u8>> = None;
611        let attachments = data.to_attachments();
612
613        assert_eq!(attachments.len(), 0);
614    }
615
616    #[test]
617    fn test_to_attachments_vec() {
618        let items = vec![vec![1, 2, 3], vec![4, 5, 6]];
619        let attachments = items.to_attachments();
620
621        assert_eq!(attachments.len(), 2);
622        assert_eq!(attachments[0].0, "data_0");
623        assert_eq!(attachments[1].0, "data_1");
624    }
625
626    #[test]
627    fn test_to_attachments_custom_implementation() {
628        struct MyOutput {
629            chart: Vec<u8>,
630            thumbnail: Vec<u8>,
631        }
632
633        impl ToAttachments for MyOutput {
634            fn to_attachments(&self) -> Vec<(String, Attachment)> {
635                vec![
636                    (
637                        "chart".to_string(),
638                        Attachment::in_memory(self.chart.clone()),
639                    ),
640                    (
641                        "thumbnail".to_string(),
642                        Attachment::in_memory(self.thumbnail.clone()),
643                    ),
644                ]
645            }
646        }
647
648        let output = MyOutput {
649            chart: vec![1, 2, 3],
650            thumbnail: vec![4, 5, 6],
651        };
652
653        let attachments = output.to_attachments();
654        assert_eq!(attachments.len(), 2);
655        assert_eq!(attachments[0].0, "chart");
656        assert_eq!(attachments[1].0, "thumbnail");
657    }
658
659    // === Tests for AttachmentSchema trait ===
660
661    #[test]
662    fn test_attachment_schema_keys() {
663        struct TestOutput;
664
665        impl AttachmentSchema for TestOutput {
666            fn attachment_keys() -> &'static [&'static str] {
667                &["image", "data"]
668            }
669        }
670
671        let keys = TestOutput::attachment_keys();
672        assert_eq!(keys.len(), 2);
673        assert_eq!(keys[0], "image");
674        assert_eq!(keys[1], "data");
675    }
676
677    #[test]
678    fn test_attachment_schema_empty_keys() {
679        struct EmptyOutput;
680
681        impl AttachmentSchema for EmptyOutput {
682            fn attachment_keys() -> &'static [&'static str] {
683                &[]
684            }
685        }
686
687        let keys = EmptyOutput::attachment_keys();
688        assert_eq!(keys.len(), 0);
689    }
690}