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 std::path::PathBuf;
7use url::Url;
8
9/// Represents a resource that can be attached to a payload or produced by an agent.
10///
11/// Attachments provide a flexible way to handle various types of data sources:
12/// - Local files on the filesystem
13/// - Remote resources accessible via URLs
14/// - In-memory data with optional metadata
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Attachment {
17    /// A file on the local filesystem.
18    Local(PathBuf),
19
20    /// A resource accessible via a URL (e.g., http://, https://, s3://).
21    ///
22    /// Note: Remote fetching is not yet implemented. This variant is reserved
23    /// for future functionality.
24    Remote(Url),
25
26    /// In-memory data with optional name and MIME type.
27    InMemory {
28        /// The raw bytes of the attachment.
29        bytes: Vec<u8>,
30        /// Optional file name for identification.
31        file_name: Option<String>,
32        /// Optional MIME type (e.g., "image/png", "application/pdf").
33        mime_type: Option<String>,
34    },
35}
36
37impl Attachment {
38    /// Creates a new local file attachment.
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// use llm_toolkit::attachment::Attachment;
44    /// use std::path::PathBuf;
45    ///
46    /// let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
47    /// ```
48    pub fn local(path: impl Into<PathBuf>) -> Self {
49        Self::Local(path.into())
50    }
51
52    /// Creates a new remote URL attachment.
53    ///
54    /// # Panics
55    ///
56    /// Panics if the URL string is invalid. For fallible construction, use `Url::parse()`
57    /// and construct `Attachment::Remote(url)` directly.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use llm_toolkit::attachment::Attachment;
63    ///
64    /// let attachment = Attachment::remote("https://example.com/image.png");
65    /// ```
66    pub fn remote(url: &str) -> Self {
67        Self::Remote(Url::parse(url).expect("Invalid URL"))
68    }
69
70    /// Creates a new in-memory attachment from raw bytes.
71    ///
72    /// # Examples
73    ///
74    /// ```
75    /// use llm_toolkit::attachment::Attachment;
76    ///
77    /// let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header
78    /// let attachment = Attachment::in_memory(data);
79    /// ```
80    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    /// Creates a new in-memory attachment with metadata.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use llm_toolkit::attachment::Attachment;
94    ///
95    /// let data = vec![0x89, 0x50, 0x4E, 0x47];
96    /// let attachment = Attachment::in_memory_with_meta(
97    ///     data,
98    ///     Some("chart.png".to_string()),
99    ///     Some("image/png".to_string()),
100    /// );
101    /// ```
102    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    /// Returns the file name if available.
115    ///
116    /// For local files, extracts the file name from the path.
117    /// For remote URLs, extracts the last path segment.
118    /// For in-memory attachments, returns the stored file name.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use llm_toolkit::attachment::Attachment;
124    /// use std::path::PathBuf;
125    ///
126    /// let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
127    /// assert_eq!(attachment.file_name(), Some("file.png".to_string()));
128    /// ```
129    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    /// Returns the MIME type if available or can be inferred.
145    ///
146    /// For local files, attempts to infer the MIME type from the file extension.
147    /// For in-memory attachments, returns the stored MIME type.
148    /// For remote URLs, returns None.
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use llm_toolkit::attachment::Attachment;
154    /// use std::path::PathBuf;
155    ///
156    /// let attachment = Attachment::local(PathBuf::from("/path/to/file.png"));
157    /// assert_eq!(attachment.mime_type(), Some("image/png".to_string()));
158    /// ```
159    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    /// Infers MIME type from file extension using the `mime_guess` crate.
168    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    /// Loads the attachment data as bytes.
175    ///
176    /// For local files, reads the file from the filesystem.
177    /// For in-memory attachments, returns a clone of the stored bytes.
178    /// For remote URLs, returns an error (not yet implemented).
179    ///
180    /// This method is only available when the `agent` feature is enabled,
181    /// as it requires async runtime support.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if:
186    /// - The file cannot be read (for local attachments)
187    /// - Remote fetching is attempted (not yet supported)
188    ///
189    /// # Examples
190    ///
191    /// ```no_run
192    /// use llm_toolkit::attachment::Attachment;
193    ///
194    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
195    /// let attachment = Attachment::in_memory(vec![1, 2, 3]);
196    /// let bytes = attachment.load_bytes().await?;
197    /// assert_eq!(bytes, vec![1, 2, 3]);
198    /// # Ok(())
199    /// # }
200    /// ```
201    #[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
214/// Trait for types that can produce named attachments.
215///
216/// This trait is typically derived using `#[derive(ToAttachments)]`.
217/// Types implementing this trait can be used as agent outputs that produce
218/// file-based data that can be consumed by subsequent agents in a workflow.
219///
220/// # Examples
221///
222/// ```
223/// use llm_toolkit::attachment::{Attachment, ToAttachments};
224/// use std::path::PathBuf;
225///
226/// // Manual implementation
227/// struct MyOutput {
228///     data: Vec<u8>,
229/// }
230///
231/// impl ToAttachments for MyOutput {
232///     fn to_attachments(&self) -> Vec<(String, Attachment)> {
233///         vec![("data".to_string(), Attachment::in_memory(self.data.clone()))]
234///     }
235/// }
236///
237/// let output = MyOutput { data: vec![1, 2, 3] };
238/// let attachments = output.to_attachments();
239/// assert_eq!(attachments.len(), 1);
240/// assert_eq!(attachments[0].0, "data");
241/// ```
242pub trait ToAttachments {
243    /// Converts this type into a list of named attachments.
244    ///
245    /// Returns `Vec<(key, Attachment)>` where key identifies the attachment.
246    /// The key is used by the orchestrator to reference this attachment in
247    /// subsequent steps.
248    fn to_attachments(&self) -> Vec<(String, Attachment)>;
249}
250
251/// Trait for types that can declare their attachment schema at compile-time.
252///
253/// This trait is automatically implemented when deriving `ToAttachments`.
254/// It provides metadata about what attachment keys a type will produce,
255/// which is used by the Agent derive macro to augment the agent's expertise.
256///
257/// # Design Philosophy
258///
259/// This trait intentionally does **not** include a `descriptions()` method.
260/// Instead, types implementing `AttachmentSchema` should also implement
261/// `ToPrompt`, which already provides `prompt_schema()` that includes
262/// field descriptions from doc comments.
263///
264/// **Why not duplicate descriptions?**
265/// - `ToPrompt::prompt_schema()` already includes field descriptions
266/// - Adding `attachment_descriptions()` would be redundant
267/// - Users who need schema + descriptions should use `ToPrompt`
268///
269/// # Examples
270///
271/// ```
272/// use llm_toolkit::attachment::AttachmentSchema;
273///
274/// struct MyOutput;
275///
276/// impl AttachmentSchema for MyOutput {
277///     fn attachment_keys() -> &'static [&'static str] {
278///         &["chart", "thumbnail"]
279///     }
280/// }
281///
282/// assert_eq!(MyOutput::attachment_keys(), &["chart", "thumbnail"]);
283/// ```
284///
285/// # Integration with ToPrompt
286///
287/// For full schema with descriptions, implement both traits:
288///
289/// ```ignore
290/// #[derive(ToPrompt, ToAttachments)]
291/// struct ImageGeneratorOutput {
292///     /// Visual chart of the analysis results
293///     #[attachment(key = "analysis_chart")]
294///     pub chart_bytes: Vec<u8>,
295/// }
296///
297/// // AttachmentSchema::attachment_keys() returns: ["analysis_chart"]
298/// // ToPrompt::prompt_schema() returns full schema with descriptions
299/// ```
300pub trait AttachmentSchema {
301    /// Returns a static slice of attachment keys this type produces.
302    fn attachment_keys() -> &'static [&'static str];
303}
304
305// === Blanket implementations for common types ===
306
307impl 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    // === Tests for Attachment ===
352
353    #[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        // Trailing slash indicates a directory, so no file name
437        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    // === Tests for ToAttachments trait ===
562
563    #[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    // === Tests for AttachmentSchema trait ===
659
660    #[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}