Skip to main content

agy_bridge/content/
media.rs

1//! Multimodal content types for chat input, mirroring the Python SDK's content
2//! primitives.
3//!
4//! The Python SDK accepts `Content = str | Image | Document | Audio | Video |
5//! list[ContentPrimitive]` as chat input. This module provides strongly-typed
6//! Rust equivalents with serialization support and ergonomic `From` impls.
7
8use serde::{Deserialize, Serialize};
9
10// =============================================================================
11// Media structs
12// =============================================================================
13
14/// Image content attachment primitive.
15///
16/// Binary image data with MIME type, mirroring `google.antigravity.types.Image`.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Image {
19    /// Raw image bytes (e.g. PNG, JPEG).
20    pub data: Vec<u8>,
21    /// MIME type of the image (e.g. `"image/png"`).
22    pub mime_type: String,
23    /// Optional text description of the image.
24    #[serde(default)]
25    pub description: Option<String>,
26}
27
28/// Document content attachment primitive.
29///
30/// Binary document data with MIME type, mirroring `google.antigravity.types.Document`.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Document {
33    /// Raw document bytes (e.g. PDF, JSON).
34    pub data: Vec<u8>,
35    /// MIME type of the document (e.g. `"application/pdf"`).
36    pub mime_type: String,
37    /// Optional text description of the document.
38    #[serde(default)]
39    pub description: Option<String>,
40}
41
42/// Audio content attachment primitive.
43///
44/// Binary audio data with MIME type, mirroring `google.antigravity.types.Audio`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Audio {
47    /// Raw audio bytes (e.g. WAV, MP3).
48    pub data: Vec<u8>,
49    /// MIME type of the audio (e.g. `"audio/wav"`).
50    pub mime_type: String,
51    /// Optional text description of the audio.
52    #[serde(default)]
53    pub description: Option<String>,
54}
55
56/// Video content attachment primitive.
57///
58/// Binary video data with MIME type, mirroring `google.antigravity.types.Video`.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Video {
61    /// Raw video bytes (e.g. MP4, `WebM`).
62    pub data: Vec<u8>,
63    /// MIME type of the video (e.g. `"video/mp4"`).
64    pub mime_type: String,
65    /// Optional text description of the video.
66    #[serde(default)]
67    pub description: Option<String>,
68}
69
70// =============================================================================
71// MediaContent trait — shared interface for all media attachment types
72// =============================================================================
73
74/// Common interface for binary media attachment types ([`Image`], [`Document`],
75/// [`Audio`], [`Video`]).
76///
77/// Introduced to reduce boilerplate in serialization helpers that previously
78/// had to destructure each media struct individually.
79pub trait MediaContent {
80    /// The Python SDK type name used in the wire-format `"type"` field
81    /// (e.g. `"Image"`, `"Audio"`).
82    const TYPE_NAME: &'static str;
83
84    /// Raw binary payload.
85    fn data(&self) -> &[u8];
86    /// MIME type string.
87    fn mime_type(&self) -> &str;
88    /// Optional human-readable description.
89    fn description(&self) -> Option<&str>;
90}
91
92impl MediaContent for Image {
93    const TYPE_NAME: &'static str = "Image";
94    fn data(&self) -> &[u8] {
95        &self.data
96    }
97    fn mime_type(&self) -> &str {
98        &self.mime_type
99    }
100    fn description(&self) -> Option<&str> {
101        self.description.as_deref()
102    }
103}
104
105impl MediaContent for Document {
106    const TYPE_NAME: &'static str = "Document";
107    fn data(&self) -> &[u8] {
108        &self.data
109    }
110    fn mime_type(&self) -> &str {
111        &self.mime_type
112    }
113    fn description(&self) -> Option<&str> {
114        self.description.as_deref()
115    }
116}
117
118impl MediaContent for Audio {
119    const TYPE_NAME: &'static str = "Audio";
120    fn data(&self) -> &[u8] {
121        &self.data
122    }
123    fn mime_type(&self) -> &str {
124        &self.mime_type
125    }
126    fn description(&self) -> Option<&str> {
127        self.description.as_deref()
128    }
129}
130
131impl MediaContent for Video {
132    const TYPE_NAME: &'static str = "Video";
133    fn data(&self) -> &[u8] {
134        &self.data
135    }
136    fn mime_type(&self) -> &str {
137        &self.mime_type
138    }
139    fn description(&self) -> Option<&str> {
140        self.description.as_deref()
141    }
142}
143
144// =============================================================================
145// MIME type constants
146// =============================================================================
147
148/// Common image MIME types.
149pub mod mime {
150    /// MIME type for PNG images.
151    pub const IMAGE_PNG: &str = "image/png";
152    /// MIME type for JPEG images.
153    pub const IMAGE_JPEG: &str = "image/jpeg";
154    /// MIME type for BMP images.
155    pub const IMAGE_BMP: &str = "image/bmp";
156    /// MIME type for WebP images.
157    pub const IMAGE_WEBP: &str = "image/webp";
158
159    /// MIME type for PDF documents.
160    pub const APPLICATION_PDF: &str = "application/pdf";
161    /// MIME type for plain text documents.
162    pub const TEXT_PLAIN: &str = "text/plain";
163    /// MIME type for JSON documents.
164    pub const APPLICATION_JSON: &str = "application/json";
165    /// MIME type for CSS stylesheets.
166    pub const TEXT_CSS: &str = "text/css";
167    /// MIME type for CSV data.
168    pub const TEXT_CSV: &str = "text/csv";
169    /// MIME type for HTML documents.
170    pub const TEXT_HTML: &str = "text/html";
171    /// MIME type for JavaScript.
172    pub const TEXT_JAVASCRIPT: &str = "text/javascript";
173    /// MIME type for RTF documents.
174    pub const TEXT_RTF: &str = "text/rtf";
175    /// MIME type for XML documents.
176    pub const TEXT_XML: &str = "text/xml";
177
178    /// MIME type for MP3 audio.
179    pub const AUDIO_MPEG: &str = "audio/mpeg";
180    /// MIME type for WAV audio.
181    pub const AUDIO_WAV: &str = "audio/wav";
182    /// MIME type for OGG audio.
183    pub const AUDIO_OGG: &str = "audio/ogg";
184    /// MIME type for FLAC audio.
185    pub const AUDIO_FLAC: &str = "audio/flac";
186    /// MIME type for AAC audio.
187    pub const AUDIO_AAC: &str = "audio/aac";
188    /// MIME type for Opus audio.
189    pub const AUDIO_OPUS: &str = "audio/opus";
190    /// MIME type for M4A audio.
191    pub const AUDIO_M4A: &str = "audio/m4a";
192
193    /// MIME type for MP4 video.
194    pub const VIDEO_MP4: &str = "video/mp4";
195    /// MIME type for `WebM` video.
196    pub const VIDEO_WEBM: &str = "video/webm";
197    /// MIME type for 3GPP video.
198    pub const VIDEO_3GPP: &str = "video/3gpp";
199    /// MIME type for AVI video.
200    pub const VIDEO_AVI: &str = "video/avi";
201    /// MIME type for MPEG video.
202    pub const VIDEO_MPEG: &str = "video/mpeg";
203    /// MIME type for `QuickTime` video.
204    pub const VIDEO_QUICKTIME: &str = "video/quicktime";
205    /// MIME type for WMV video.
206    pub const VIDEO_WMV: &str = "video/wmv";
207    /// MIME type for FLV video.
208    pub const VIDEO_X_FLV: &str = "video/x-flv";
209
210    /// Infer a MIME type from a file extension.
211    ///
212    /// Returns `None` if the extension is unrecognized.
213    ///
214    /// The supported set matches the Python SDK's `SUPPORTED_*_MIMES`
215    /// allowlists. If the SDK adds new types, this function should be
216    /// updated to match.
217    #[must_use]
218    pub fn from_extension(ext: &str) -> Option<&'static str> {
219        match ext.to_ascii_lowercase().as_str() {
220            // Images
221            "png" => Some(IMAGE_PNG),
222            "jpg" | "jpeg" => Some(IMAGE_JPEG),
223            "bmp" => Some(IMAGE_BMP),
224            "webp" => Some(IMAGE_WEBP),
225            // Documents
226            "pdf" => Some(APPLICATION_PDF),
227            "txt" => Some(TEXT_PLAIN),
228            "json" => Some(APPLICATION_JSON),
229            "css" => Some(TEXT_CSS),
230            "csv" => Some(TEXT_CSV),
231            "html" | "htm" => Some(TEXT_HTML),
232            "js" | "mjs" => Some(TEXT_JAVASCRIPT),
233            "rtf" => Some(TEXT_RTF),
234            "xml" => Some(TEXT_XML),
235            // Audio
236            "mp3" => Some(AUDIO_MPEG),
237            "wav" => Some(AUDIO_WAV),
238            "ogg" | "oga" => Some(AUDIO_OGG),
239            "flac" => Some(AUDIO_FLAC),
240            "aac" => Some(AUDIO_AAC),
241            "opus" => Some(AUDIO_OPUS),
242            "m4a" => Some(AUDIO_M4A),
243            // Video
244            "mp4" | "m4v" => Some(VIDEO_MP4),
245            "webm" => Some(VIDEO_WEBM),
246            "3gp" | "3gpp" => Some(VIDEO_3GPP),
247            "avi" => Some(VIDEO_AVI),
248            "mpeg" | "mpg" => Some(VIDEO_MPEG),
249            "mov" => Some(VIDEO_QUICKTIME),
250            "wmv" => Some(VIDEO_WMV),
251            "flv" => Some(VIDEO_X_FLV),
252            _ => None,
253        }
254    }
255}
256
257/// Shared implementation for loading media from a file path.
258///
259/// Extracts the file extension, looks up the MIME type, validates the prefix,
260/// reads the file, and returns `(data, mime_type)`.
261fn from_file_inner(
262    path: &std::path::Path,
263    type_label: &str,
264    mime_prefixes: &[&str],
265) -> std::io::Result<(Vec<u8>, String)> {
266    let ext = path.extension().and_then(|e| e.to_str()).ok_or_else(|| {
267        std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing file extension")
268    })?;
269    let mime_type = mime::from_extension(ext).ok_or_else(|| {
270        std::io::Error::new(
271            std::io::ErrorKind::InvalidInput,
272            format!("unrecognized {type_label} extension: {ext}"),
273        )
274    })?;
275    if !mime_prefixes
276        .iter()
277        .any(|prefix| mime_type.starts_with(prefix))
278    {
279        return Err(std::io::Error::new(
280            std::io::ErrorKind::InvalidInput,
281            format!("MIME type '{mime_type}' is not {type_label} type"),
282        ));
283    }
284    let data = std::fs::read(path)?;
285    Ok((data, mime_type.to_owned()))
286}
287
288// =============================================================================
289// Media convenience constructors
290// =============================================================================
291
292impl Image {
293    /// Creates a new [`Image`] with the given data and MIME type.
294    ///
295    /// # Examples
296    ///
297    /// ```
298    /// # use agy_bridge::content::Image;
299    /// let img = Image::new(vec![0x89, 0x50], "image/png");
300    /// assert_eq!(img.mime_type, "image/png");
301    /// assert_eq!(img.data, vec![0x89, 0x50]);
302    /// assert!(img.description.is_none());
303    /// ```
304    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
305        Self {
306            data,
307            mime_type: mime_type.into(),
308            description: None,
309        }
310    }
311
312    /// Creates a new [`Image`] with MIME type `image/png`.
313    ///
314    /// # Examples
315    ///
316    /// ```
317    /// # use agy_bridge::content::Image;
318    /// let img = Image::png(vec![1, 2, 3]);
319    /// assert_eq!(img.mime_type, "image/png");
320    /// assert_eq!(img.data, vec![1, 2, 3]);
321    /// ```
322    #[must_use]
323    pub fn png(data: Vec<u8>) -> Self {
324        Self::new(data, mime::IMAGE_PNG)
325    }
326
327    /// Creates a new [`Image`] with MIME type `image/jpeg`.
328    ///
329    /// # Examples
330    ///
331    /// ```
332    /// # use agy_bridge::content::Image;
333    /// let img = Image::jpeg(vec![0xFF, 0xD8]);
334    /// assert_eq!(img.mime_type, "image/jpeg");
335    /// ```
336    #[must_use]
337    pub fn jpeg(data: Vec<u8>) -> Self {
338        Self::new(data, mime::IMAGE_JPEG)
339    }
340
341    /// Creates a new [`Image`] with MIME type `image/webp`.
342    #[must_use]
343    pub fn webp(data: Vec<u8>) -> Self {
344        Self::new(data, mime::IMAGE_WEBP)
345    }
346
347    /// Creates a new [`Image`] with MIME type `image/bmp`.
348    #[must_use]
349    pub fn bmp(data: Vec<u8>) -> Self {
350        Self::new(data, mime::IMAGE_BMP)
351    }
352
353    /// Sets a description on this image, consuming and returning `self`.
354    #[must_use]
355    pub fn with_description(mut self, description: impl Into<String>) -> Self {
356        self.description = Some(description.into());
357        self
358    }
359
360    /// Load an image from a file path, inferring the MIME type from the extension.
361    ///
362    /// # Errors
363    ///
364    /// Returns `std::io::Error` if the file cannot be read or the extension
365    /// is unrecognized.
366    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
367        let (data, mime_type) = from_file_inner(path.as_ref(), "an image", &["image/"])?;
368        Ok(Self::new(data, mime_type))
369    }
370}
371
372impl Document {
373    /// Creates a new [`Document`] with the given data and MIME type.
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// # use agy_bridge::content::Document;
379    /// let doc = Document::new(b"%PDF".to_vec(), "application/pdf");
380    /// assert_eq!(doc.mime_type, "application/pdf");
381    /// assert!(doc.description.is_none());
382    /// ```
383    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
384        Self {
385            data,
386            mime_type: mime_type.into(),
387            description: None,
388        }
389    }
390
391    /// Creates a new [`Document`] with MIME type `application/pdf`.
392    ///
393    /// # Examples
394    ///
395    /// ```
396    /// # use agy_bridge::content::Document;
397    /// let doc = Document::pdf(b"%PDF-1.4".to_vec());
398    /// assert_eq!(doc.mime_type, "application/pdf");
399    /// ```
400    #[must_use]
401    pub fn pdf(data: Vec<u8>) -> Self {
402        Self::new(data, mime::APPLICATION_PDF)
403    }
404
405    /// Creates a new [`Document`] with MIME type `text/plain`.
406    #[must_use]
407    pub fn plain_text(data: Vec<u8>) -> Self {
408        Self::new(data, mime::TEXT_PLAIN)
409    }
410
411    /// Creates a new [`Document`] with MIME type `application/json`.
412    #[must_use]
413    pub fn json(data: Vec<u8>) -> Self {
414        Self::new(data, mime::APPLICATION_JSON)
415    }
416
417    /// Sets a description on this document, consuming and returning `self`.
418    #[must_use]
419    pub fn with_description(mut self, description: impl Into<String>) -> Self {
420        self.description = Some(description.into());
421        self
422    }
423
424    /// Load a document from a file path, inferring the MIME type from the extension.
425    ///
426    /// # Errors
427    ///
428    /// Returns `std::io::Error` if the file cannot be read or the extension
429    /// is unrecognized.
430    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
431        let (data, mime_type) =
432            from_file_inner(path.as_ref(), "a document", &["application/", "text/"])?;
433        Ok(Self::new(data, mime_type))
434    }
435}
436
437impl Audio {
438    /// Creates a new [`Audio`] with the given data and MIME type.
439    ///
440    /// # Examples
441    ///
442    /// ```
443    /// # use agy_bridge::content::Audio;
444    /// let audio = Audio::new(vec![0xFF, 0xFB], "audio/mpeg");
445    /// assert_eq!(audio.mime_type, "audio/mpeg");
446    /// assert!(audio.description.is_none());
447    /// ```
448    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
449        Self {
450            data,
451            mime_type: mime_type.into(),
452            description: None,
453        }
454    }
455
456    /// Creates a new [`Audio`] with MIME type `audio/mpeg` (MP3).
457    ///
458    /// # Examples
459    ///
460    /// ```
461    /// # use agy_bridge::content::Audio;
462    /// let audio = Audio::mp3(vec![0xFF, 0xFB]);
463    /// assert_eq!(audio.mime_type, "audio/mpeg");
464    /// ```
465    #[must_use]
466    pub fn mp3(data: Vec<u8>) -> Self {
467        Self::new(data, mime::AUDIO_MPEG)
468    }
469
470    /// Creates a new [`Audio`] with MIME type `audio/wav`.
471    ///
472    /// # Examples
473    ///
474    /// ```
475    /// # use agy_bridge::content::Audio;
476    /// let audio = Audio::wav(vec![0x52, 0x49, 0x46, 0x46]);
477    /// assert_eq!(audio.mime_type, "audio/wav");
478    /// ```
479    #[must_use]
480    pub fn wav(data: Vec<u8>) -> Self {
481        Self::new(data, mime::AUDIO_WAV)
482    }
483
484    /// Creates a new [`Audio`] with MIME type `audio/ogg`.
485    #[must_use]
486    pub fn ogg(data: Vec<u8>) -> Self {
487        Self::new(data, mime::AUDIO_OGG)
488    }
489
490    /// Creates a new [`Audio`] with MIME type `audio/flac`.
491    #[must_use]
492    pub fn flac(data: Vec<u8>) -> Self {
493        Self::new(data, mime::AUDIO_FLAC)
494    }
495
496    /// Sets a description on this audio, consuming and returning `self`.
497    #[must_use]
498    pub fn with_description(mut self, description: impl Into<String>) -> Self {
499        self.description = Some(description.into());
500        self
501    }
502
503    /// Load audio from a file path, inferring the MIME type from the extension.
504    ///
505    /// # Errors
506    ///
507    /// Returns `std::io::Error` if the file cannot be read or the extension
508    /// is unrecognized.
509    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
510        let (data, mime_type) = from_file_inner(path.as_ref(), "an audio", &["audio/"])?;
511        Ok(Self::new(data, mime_type))
512    }
513}
514
515impl Video {
516    /// Creates a new [`Video`] with the given data and MIME type.
517    ///
518    /// # Examples
519    ///
520    /// ```
521    /// # use agy_bridge::content::Video;
522    /// let video = Video::new(vec![0x00, 0x00], "video/mp4");
523    /// assert_eq!(video.mime_type, "video/mp4");
524    /// assert!(video.description.is_none());
525    /// ```
526    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
527        Self {
528            data,
529            mime_type: mime_type.into(),
530            description: None,
531        }
532    }
533
534    /// Creates a new [`Video`] with MIME type `video/mp4`.
535    ///
536    /// # Examples
537    ///
538    /// ```
539    /// # use agy_bridge::content::Video;
540    /// let video = Video::mp4(vec![0x00, 0x00, 0x00, 0x1C]);
541    /// assert_eq!(video.mime_type, "video/mp4");
542    /// ```
543    #[must_use]
544    pub fn mp4(data: Vec<u8>) -> Self {
545        Self::new(data, mime::VIDEO_MP4)
546    }
547
548    /// Creates a new [`Video`] with MIME type `video/webm`.
549    #[must_use]
550    pub fn webm(data: Vec<u8>) -> Self {
551        Self::new(data, mime::VIDEO_WEBM)
552    }
553
554    /// Sets a description on this video, consuming and returning `self`.
555    #[must_use]
556    pub fn with_description(mut self, description: impl Into<String>) -> Self {
557        self.description = Some(description.into());
558        self
559    }
560
561    /// Load video from a file path, inferring the MIME type from the extension.
562    ///
563    /// # Errors
564    ///
565    /// Returns `std::io::Error` if the file cannot be read or the extension
566    /// is unrecognized.
567    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
568        let (data, mime_type) = from_file_inner(path.as_ref(), "a video", &["video/"])?;
569        Ok(Self::new(data, mime_type))
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn image_struct_serde_roundtrip() {
579        let img = Image {
580            data: vec![10, 20, 30],
581            mime_type: "image/bmp".to_string(),
582            description: Some("bitmap".to_string()),
583        };
584        let json = serde_json::to_string(&img).unwrap();
585        let parsed: Image = serde_json::from_str(&json).unwrap();
586        assert_eq!(parsed, img);
587    }
588
589    #[test]
590    fn document_struct_serde_roundtrip() {
591        let doc = Document {
592            data: b"{}".to_vec(),
593            mime_type: "application/json".to_string(),
594            description: None,
595        };
596        let json = serde_json::to_string(&doc).unwrap();
597        let parsed: Document = serde_json::from_str(&json).unwrap();
598        assert_eq!(parsed, doc);
599    }
600
601    #[test]
602    fn audio_struct_serde_roundtrip() {
603        let audio = Audio {
604            data: vec![0xAA, 0xBB],
605            mime_type: "audio/wav".to_string(),
606            description: Some("beep".to_string()),
607        };
608        let json = serde_json::to_string(&audio).unwrap();
609        let parsed: Audio = serde_json::from_str(&json).unwrap();
610        assert_eq!(parsed, audio);
611    }
612
613    #[test]
614    fn video_struct_serde_roundtrip() {
615        let video = Video {
616            data: vec![0xCC, 0xDD, 0xEE],
617            mime_type: "video/webm".to_string(),
618            description: None,
619        };
620        let json = serde_json::to_string(&video).unwrap();
621        let parsed: Video = serde_json::from_str(&json).unwrap();
622        assert_eq!(parsed, video);
623    }
624
625    #[test]
626    fn image_description_defaults_to_none() {
627        let json = r#"{"data":[1,2,3],"mime_type":"image/png"}"#;
628        let img: Image = serde_json::from_str(json).unwrap();
629        assert!(img.description.is_none());
630    }
631
632    #[test]
633    fn image_new_creates_correct_image() {
634        let img = Image::new(vec![10, 20], "image/webp");
635        assert_eq!(img.data, vec![10, 20]);
636        assert_eq!(img.mime_type, "image/webp");
637        assert!(img.description.is_none());
638    }
639
640    #[test]
641    fn image_png_creates_correct_image() {
642        let img = Image::png(vec![1, 2, 3]);
643        assert_eq!(img.data, vec![1, 2, 3]);
644        assert_eq!(img.mime_type, "image/png");
645        assert!(img.description.is_none());
646    }
647
648    #[test]
649    fn image_jpeg_creates_correct_image() {
650        let img = Image::jpeg(vec![0xFF, 0xD8]);
651        assert_eq!(img.data, vec![0xFF, 0xD8]);
652        assert_eq!(img.mime_type, "image/jpeg");
653        assert!(img.description.is_none());
654    }
655
656    #[test]
657    fn document_new_creates_correct_document() {
658        let doc = Document::new(b"data".to_vec(), "text/plain");
659        assert_eq!(doc.data, b"data".to_vec());
660        assert_eq!(doc.mime_type, "text/plain");
661        assert!(doc.description.is_none());
662    }
663
664    #[test]
665    fn document_pdf_creates_correct_document() {
666        let doc = Document::pdf(b"%PDF-1.4".to_vec());
667        assert_eq!(doc.data, b"%PDF-1.4".to_vec());
668        assert_eq!(doc.mime_type, "application/pdf");
669        assert!(doc.description.is_none());
670    }
671
672    #[test]
673    fn audio_new_creates_correct_audio() {
674        let audio = Audio::new(vec![0xAA], "audio/ogg");
675        assert_eq!(audio.data, vec![0xAA]);
676        assert_eq!(audio.mime_type, "audio/ogg");
677        assert!(audio.description.is_none());
678    }
679
680    #[test]
681    fn audio_mp3_creates_correct_audio() {
682        let audio = Audio::mp3(vec![0xFF, 0xFB]);
683        assert_eq!(audio.data, vec![0xFF, 0xFB]);
684        assert_eq!(audio.mime_type, "audio/mpeg");
685        assert!(audio.description.is_none());
686    }
687
688    #[test]
689    fn audio_wav_creates_correct_audio() {
690        let audio = Audio::wav(vec![0x52, 0x49, 0x46, 0x46]);
691        assert_eq!(audio.data, vec![0x52, 0x49, 0x46, 0x46]);
692        assert_eq!(audio.mime_type, "audio/wav");
693        assert!(audio.description.is_none());
694    }
695
696    #[test]
697    fn video_new_creates_correct_video() {
698        let video = Video::new(vec![0x00], "video/webm");
699        assert_eq!(video.data, vec![0x00]);
700        assert_eq!(video.mime_type, "video/webm");
701        assert!(video.description.is_none());
702    }
703
704    #[test]
705    fn video_mp4_creates_correct_video() {
706        let video = Video::mp4(vec![0x00, 0x00, 0x00, 0x1C]);
707        assert_eq!(video.data, vec![0x00, 0x00, 0x00, 0x1C]);
708        assert_eq!(video.mime_type, "video/mp4");
709        assert!(video.description.is_none());
710    }
711
712    #[test]
713    fn image_new_accepts_string_type() {
714        let img = Image::new(vec![1], String::from("image/bmp"));
715        assert_eq!(img.mime_type, "image/bmp");
716    }
717
718    // ── with_description() builder ──────────────────────────────────
719
720    #[test]
721    fn image_with_description_sets_description() {
722        let img = Image::png(vec![1]).with_description("a logo");
723        assert_eq!(img.description.as_deref(), Some("a logo"));
724        assert_eq!(img.mime_type, "image/png");
725    }
726
727    #[test]
728    fn document_with_description_sets_description() {
729        let doc = Document::pdf(vec![1]).with_description("invoice");
730        assert_eq!(doc.description.as_deref(), Some("invoice"));
731        assert_eq!(doc.mime_type, "application/pdf");
732    }
733
734    #[test]
735    fn audio_with_description_sets_description() {
736        let audio = Audio::mp3(vec![1]).with_description("intro jingle");
737        assert_eq!(audio.description.as_deref(), Some("intro jingle"));
738        assert_eq!(audio.mime_type, "audio/mpeg");
739    }
740
741    #[test]
742    fn video_with_description_sets_description() {
743        let video = Video::mp4(vec![1]).with_description("demo clip");
744        assert_eq!(video.description.as_deref(), Some("demo clip"));
745        assert_eq!(video.mime_type, "video/mp4");
746    }
747
748    // ── Convenience constructors ────────────────────────────────────
749
750    #[test]
751    fn image_webp_creates_correct_image() {
752        let img = Image::webp(vec![1, 2]);
753        assert_eq!(img.mime_type, "image/webp");
754        assert_eq!(img.data, vec![1, 2]);
755        assert!(img.description.is_none());
756    }
757
758    #[test]
759    fn image_bmp_creates_correct_image() {
760        let img = Image::bmp(vec![0x42, 0x4D]);
761        assert_eq!(img.mime_type, "image/bmp");
762        assert_eq!(img.data, vec![0x42, 0x4D]);
763        assert!(img.description.is_none());
764    }
765
766    #[test]
767    fn convenience_constructors_use_sdk_allowed_mimes() {
768        // Mirrors google.antigravity.types SUPPORTED_*_MIMES (the source of
769        // truth). If the SDK allowlist changes, update these arrays and the
770        // constructors together.
771        const SDK_IMAGE: &[&str] = &["image/bmp", "image/jpeg", "image/png", "image/webp"];
772        const SDK_DOCUMENT: &[&str] = &[
773            "application/pdf",
774            "application/json",
775            "text/css",
776            "text/csv",
777            "text/html",
778            "text/javascript",
779            "text/plain",
780            "text/rtf",
781            "text/xml",
782        ];
783        const SDK_AUDIO: &[&str] = &[
784            "audio/wav",
785            "audio/mp3",
786            "audio/aac",
787            "audio/ogg",
788            "audio/flac",
789            "audio/opus",
790            "audio/mpeg",
791            "audio/m4a",
792            "audio/l16",
793        ];
794        const SDK_VIDEO: &[&str] = &[
795            "video/3gpp",
796            "video/avi",
797            "video/mp4",
798            "video/mpeg",
799            "video/mpg",
800            "video/quicktime",
801            "video/webm",
802            "video/wmv",
803            "video/x-flv",
804        ];
805
806        let d = vec![0u8];
807        for m in [
808            Image::png(d.clone()).mime_type,
809            Image::jpeg(d.clone()).mime_type,
810            Image::webp(d.clone()).mime_type,
811            Image::bmp(d.clone()).mime_type,
812        ] {
813            assert!(
814                SDK_IMAGE.contains(&m.as_str()),
815                "image constructor mime {m} not in SDK allowlist"
816            );
817        }
818        for m in [
819            Document::pdf(d.clone()).mime_type,
820            Document::json(d.clone()).mime_type,
821            Document::plain_text(d.clone()).mime_type,
822        ] {
823            assert!(
824                SDK_DOCUMENT.contains(&m.as_str()),
825                "document constructor mime {m} not in SDK allowlist"
826            );
827        }
828        for m in [
829            Audio::mp3(d.clone()).mime_type,
830            Audio::wav(d.clone()).mime_type,
831            Audio::ogg(d.clone()).mime_type,
832            Audio::flac(d.clone()).mime_type,
833        ] {
834            assert!(
835                SDK_AUDIO.contains(&m.as_str()),
836                "audio constructor mime {m} not in SDK allowlist"
837            );
838        }
839        for m in [
840            Video::mp4(d.clone()).mime_type,
841            Video::webm(d.clone()).mime_type,
842        ] {
843            assert!(
844                SDK_VIDEO.contains(&m.as_str()),
845                "video constructor mime {m} not in SDK allowlist"
846            );
847        }
848
849        // Every extension the bridge infers must map to an SDK-allowed MIME.
850        for ext in [
851            "png", "jpg", "jpeg", "bmp", "webp", "pdf", "txt", "json", "css", "csv", "html", "htm",
852            "js", "mjs", "rtf", "xml", "mp3", "wav", "ogg", "oga", "flac", "aac", "opus", "m4a",
853            "mp4", "m4v", "webm", "3gp", "3gpp", "avi", "mpeg", "mpg", "mov", "wmv", "flv",
854        ] {
855            let mime = mime::from_extension(ext).expect("known extension");
856            let allowed = SDK_IMAGE.contains(&mime)
857                || SDK_DOCUMENT.contains(&mime)
858                || SDK_AUDIO.contains(&mime)
859                || SDK_VIDEO.contains(&mime);
860            assert!(allowed, "extension .{ext} maps to non-SDK mime {mime}");
861        }
862    }
863
864    #[test]
865    fn audio_ogg_creates_correct_audio() {
866        let audio = Audio::ogg(vec![0x4F, 0x67]);
867        assert_eq!(audio.mime_type, "audio/ogg");
868        assert_eq!(audio.data, vec![0x4F, 0x67]);
869        assert!(audio.description.is_none());
870    }
871
872    #[test]
873    fn audio_flac_creates_correct_audio() {
874        let audio = Audio::flac(vec![0x66, 0x4C]);
875        assert_eq!(audio.mime_type, "audio/flac");
876        assert_eq!(audio.data, vec![0x66, 0x4C]);
877        assert!(audio.description.is_none());
878    }
879
880    #[test]
881    fn document_plain_text_creates_correct_document() {
882        let doc = Document::plain_text(b"hello".to_vec());
883        assert_eq!(doc.mime_type, "text/plain");
884        assert_eq!(doc.data, b"hello");
885        assert!(doc.description.is_none());
886    }
887
888    #[test]
889    fn document_json_creates_correct_document() {
890        let doc = Document::json(b"{}".to_vec());
891        assert_eq!(doc.mime_type, "application/json");
892        assert_eq!(doc.data, b"{}");
893        assert!(doc.description.is_none());
894    }
895
896    #[test]
897    fn video_webm_creates_correct_video() {
898        let video = Video::webm(vec![0x1A, 0x45]);
899        assert_eq!(video.mime_type, "video/webm");
900        assert_eq!(video.data, vec![0x1A, 0x45]);
901        assert!(video.description.is_none());
902    }
903
904    // ── from_file() — Image ────────────────────────────────────────
905
906    #[test]
907    fn image_from_file_success() {
908        let dir = tempfile::tempdir().unwrap();
909        let path = dir.path().join("photo.png");
910        std::fs::write(&path, b"\x89PNG").unwrap();
911        let img = Image::from_file(&path).unwrap();
912        assert_eq!(img.data, b"\x89PNG");
913        assert_eq!(img.mime_type, "image/png");
914        assert!(img.description.is_none());
915    }
916
917    #[test]
918    fn image_from_file_unknown_extension() {
919        let dir = tempfile::tempdir().unwrap();
920        let path = dir.path().join("photo.tiff");
921        std::fs::write(&path, b"II").unwrap();
922        let err = Image::from_file(&path).unwrap_err();
923        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
924        assert!(
925            err.to_string().contains("unrecognized"),
926            "expected 'unrecognized' in: {err}"
927        );
928    }
929
930    #[test]
931    fn image_from_file_wrong_mime_prefix() {
932        let dir = tempfile::tempdir().unwrap();
933        let path = dir.path().join("not_image.mp3");
934        std::fs::write(&path, b"\xFF\xFB").unwrap();
935        let err = Image::from_file(&path).unwrap_err();
936        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
937        assert!(
938            err.to_string().contains("not an image type"),
939            "expected MIME prefix error in: {err}"
940        );
941    }
942
943    #[test]
944    fn image_from_file_missing_extension() {
945        let dir = tempfile::tempdir().unwrap();
946        let path = dir.path().join("noext");
947        std::fs::write(&path, b"data").unwrap();
948        let err = Image::from_file(&path).unwrap_err();
949        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
950        assert!(
951            err.to_string().contains("missing file extension"),
952            "expected 'missing file extension' in: {err}"
953        );
954    }
955
956    // ── from_file() — Document ─────────────────────────────────────
957
958    #[test]
959    fn document_from_file_success() {
960        let dir = tempfile::tempdir().unwrap();
961        let path = dir.path().join("report.pdf");
962        std::fs::write(&path, b"%PDF-1.4").unwrap();
963        let doc = Document::from_file(&path).unwrap();
964        assert_eq!(doc.data, b"%PDF-1.4");
965        assert_eq!(doc.mime_type, "application/pdf");
966        assert!(doc.description.is_none());
967    }
968
969    #[test]
970    fn document_from_file_text_extension() {
971        let dir = tempfile::tempdir().unwrap();
972        let path = dir.path().join("notes.txt");
973        std::fs::write(&path, b"hello world").unwrap();
974        let doc = Document::from_file(&path).unwrap();
975        assert_eq!(doc.data, b"hello world");
976        assert_eq!(doc.mime_type, "text/plain");
977    }
978
979    #[test]
980    fn document_from_file_json_extension() {
981        let dir = tempfile::tempdir().unwrap();
982        let path = dir.path().join("config.json");
983        std::fs::write(&path, b"{}").unwrap();
984        let doc = Document::from_file(&path).unwrap();
985        assert_eq!(doc.data, b"{}");
986        assert_eq!(doc.mime_type, "application/json");
987    }
988
989    #[test]
990    fn document_from_file_unknown_extension() {
991        let dir = tempfile::tempdir().unwrap();
992        let path = dir.path().join("data.xyz");
993        std::fs::write(&path, b"stuff").unwrap();
994        let err = Document::from_file(&path).unwrap_err();
995        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
996        assert!(
997            err.to_string().contains("unrecognized"),
998            "expected 'unrecognized' in: {err}"
999        );
1000    }
1001
1002    #[test]
1003    fn document_from_file_wrong_mime_prefix() {
1004        let dir = tempfile::tempdir().unwrap();
1005        let path = dir.path().join("not_doc.png");
1006        std::fs::write(&path, b"\x89PNG").unwrap();
1007        let err = Document::from_file(&path).unwrap_err();
1008        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1009        assert!(
1010            err.to_string().contains("not a document type"),
1011            "expected MIME prefix error in: {err}"
1012        );
1013    }
1014
1015    #[test]
1016    fn document_from_file_missing_extension() {
1017        let dir = tempfile::tempdir().unwrap();
1018        let path = dir.path().join("noext");
1019        std::fs::write(&path, b"data").unwrap();
1020        let err = Document::from_file(&path).unwrap_err();
1021        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1022        assert!(
1023            err.to_string().contains("missing file extension"),
1024            "expected 'missing file extension' in: {err}"
1025        );
1026    }
1027
1028    // ── from_file() — Audio ────────────────────────────────────────
1029
1030    #[test]
1031    fn audio_from_file_success() {
1032        let dir = tempfile::tempdir().unwrap();
1033        let path = dir.path().join("clip.mp3");
1034        std::fs::write(&path, b"\xFF\xFB\x90").unwrap();
1035        let audio = Audio::from_file(&path).unwrap();
1036        assert_eq!(audio.data, b"\xFF\xFB\x90");
1037        assert_eq!(audio.mime_type, "audio/mpeg");
1038        assert!(audio.description.is_none());
1039    }
1040
1041    #[test]
1042    fn audio_from_file_wav_extension() {
1043        let dir = tempfile::tempdir().unwrap();
1044        let path = dir.path().join("sample.wav");
1045        std::fs::write(&path, b"RIFF").unwrap();
1046        let audio = Audio::from_file(&path).unwrap();
1047        assert_eq!(audio.mime_type, "audio/wav");
1048    }
1049
1050    #[test]
1051    fn audio_from_file_unknown_extension() {
1052        let dir = tempfile::tempdir().unwrap();
1053        let path = dir.path().join("sound.mid");
1054        std::fs::write(&path, b"data").unwrap();
1055        let err = Audio::from_file(&path).unwrap_err();
1056        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1057        assert!(
1058            err.to_string().contains("unrecognized"),
1059            "expected 'unrecognized' in: {err}"
1060        );
1061    }
1062
1063    #[test]
1064    fn audio_from_file_wrong_mime_prefix() {
1065        let dir = tempfile::tempdir().unwrap();
1066        let path = dir.path().join("not_audio.png");
1067        std::fs::write(&path, b"\x89PNG").unwrap();
1068        let err = Audio::from_file(&path).unwrap_err();
1069        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1070        assert!(
1071            err.to_string().contains("not an audio type"),
1072            "expected MIME prefix error in: {err}"
1073        );
1074    }
1075
1076    #[test]
1077    fn audio_from_file_missing_extension() {
1078        let dir = tempfile::tempdir().unwrap();
1079        let path = dir.path().join("noext");
1080        std::fs::write(&path, b"data").unwrap();
1081        let err = Audio::from_file(&path).unwrap_err();
1082        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1083        assert!(
1084            err.to_string().contains("missing file extension"),
1085            "expected 'missing file extension' in: {err}"
1086        );
1087    }
1088
1089    // ── from_file() — Video ────────────────────────────────────────
1090
1091    #[test]
1092    fn video_from_file_success() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let path = dir.path().join("clip.mp4");
1095        std::fs::write(&path, b"\x00\x00\x00\x1Cftyp").unwrap();
1096        let video = Video::from_file(&path).unwrap();
1097        assert_eq!(video.data, b"\x00\x00\x00\x1Cftyp");
1098        assert_eq!(video.mime_type, "video/mp4");
1099        assert!(video.description.is_none());
1100    }
1101
1102    #[test]
1103    fn video_from_file_webm_extension() {
1104        let dir = tempfile::tempdir().unwrap();
1105        let path = dir.path().join("clip.webm");
1106        std::fs::write(&path, b"\x1A\x45\xDF\xA3").unwrap();
1107        let video = Video::from_file(&path).unwrap();
1108        assert_eq!(video.mime_type, "video/webm");
1109    }
1110
1111    #[test]
1112    fn video_from_file_unknown_extension() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let path = dir.path().join("movie.mkv");
1115        std::fs::write(&path, b"RIFF").unwrap();
1116        let err = Video::from_file(&path).unwrap_err();
1117        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1118        assert!(
1119            err.to_string().contains("unrecognized"),
1120            "expected 'unrecognized' in: {err}"
1121        );
1122    }
1123
1124    #[test]
1125    fn video_from_file_wrong_mime_prefix() {
1126        let dir = tempfile::tempdir().unwrap();
1127        let path = dir.path().join("not_video.png");
1128        std::fs::write(&path, b"\x89PNG").unwrap();
1129        let err = Video::from_file(&path).unwrap_err();
1130        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1131        assert!(
1132            err.to_string().contains("not a video type"),
1133            "expected MIME prefix error in: {err}"
1134        );
1135    }
1136
1137    #[test]
1138    fn video_from_file_missing_extension() {
1139        let dir = tempfile::tempdir().unwrap();
1140        let path = dir.path().join("noext");
1141        std::fs::write(&path, b"data").unwrap();
1142        let err = Video::from_file(&path).unwrap_err();
1143        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1144        assert!(
1145            err.to_string().contains("missing file extension"),
1146            "expected 'missing file extension' in: {err}"
1147        );
1148    }
1149
1150    // ── from_file() — file does not exist ──────────────────────────
1151
1152    #[test]
1153    fn image_from_file_nonexistent_file() {
1154        let err = Image::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.png").unwrap_err();
1155        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1156    }
1157
1158    #[test]
1159    fn document_from_file_nonexistent_file() {
1160        let err = Document::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.pdf").unwrap_err();
1161        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1162    }
1163
1164    #[test]
1165    fn audio_from_file_nonexistent_file() {
1166        let err = Audio::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.mp3").unwrap_err();
1167        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1168    }
1169
1170    #[test]
1171    fn video_from_file_nonexistent_file() {
1172        let err = Video::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.mp4").unwrap_err();
1173        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1174    }
1175
1176    // ── mime::from_extension ────────────────────────────────────────
1177
1178    #[test]
1179    fn mime_from_extension_case_insensitive() {
1180        assert_eq!(mime::from_extension("PNG"), Some("image/png"));
1181        assert_eq!(mime::from_extension("Jpeg"), Some("image/jpeg"));
1182        assert_eq!(mime::from_extension("MP4"), Some("video/mp4"));
1183    }
1184
1185    #[test]
1186    fn mime_from_extension_unknown_returns_none() {
1187        assert_eq!(mime::from_extension("tiff"), None);
1188        assert_eq!(mime::from_extension("tga"), None);
1189        assert_eq!(mime::from_extension(""), None);
1190    }
1191}