Skip to main content

agy_bridge/content/
types.rs

1//! Content type definitions and conversions.
2
3use serde::{Deserialize, Serialize};
4
5use super::media::{Audio, Document, Image, Video};
6
7// =============================================================================
8// ContentPrimitive — a single content element (non-list)
9// =============================================================================
10
11/// A single content primitive within a [`Content::Multi`] list.
12///
13/// Mirrors the Python SDK's `ContentPrimitive = str | Image | Document | Audio | Video`.
14///
15/// **Why both `ContentPrimitive` and [`Content`]?**
16///
17/// `ContentPrimitive` represents a *single, non-compound* element —
18/// it deliberately excludes the `Multi` variant that [`Content`] provides.
19/// This separation enforces the invariant that multimodal lists are flat
20/// (you cannot nest a `Content::Multi` inside another `Multi`), while
21/// [`Content`] remains the top-level union accepted by `agent.chat()`.
22#[non_exhaustive]
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "type")]
25pub enum ContentPrimitive {
26    /// Plain text content.
27    Text {
28        /// The text value.
29        text: String,
30    },
31    /// An image attachment.
32    Image(Image),
33    /// A document attachment.
34    Document(Document),
35    /// An audio attachment.
36    Audio(Audio),
37    /// A video attachment.
38    Video(Video),
39}
40
41// =============================================================================
42// Content — the top-level chat input union type
43// =============================================================================
44
45/// Chat input content, mirroring the Python SDK's
46/// `Content = str | Image | Document | Audio | Video | list[ContentPrimitive]`.
47///
48/// This is the top-level union type accepted by [`crate::agent::AgentHandle::chat()`].
49/// Unlike [`ContentPrimitive`], it includes the [`Multi`](Self::Multi) variant
50/// for compound multimodal inputs. Scalar variants mirror `ContentPrimitive`
51/// for convenience so callers do not have to wrap a single item in a list.
52///
53/// Use [`From<&str>`] or [`From<String>`] to create text content ergonomically:
54/// ```rust
55/// # use agy_bridge::content::Content;
56/// let content: Content = "hello".into();
57/// ```
58#[non_exhaustive]
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "type")]
61pub enum Content {
62    /// Plain text content (backward-compatible with `chat("hello")`).
63    Text {
64        /// The text value.
65        text: String,
66    },
67    /// An image attachment.
68    Image(Image),
69    /// A document attachment.
70    Document(Document),
71    /// An audio attachment.
72    Audio(Audio),
73    /// A video attachment.
74    Video(Video),
75    /// A list of content primitives (multimodal).
76    Multi {
77        /// The individual content elements.
78        parts: Vec<ContentPrimitive>,
79    },
80}
81
82impl Content {
83    /// Creates a [`Content::Text`] variant from any string-like value.
84    ///
85    /// This is a convenience constructor equivalent to `Content::Text { text: s.into() }`.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// # use agy_bridge::content::Content;
91    /// let c = Content::text("hello");
92    /// assert!(c.is_text());
93    /// assert_eq!(c.as_text(), Some("hello"));
94    /// ```
95    #[must_use]
96    pub fn text(s: impl Into<String>) -> Self {
97        Self::Text { text: s.into() }
98    }
99
100    /// Returns `true` if this content is a [`Content::Text`] variant.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// # use agy_bridge::content::{Content, Image};
106    /// assert!(Content::text("hi").is_text());
107    /// assert!(!Content::Image(Image::png(vec![1])).is_text());
108    /// ```
109    #[must_use]
110    pub const fn is_text(&self) -> bool {
111        matches!(self, Self::Text { .. })
112    }
113
114    /// Returns the text content if this is a [`Content::Text`] variant,
115    /// or `None` otherwise.
116    ///
117    /// # Examples
118    ///
119    /// ```
120    /// # use agy_bridge::content::{Content, Image};
121    /// let text_content = Content::text("hello");
122    /// assert_eq!(text_content.as_text(), Some("hello"));
123    ///
124    /// let image_content = Content::Image(Image::png(vec![1]));
125    /// assert_eq!(image_content.as_text(), None);
126    /// ```
127    #[must_use]
128    pub const fn as_text(&self) -> Option<&str> {
129        match self {
130            Self::Text { text } => Some(text.as_str()),
131            _ => None,
132        }
133    }
134}
135
136// =============================================================================
137// Default + Display for Content
138// =============================================================================
139
140impl Default for Content {
141    /// Defaults to an empty [`Content::Text`] variant.
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// # use agy_bridge::content::Content;
147    /// let c = Content::default();
148    /// assert_eq!(c.as_text(), Some(""));
149    /// ```
150    fn default() -> Self {
151        Self::Text {
152            text: String::new(),
153        }
154    }
155}
156
157impl std::fmt::Display for Content {
158    /// Renders a human-readable summary of the content.
159    ///
160    /// - `Text` → the text itself.
161    /// - Media variants → `"[Image: image/png]"`, etc.
162    /// - `Multi` → `"[Multi: 3 parts]"`.
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            Self::Text { text } => f.write_str(text),
166            Self::Image(m) => write!(f, "[Image: {}]", m.mime_type),
167            Self::Document(m) => write!(f, "[Document: {}]", m.mime_type),
168            Self::Audio(m) => write!(f, "[Audio: {}]", m.mime_type),
169            Self::Video(m) => write!(f, "[Video: {}]", m.mime_type),
170            Self::Multi { parts } => write!(f, "[Multi: {} parts]", parts.len()),
171        }
172    }
173}
174
175// =============================================================================
176// Ergonomic From impls
177// =============================================================================
178
179impl From<&str> for Content {
180    fn from(s: &str) -> Self {
181        Self::Text { text: s.to_owned() }
182    }
183}
184
185impl From<String> for Content {
186    fn from(s: String) -> Self {
187        Self::Text { text: s }
188    }
189}
190
191impl From<Image> for Content {
192    fn from(img: Image) -> Self {
193        Self::Image(img)
194    }
195}
196
197impl From<Document> for Content {
198    fn from(doc: Document) -> Self {
199        Self::Document(doc)
200    }
201}
202
203impl From<Audio> for Content {
204    fn from(audio: Audio) -> Self {
205        Self::Audio(audio)
206    }
207}
208
209impl From<Video> for Content {
210    fn from(video: Video) -> Self {
211        Self::Video(video)
212    }
213}
214
215impl From<Vec<ContentPrimitive>> for Content {
216    fn from(parts: Vec<ContentPrimitive>) -> Self {
217        Self::Multi { parts }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn from_str_ref_creates_text_content() {
227        let content: Content = "hello".into();
228        assert_eq!(
229            content,
230            Content::Text {
231                text: "hello".to_string()
232            }
233        );
234    }
235
236    #[test]
237    fn from_string_creates_text_content() {
238        let content: Content = String::from("world").into();
239        assert_eq!(
240            content,
241            Content::Text {
242                text: "world".to_string()
243            }
244        );
245    }
246
247    #[test]
248    fn from_image_creates_image_content() {
249        let img = Image {
250            data: vec![0x89, 0x50, 0x4E, 0x47],
251            mime_type: "image/png".to_string(),
252            description: Some("test image".to_string()),
253        };
254        let content: Content = img.clone().into();
255        assert_eq!(content, Content::Image(img));
256    }
257
258    #[test]
259    fn from_document_creates_document_content() {
260        let doc = Document {
261            data: b"%PDF".to_vec(),
262            mime_type: "application/pdf".to_string(),
263            description: None,
264        };
265        let content: Content = doc.clone().into();
266        assert_eq!(content, Content::Document(doc));
267    }
268
269    #[test]
270    fn from_audio_creates_audio_content() {
271        let audio = Audio {
272            data: vec![0xFF, 0xFB],
273            mime_type: "audio/mp3".to_string(),
274            description: None,
275        };
276        let content: Content = audio.clone().into();
277        assert_eq!(content, Content::Audio(audio));
278    }
279
280    #[test]
281    fn from_video_creates_video_content() {
282        let video = Video {
283            data: vec![0x00, 0x00, 0x00, 0x1C],
284            mime_type: "video/mp4".to_string(),
285            description: Some("test video".to_string()),
286        };
287        let content: Content = video.clone().into();
288        assert_eq!(content, Content::Video(video));
289    }
290
291    #[test]
292    fn from_vec_creates_multi_content() {
293        let parts = vec![
294            ContentPrimitive::Text {
295                text: "describe this:".to_string(),
296            },
297            ContentPrimitive::Image(Image {
298                data: vec![1, 2, 3],
299                mime_type: "image/png".to_string(),
300                description: None,
301            }),
302        ];
303        let content: Content = parts.clone().into();
304        assert_eq!(content, Content::Multi { parts });
305    }
306
307    // ── Serde roundtrip ─────────────────────────────────────────────
308
309    #[test]
310    fn content_text_serde_roundtrip() {
311        let content = Content::Text {
312            text: "hello".to_string(),
313        };
314        let json = serde_json::to_string(&content).unwrap();
315        let parsed: Content = serde_json::from_str(&json).unwrap();
316        assert_eq!(parsed, content);
317    }
318
319    #[test]
320    fn content_image_serde_roundtrip() {
321        let content = Content::Image(Image {
322            data: vec![0x89, 0x50, 0x4E, 0x47],
323            mime_type: "image/png".to_string(),
324            description: Some("a PNG".to_string()),
325        });
326        let json = serde_json::to_string(&content).unwrap();
327        let parsed: Content = serde_json::from_str(&json).unwrap();
328        assert_eq!(parsed, content);
329    }
330
331    #[test]
332    fn content_document_serde_roundtrip() {
333        let content = Content::Document(Document {
334            data: b"%PDF-1.4".to_vec(),
335            mime_type: "application/pdf".to_string(),
336            description: None,
337        });
338        let json = serde_json::to_string(&content).unwrap();
339        let parsed: Content = serde_json::from_str(&json).unwrap();
340        assert_eq!(parsed, content);
341    }
342
343    #[test]
344    fn content_audio_serde_roundtrip() {
345        let content = Content::Audio(Audio {
346            data: vec![0xFF, 0xFB, 0x90],
347            mime_type: "audio/mp3".to_string(),
348            description: None,
349        });
350        let json = serde_json::to_string(&content).unwrap();
351        let parsed: Content = serde_json::from_str(&json).unwrap();
352        assert_eq!(parsed, content);
353    }
354
355    #[test]
356    fn content_video_serde_roundtrip() {
357        let content = Content::Video(Video {
358            data: vec![0x00, 0x00, 0x00, 0x1C, 0x66],
359            mime_type: "video/mp4".to_string(),
360            description: Some("clip".to_string()),
361        });
362        let json = serde_json::to_string(&content).unwrap();
363        let parsed: Content = serde_json::from_str(&json).unwrap();
364        assert_eq!(parsed, content);
365    }
366
367    #[test]
368    fn content_multi_serde_roundtrip() {
369        let content = Content::Multi {
370            parts: vec![
371                ContentPrimitive::Text {
372                    text: "look at this".to_string(),
373                },
374                ContentPrimitive::Image(Image {
375                    data: vec![1, 2, 3],
376                    mime_type: "image/jpeg".to_string(),
377                    description: None,
378                }),
379            ],
380        };
381        let json = serde_json::to_string(&content).unwrap();
382        let parsed: Content = serde_json::from_str(&json).unwrap();
383        assert_eq!(parsed, content);
384    }
385
386    #[test]
387    fn content_primitive_text_serde_roundtrip() {
388        let prim = ContentPrimitive::Text {
389            text: "hi".to_string(),
390        };
391        let json = serde_json::to_string(&prim).unwrap();
392        let parsed: ContentPrimitive = serde_json::from_str(&json).unwrap();
393        assert_eq!(parsed, prim);
394    }
395
396    #[test]
397    fn content_primitive_image_serde_roundtrip() {
398        let prim = ContentPrimitive::Image(Image {
399            data: vec![9, 8, 7],
400            mime_type: "image/webp".to_string(),
401            description: Some("webp img".to_string()),
402        });
403        let json = serde_json::to_string(&prim).unwrap();
404        let parsed: ContentPrimitive = serde_json::from_str(&json).unwrap();
405        assert_eq!(parsed, prim);
406    }
407
408    #[test]
409    fn content_text_creates_text_variant() {
410        let c = Content::text("hello");
411        assert_eq!(
412            c,
413            Content::Text {
414                text: "hello".to_string()
415            }
416        );
417    }
418
419    #[test]
420    fn content_text_accepts_string() {
421        let c = Content::text(String::from("world"));
422        assert_eq!(
423            c,
424            Content::Text {
425                text: "world".to_string()
426            }
427        );
428    }
429
430    #[test]
431    fn content_is_text_returns_true_for_text() {
432        assert!(Content::text("hello").is_text());
433    }
434
435    #[test]
436    fn content_is_text_returns_false_for_image() {
437        let content = Content::Image(Image::png(vec![1]));
438        assert!(!content.is_text());
439    }
440
441    #[test]
442    fn content_is_text_returns_false_for_document() {
443        let content = Content::Document(Document::pdf(vec![1]));
444        assert!(!content.is_text());
445    }
446
447    #[test]
448    fn content_is_text_returns_false_for_audio() {
449        let content = Content::Audio(Audio::mp3(vec![1]));
450        assert!(!content.is_text());
451    }
452
453    #[test]
454    fn content_is_text_returns_false_for_video() {
455        let content = Content::Video(Video::mp4(vec![1]));
456        assert!(!content.is_text());
457    }
458
459    #[test]
460    fn content_is_text_returns_false_for_multi() {
461        let content = Content::Multi { parts: vec![] };
462        assert!(!content.is_text());
463    }
464
465    #[test]
466    fn content_as_text_returns_some_for_text() {
467        let c = Content::text("hello");
468        assert_eq!(c.as_text(), Some("hello"));
469    }
470
471    #[test]
472    fn content_as_text_returns_none_for_image() {
473        let c = Content::Image(Image::png(vec![1]));
474        assert_eq!(c.as_text(), None);
475    }
476
477    #[test]
478    fn content_as_text_returns_none_for_document() {
479        let c = Content::Document(Document::pdf(vec![1]));
480        assert_eq!(c.as_text(), None);
481    }
482
483    #[test]
484    fn content_as_text_returns_none_for_audio() {
485        let c = Content::Audio(Audio::mp3(vec![1]));
486        assert_eq!(c.as_text(), None);
487    }
488
489    #[test]
490    fn content_as_text_returns_none_for_video() {
491        let c = Content::Video(Video::mp4(vec![1]));
492        assert_eq!(c.as_text(), None);
493    }
494
495    #[test]
496    fn content_as_text_returns_none_for_multi() {
497        let c = Content::Multi { parts: vec![] };
498        assert_eq!(c.as_text(), None);
499    }
500}