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
221impl From<ContentPrimitive> for Content {
222    fn from(prim: ContentPrimitive) -> Self {
223        match prim {
224            ContentPrimitive::Text { text } => Self::Text { text },
225            ContentPrimitive::Image(img) => Self::Image(img),
226            ContentPrimitive::Document(doc) => Self::Document(doc),
227            ContentPrimitive::Audio(audio) => Self::Audio(audio),
228            ContentPrimitive::Video(video) => Self::Video(video),
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn from_str_ref_creates_text_content() {
239        let content: Content = "hello".into();
240        assert_eq!(
241            content,
242            Content::Text {
243                text: "hello".to_string()
244            }
245        );
246    }
247
248    #[test]
249    fn from_string_creates_text_content() {
250        let content: Content = String::from("world").into();
251        assert_eq!(
252            content,
253            Content::Text {
254                text: "world".to_string()
255            }
256        );
257    }
258
259    #[test]
260    fn from_image_creates_image_content() {
261        let img = Image {
262            data: vec![0x89, 0x50, 0x4E, 0x47],
263            mime_type: "image/png".to_string(),
264            description: Some("test image".to_string()),
265        };
266        let content: Content = img.clone().into();
267        assert_eq!(content, Content::Image(img));
268    }
269
270    #[test]
271    fn from_document_creates_document_content() {
272        let doc = Document {
273            data: b"%PDF".to_vec(),
274            mime_type: "application/pdf".to_string(),
275            description: None,
276        };
277        let content: Content = doc.clone().into();
278        assert_eq!(content, Content::Document(doc));
279    }
280
281    #[test]
282    fn from_audio_creates_audio_content() {
283        let audio = Audio {
284            data: vec![0xFF, 0xFB],
285            mime_type: "audio/mp3".to_string(),
286            description: None,
287        };
288        let content: Content = audio.clone().into();
289        assert_eq!(content, Content::Audio(audio));
290    }
291
292    #[test]
293    fn from_video_creates_video_content() {
294        let video = Video {
295            data: vec![0x00, 0x00, 0x00, 0x1C],
296            mime_type: "video/mp4".to_string(),
297            description: Some("test video".to_string()),
298        };
299        let content: Content = video.clone().into();
300        assert_eq!(content, Content::Video(video));
301    }
302
303    #[test]
304    fn from_vec_creates_multi_content() {
305        let parts = vec![
306            ContentPrimitive::Text {
307                text: "describe this:".to_string(),
308            },
309            ContentPrimitive::Image(Image {
310                data: vec![1, 2, 3],
311                mime_type: "image/png".to_string(),
312                description: None,
313            }),
314        ];
315        let content: Content = parts.clone().into();
316        assert_eq!(content, Content::Multi { parts });
317    }
318
319    // ── Serde roundtrip ─────────────────────────────────────────────
320
321    #[test]
322    fn content_text_serde_roundtrip() {
323        let content = Content::Text {
324            text: "hello".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_image_serde_roundtrip() {
333        let content = Content::Image(Image {
334            data: vec![0x89, 0x50, 0x4E, 0x47],
335            mime_type: "image/png".to_string(),
336            description: Some("a PNG".to_string()),
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_document_serde_roundtrip() {
345        let content = Content::Document(Document {
346            data: b"%PDF-1.4".to_vec(),
347            mime_type: "application/pdf".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_audio_serde_roundtrip() {
357        let content = Content::Audio(Audio {
358            data: vec![0xFF, 0xFB, 0x90],
359            mime_type: "audio/mp3".to_string(),
360            description: None,
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_video_serde_roundtrip() {
369        let content = Content::Video(Video {
370            data: vec![0x00, 0x00, 0x00, 0x1C, 0x66],
371            mime_type: "video/mp4".to_string(),
372            description: Some("clip".to_string()),
373        });
374        let json = serde_json::to_string(&content).unwrap();
375        let parsed: Content = serde_json::from_str(&json).unwrap();
376        assert_eq!(parsed, content);
377    }
378
379    #[test]
380    fn content_multi_serde_roundtrip() {
381        let content = Content::Multi {
382            parts: vec![
383                ContentPrimitive::Text {
384                    text: "look at this".to_string(),
385                },
386                ContentPrimitive::Image(Image {
387                    data: vec![1, 2, 3],
388                    mime_type: "image/jpeg".to_string(),
389                    description: None,
390                }),
391            ],
392        };
393        let json = serde_json::to_string(&content).unwrap();
394        let parsed: Content = serde_json::from_str(&json).unwrap();
395        assert_eq!(parsed, content);
396    }
397
398    #[test]
399    fn content_primitive_text_serde_roundtrip() {
400        let prim = ContentPrimitive::Text {
401            text: "hi".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_primitive_image_serde_roundtrip() {
410        let prim = ContentPrimitive::Image(Image {
411            data: vec![9, 8, 7],
412            mime_type: "image/webp".to_string(),
413            description: Some("webp img".to_string()),
414        });
415        let json = serde_json::to_string(&prim).unwrap();
416        let parsed: ContentPrimitive = serde_json::from_str(&json).unwrap();
417        assert_eq!(parsed, prim);
418    }
419
420    #[test]
421    fn content_text_creates_text_variant() {
422        let c = Content::text("hello");
423        assert_eq!(
424            c,
425            Content::Text {
426                text: "hello".to_string()
427            }
428        );
429    }
430
431    #[test]
432    fn content_text_accepts_string() {
433        let c = Content::text(String::from("world"));
434        assert_eq!(
435            c,
436            Content::Text {
437                text: "world".to_string()
438            }
439        );
440    }
441
442    #[test]
443    fn content_is_text_returns_true_for_text() {
444        assert!(Content::text("hello").is_text());
445    }
446
447    #[test]
448    fn content_is_text_returns_false_for_image() {
449        let content = Content::Image(Image::png(vec![1]));
450        assert!(!content.is_text());
451    }
452
453    #[test]
454    fn content_is_text_returns_false_for_document() {
455        let content = Content::Document(Document::pdf(vec![1]));
456        assert!(!content.is_text());
457    }
458
459    #[test]
460    fn content_is_text_returns_false_for_audio() {
461        let content = Content::Audio(Audio::mp3(vec![1]));
462        assert!(!content.is_text());
463    }
464
465    #[test]
466    fn content_is_text_returns_false_for_video() {
467        let content = Content::Video(Video::mp4(vec![1]));
468        assert!(!content.is_text());
469    }
470
471    #[test]
472    fn content_is_text_returns_false_for_multi() {
473        let content = Content::Multi { parts: vec![] };
474        assert!(!content.is_text());
475    }
476
477    #[test]
478    fn content_as_text_returns_some_for_text() {
479        let c = Content::text("hello");
480        assert_eq!(c.as_text(), Some("hello"));
481    }
482
483    #[test]
484    fn content_as_text_returns_none_for_image() {
485        let c = Content::Image(Image::png(vec![1]));
486        assert_eq!(c.as_text(), None);
487    }
488
489    #[test]
490    fn content_as_text_returns_none_for_document() {
491        let c = Content::Document(Document::pdf(vec![1]));
492        assert_eq!(c.as_text(), None);
493    }
494
495    #[test]
496    fn content_as_text_returns_none_for_audio() {
497        let c = Content::Audio(Audio::mp3(vec![1]));
498        assert_eq!(c.as_text(), None);
499    }
500
501    #[test]
502    fn content_as_text_returns_none_for_video() {
503        let c = Content::Video(Video::mp4(vec![1]));
504        assert_eq!(c.as_text(), None);
505    }
506
507    #[test]
508    fn content_as_text_returns_none_for_multi() {
509        let c = Content::Multi { parts: vec![] };
510        assert_eq!(c.as_text(), None);
511    }
512
513    // ── Display tests ───────────────────────────────────────────────
514
515    #[test]
516    fn display_text_renders_content() {
517        let c = Content::text("hello world");
518        assert_eq!(format!("{c}"), "hello world");
519    }
520
521    #[test]
522    fn display_image_shows_mime_type() {
523        let c = Content::Image(Image::png(vec![1]));
524        assert_eq!(format!("{c}"), "[Image: image/png]");
525    }
526
527    #[test]
528    fn display_document_shows_mime_type() {
529        let c = Content::Document(Document::pdf(vec![1]));
530        assert_eq!(format!("{c}"), "[Document: application/pdf]");
531    }
532
533    #[test]
534    fn display_audio_shows_mime_type() {
535        let c = Content::Audio(Audio::mp3(vec![1]));
536        assert_eq!(format!("{c}"), "[Audio: audio/mpeg]");
537    }
538
539    #[test]
540    fn display_video_shows_mime_type() {
541        let c = Content::Video(Video::mp4(vec![1]));
542        assert_eq!(format!("{c}"), "[Video: video/mp4]");
543    }
544
545    #[test]
546    fn display_multi_shows_part_count() {
547        let c = Content::Multi {
548            parts: vec![
549                ContentPrimitive::Text {
550                    text: "a".to_string(),
551                },
552                ContentPrimitive::Text {
553                    text: "b".to_string(),
554                },
555                ContentPrimitive::Text {
556                    text: "c".to_string(),
557                },
558            ],
559        };
560        assert_eq!(format!("{c}"), "[Multi: 3 parts]");
561    }
562
563    #[test]
564    fn display_empty_text() {
565        let c = Content::text("");
566        assert_eq!(format!("{c}"), "");
567    }
568
569    // ── From<ContentPrimitive> tests ────────────────────────────────
570
571    #[test]
572    fn from_content_primitive_text() {
573        let prim = ContentPrimitive::Text {
574            text: "hello".to_string(),
575        };
576        let content: Content = prim.into();
577        assert_eq!(
578            content,
579            Content::Text {
580                text: "hello".to_string()
581            }
582        );
583    }
584
585    #[test]
586    fn from_content_primitive_image() {
587        let prim = ContentPrimitive::Image(Image::png(vec![1, 2, 3]));
588        let content: Content = prim.into();
589        assert!(matches!(content, Content::Image(_)));
590    }
591}