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 GIF images.
155    pub const IMAGE_GIF: &str = "image/gif";
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
166    /// MIME type for MP3 audio.
167    pub const AUDIO_MPEG: &str = "audio/mpeg";
168    /// MIME type for WAV audio.
169    pub const AUDIO_WAV: &str = "audio/wav";
170    /// MIME type for OGG audio.
171    pub const AUDIO_OGG: &str = "audio/ogg";
172    /// MIME type for FLAC audio.
173    pub const AUDIO_FLAC: &str = "audio/flac";
174
175    /// MIME type for MP4 video.
176    pub const VIDEO_MP4: &str = "video/mp4";
177    /// MIME type for `WebM` video.
178    pub const VIDEO_WEBM: &str = "video/webm";
179
180    /// Infer a MIME type from a file extension.
181    ///
182    /// Returns `None` if the extension is unrecognized.
183    #[must_use]
184    pub fn from_extension(ext: &str) -> Option<&'static str> {
185        match ext.to_ascii_lowercase().as_str() {
186            "png" => Some(IMAGE_PNG),
187            "jpg" | "jpeg" => Some(IMAGE_JPEG),
188            "gif" => Some(IMAGE_GIF),
189            "webp" => Some(IMAGE_WEBP),
190            "pdf" => Some(APPLICATION_PDF),
191            "txt" => Some(TEXT_PLAIN),
192            "json" => Some(APPLICATION_JSON),
193            "mp3" => Some(AUDIO_MPEG),
194            "wav" => Some(AUDIO_WAV),
195            "ogg" => Some(AUDIO_OGG),
196            "flac" => Some(AUDIO_FLAC),
197            "mp4" => Some(VIDEO_MP4),
198            "webm" => Some(VIDEO_WEBM),
199            _ => None,
200        }
201    }
202}
203
204/// Shared implementation for loading media from a file path.
205///
206/// Extracts the file extension, looks up the MIME type, validates the prefix,
207/// reads the file, and returns `(data, mime_type)`.
208fn from_file_inner(
209    path: &std::path::Path,
210    type_label: &str,
211    mime_prefixes: &[&str],
212) -> std::io::Result<(Vec<u8>, String)> {
213    let ext = path.extension().and_then(|e| e.to_str()).ok_or_else(|| {
214        std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing file extension")
215    })?;
216    let mime_type = mime::from_extension(ext).ok_or_else(|| {
217        std::io::Error::new(
218            std::io::ErrorKind::InvalidInput,
219            format!("unrecognized {type_label} extension: {ext}"),
220        )
221    })?;
222    if !mime_prefixes
223        .iter()
224        .any(|prefix| mime_type.starts_with(prefix))
225    {
226        return Err(std::io::Error::new(
227            std::io::ErrorKind::InvalidInput,
228            format!("MIME type '{mime_type}' is not {type_label} type"),
229        ));
230    }
231    let data = std::fs::read(path)?;
232    Ok((data, mime_type.to_owned()))
233}
234
235// =============================================================================
236// Media convenience constructors
237// =============================================================================
238
239impl Image {
240    /// Creates a new [`Image`] with the given data and MIME type.
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// # use agy_bridge::content::Image;
246    /// let img = Image::new(vec![0x89, 0x50], "image/png");
247    /// assert_eq!(img.mime_type, "image/png");
248    /// assert_eq!(img.data, vec![0x89, 0x50]);
249    /// assert!(img.description.is_none());
250    /// ```
251    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
252        Self {
253            data,
254            mime_type: mime_type.into(),
255            description: None,
256        }
257    }
258
259    /// Creates a new [`Image`] with MIME type `image/png`.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// # use agy_bridge::content::Image;
265    /// let img = Image::png(vec![1, 2, 3]);
266    /// assert_eq!(img.mime_type, "image/png");
267    /// assert_eq!(img.data, vec![1, 2, 3]);
268    /// ```
269    #[must_use]
270    pub fn png(data: Vec<u8>) -> Self {
271        Self::new(data, mime::IMAGE_PNG)
272    }
273
274    /// Creates a new [`Image`] with MIME type `image/jpeg`.
275    ///
276    /// # Examples
277    ///
278    /// ```
279    /// # use agy_bridge::content::Image;
280    /// let img = Image::jpeg(vec![0xFF, 0xD8]);
281    /// assert_eq!(img.mime_type, "image/jpeg");
282    /// ```
283    #[must_use]
284    pub fn jpeg(data: Vec<u8>) -> Self {
285        Self::new(data, mime::IMAGE_JPEG)
286    }
287
288    /// Creates a new [`Image`] with MIME type `image/webp`.
289    #[must_use]
290    pub fn webp(data: Vec<u8>) -> Self {
291        Self::new(data, mime::IMAGE_WEBP)
292    }
293
294    /// Creates a new [`Image`] with MIME type `image/gif`.
295    #[must_use]
296    pub fn gif(data: Vec<u8>) -> Self {
297        Self::new(data, mime::IMAGE_GIF)
298    }
299
300    /// Sets a description on this image, consuming and returning `self`.
301    #[must_use]
302    pub fn with_description(mut self, description: impl Into<String>) -> Self {
303        self.description = Some(description.into());
304        self
305    }
306
307    /// Load an image from a file path, inferring the MIME type from the extension.
308    ///
309    /// # Errors
310    ///
311    /// Returns `std::io::Error` if the file cannot be read or the extension
312    /// is unrecognized.
313    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
314        let (data, mime_type) = from_file_inner(path.as_ref(), "an image", &["image/"])?;
315        Ok(Self::new(data, mime_type))
316    }
317}
318
319impl Document {
320    /// Creates a new [`Document`] with the given data and MIME type.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// # use agy_bridge::content::Document;
326    /// let doc = Document::new(b"%PDF".to_vec(), "application/pdf");
327    /// assert_eq!(doc.mime_type, "application/pdf");
328    /// assert!(doc.description.is_none());
329    /// ```
330    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
331        Self {
332            data,
333            mime_type: mime_type.into(),
334            description: None,
335        }
336    }
337
338    /// Creates a new [`Document`] with MIME type `application/pdf`.
339    ///
340    /// # Examples
341    ///
342    /// ```
343    /// # use agy_bridge::content::Document;
344    /// let doc = Document::pdf(b"%PDF-1.4".to_vec());
345    /// assert_eq!(doc.mime_type, "application/pdf");
346    /// ```
347    #[must_use]
348    pub fn pdf(data: Vec<u8>) -> Self {
349        Self::new(data, mime::APPLICATION_PDF)
350    }
351
352    /// Creates a new [`Document`] with MIME type `text/plain`.
353    #[must_use]
354    pub fn plain_text(data: Vec<u8>) -> Self {
355        Self::new(data, mime::TEXT_PLAIN)
356    }
357
358    /// Creates a new [`Document`] with MIME type `application/json`.
359    #[must_use]
360    pub fn json(data: Vec<u8>) -> Self {
361        Self::new(data, mime::APPLICATION_JSON)
362    }
363
364    /// Sets a description on this document, consuming and returning `self`.
365    #[must_use]
366    pub fn with_description(mut self, description: impl Into<String>) -> Self {
367        self.description = Some(description.into());
368        self
369    }
370
371    /// Load a document from a file path, inferring the MIME type from the extension.
372    ///
373    /// # Errors
374    ///
375    /// Returns `std::io::Error` if the file cannot be read or the extension
376    /// is unrecognized.
377    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
378        let (data, mime_type) =
379            from_file_inner(path.as_ref(), "a document", &["application/", "text/"])?;
380        Ok(Self::new(data, mime_type))
381    }
382}
383
384impl Audio {
385    /// Creates a new [`Audio`] with the given data and MIME type.
386    ///
387    /// # Examples
388    ///
389    /// ```
390    /// # use agy_bridge::content::Audio;
391    /// let audio = Audio::new(vec![0xFF, 0xFB], "audio/mpeg");
392    /// assert_eq!(audio.mime_type, "audio/mpeg");
393    /// assert!(audio.description.is_none());
394    /// ```
395    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
396        Self {
397            data,
398            mime_type: mime_type.into(),
399            description: None,
400        }
401    }
402
403    /// Creates a new [`Audio`] with MIME type `audio/mpeg` (MP3).
404    ///
405    /// # Examples
406    ///
407    /// ```
408    /// # use agy_bridge::content::Audio;
409    /// let audio = Audio::mp3(vec![0xFF, 0xFB]);
410    /// assert_eq!(audio.mime_type, "audio/mpeg");
411    /// ```
412    #[must_use]
413    pub fn mp3(data: Vec<u8>) -> Self {
414        Self::new(data, mime::AUDIO_MPEG)
415    }
416
417    /// Creates a new [`Audio`] with MIME type `audio/wav`.
418    ///
419    /// # Examples
420    ///
421    /// ```
422    /// # use agy_bridge::content::Audio;
423    /// let audio = Audio::wav(vec![0x52, 0x49, 0x46, 0x46]);
424    /// assert_eq!(audio.mime_type, "audio/wav");
425    /// ```
426    #[must_use]
427    pub fn wav(data: Vec<u8>) -> Self {
428        Self::new(data, mime::AUDIO_WAV)
429    }
430
431    /// Creates a new [`Audio`] with MIME type `audio/ogg`.
432    #[must_use]
433    pub fn ogg(data: Vec<u8>) -> Self {
434        Self::new(data, mime::AUDIO_OGG)
435    }
436
437    /// Creates a new [`Audio`] with MIME type `audio/flac`.
438    #[must_use]
439    pub fn flac(data: Vec<u8>) -> Self {
440        Self::new(data, mime::AUDIO_FLAC)
441    }
442
443    /// Sets a description on this audio, consuming and returning `self`.
444    #[must_use]
445    pub fn with_description(mut self, description: impl Into<String>) -> Self {
446        self.description = Some(description.into());
447        self
448    }
449
450    /// Load audio from a file path, inferring the MIME type from the extension.
451    ///
452    /// # Errors
453    ///
454    /// Returns `std::io::Error` if the file cannot be read or the extension
455    /// is unrecognized.
456    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
457        let (data, mime_type) = from_file_inner(path.as_ref(), "an audio", &["audio/"])?;
458        Ok(Self::new(data, mime_type))
459    }
460}
461
462impl Video {
463    /// Creates a new [`Video`] with the given data and MIME type.
464    ///
465    /// # Examples
466    ///
467    /// ```
468    /// # use agy_bridge::content::Video;
469    /// let video = Video::new(vec![0x00, 0x00], "video/mp4");
470    /// assert_eq!(video.mime_type, "video/mp4");
471    /// assert!(video.description.is_none());
472    /// ```
473    pub fn new(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
474        Self {
475            data,
476            mime_type: mime_type.into(),
477            description: None,
478        }
479    }
480
481    /// Creates a new [`Video`] with MIME type `video/mp4`.
482    ///
483    /// # Examples
484    ///
485    /// ```
486    /// # use agy_bridge::content::Video;
487    /// let video = Video::mp4(vec![0x00, 0x00, 0x00, 0x1C]);
488    /// assert_eq!(video.mime_type, "video/mp4");
489    /// ```
490    #[must_use]
491    pub fn mp4(data: Vec<u8>) -> Self {
492        Self::new(data, mime::VIDEO_MP4)
493    }
494
495    /// Creates a new [`Video`] with MIME type `video/webm`.
496    #[must_use]
497    pub fn webm(data: Vec<u8>) -> Self {
498        Self::new(data, mime::VIDEO_WEBM)
499    }
500
501    /// Sets a description on this video, consuming and returning `self`.
502    #[must_use]
503    pub fn with_description(mut self, description: impl Into<String>) -> Self {
504        self.description = Some(description.into());
505        self
506    }
507
508    /// Load video from a file path, inferring the MIME type from the extension.
509    ///
510    /// # Errors
511    ///
512    /// Returns `std::io::Error` if the file cannot be read or the extension
513    /// is unrecognized.
514    pub fn from_file(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
515        let (data, mime_type) = from_file_inner(path.as_ref(), "a video", &["video/"])?;
516        Ok(Self::new(data, mime_type))
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn image_struct_serde_roundtrip() {
526        let img = Image {
527            data: vec![10, 20, 30],
528            mime_type: "image/bmp".to_string(),
529            description: Some("bitmap".to_string()),
530        };
531        let json = serde_json::to_string(&img).unwrap();
532        let parsed: Image = serde_json::from_str(&json).unwrap();
533        assert_eq!(parsed, img);
534    }
535
536    #[test]
537    fn document_struct_serde_roundtrip() {
538        let doc = Document {
539            data: b"{}".to_vec(),
540            mime_type: "application/json".to_string(),
541            description: None,
542        };
543        let json = serde_json::to_string(&doc).unwrap();
544        let parsed: Document = serde_json::from_str(&json).unwrap();
545        assert_eq!(parsed, doc);
546    }
547
548    #[test]
549    fn audio_struct_serde_roundtrip() {
550        let audio = Audio {
551            data: vec![0xAA, 0xBB],
552            mime_type: "audio/wav".to_string(),
553            description: Some("beep".to_string()),
554        };
555        let json = serde_json::to_string(&audio).unwrap();
556        let parsed: Audio = serde_json::from_str(&json).unwrap();
557        assert_eq!(parsed, audio);
558    }
559
560    #[test]
561    fn video_struct_serde_roundtrip() {
562        let video = Video {
563            data: vec![0xCC, 0xDD, 0xEE],
564            mime_type: "video/webm".to_string(),
565            description: None,
566        };
567        let json = serde_json::to_string(&video).unwrap();
568        let parsed: Video = serde_json::from_str(&json).unwrap();
569        assert_eq!(parsed, video);
570    }
571
572    #[test]
573    fn image_description_defaults_to_none() {
574        let json = r#"{"data":[1,2,3],"mime_type":"image/png"}"#;
575        let img: Image = serde_json::from_str(json).unwrap();
576        assert!(img.description.is_none());
577    }
578
579    #[test]
580    fn image_new_creates_correct_image() {
581        let img = Image::new(vec![10, 20], "image/webp");
582        assert_eq!(img.data, vec![10, 20]);
583        assert_eq!(img.mime_type, "image/webp");
584        assert!(img.description.is_none());
585    }
586
587    #[test]
588    fn image_png_creates_correct_image() {
589        let img = Image::png(vec![1, 2, 3]);
590        assert_eq!(img.data, vec![1, 2, 3]);
591        assert_eq!(img.mime_type, "image/png");
592        assert!(img.description.is_none());
593    }
594
595    #[test]
596    fn image_jpeg_creates_correct_image() {
597        let img = Image::jpeg(vec![0xFF, 0xD8]);
598        assert_eq!(img.data, vec![0xFF, 0xD8]);
599        assert_eq!(img.mime_type, "image/jpeg");
600        assert!(img.description.is_none());
601    }
602
603    #[test]
604    fn document_new_creates_correct_document() {
605        let doc = Document::new(b"data".to_vec(), "text/plain");
606        assert_eq!(doc.data, b"data".to_vec());
607        assert_eq!(doc.mime_type, "text/plain");
608        assert!(doc.description.is_none());
609    }
610
611    #[test]
612    fn document_pdf_creates_correct_document() {
613        let doc = Document::pdf(b"%PDF-1.4".to_vec());
614        assert_eq!(doc.data, b"%PDF-1.4".to_vec());
615        assert_eq!(doc.mime_type, "application/pdf");
616        assert!(doc.description.is_none());
617    }
618
619    #[test]
620    fn audio_new_creates_correct_audio() {
621        let audio = Audio::new(vec![0xAA], "audio/ogg");
622        assert_eq!(audio.data, vec![0xAA]);
623        assert_eq!(audio.mime_type, "audio/ogg");
624        assert!(audio.description.is_none());
625    }
626
627    #[test]
628    fn audio_mp3_creates_correct_audio() {
629        let audio = Audio::mp3(vec![0xFF, 0xFB]);
630        assert_eq!(audio.data, vec![0xFF, 0xFB]);
631        assert_eq!(audio.mime_type, "audio/mpeg");
632        assert!(audio.description.is_none());
633    }
634
635    #[test]
636    fn audio_wav_creates_correct_audio() {
637        let audio = Audio::wav(vec![0x52, 0x49, 0x46, 0x46]);
638        assert_eq!(audio.data, vec![0x52, 0x49, 0x46, 0x46]);
639        assert_eq!(audio.mime_type, "audio/wav");
640        assert!(audio.description.is_none());
641    }
642
643    #[test]
644    fn video_new_creates_correct_video() {
645        let video = Video::new(vec![0x00], "video/webm");
646        assert_eq!(video.data, vec![0x00]);
647        assert_eq!(video.mime_type, "video/webm");
648        assert!(video.description.is_none());
649    }
650
651    #[test]
652    fn video_mp4_creates_correct_video() {
653        let video = Video::mp4(vec![0x00, 0x00, 0x00, 0x1C]);
654        assert_eq!(video.data, vec![0x00, 0x00, 0x00, 0x1C]);
655        assert_eq!(video.mime_type, "video/mp4");
656        assert!(video.description.is_none());
657    }
658
659    #[test]
660    fn image_new_accepts_string_type() {
661        let img = Image::new(vec![1], String::from("image/gif"));
662        assert_eq!(img.mime_type, "image/gif");
663    }
664
665    // ── with_description() builder ──────────────────────────────────
666
667    #[test]
668    fn image_with_description_sets_description() {
669        let img = Image::png(vec![1]).with_description("a logo");
670        assert_eq!(img.description.as_deref(), Some("a logo"));
671        assert_eq!(img.mime_type, "image/png");
672    }
673
674    #[test]
675    fn document_with_description_sets_description() {
676        let doc = Document::pdf(vec![1]).with_description("invoice");
677        assert_eq!(doc.description.as_deref(), Some("invoice"));
678        assert_eq!(doc.mime_type, "application/pdf");
679    }
680
681    #[test]
682    fn audio_with_description_sets_description() {
683        let audio = Audio::mp3(vec![1]).with_description("intro jingle");
684        assert_eq!(audio.description.as_deref(), Some("intro jingle"));
685        assert_eq!(audio.mime_type, "audio/mpeg");
686    }
687
688    #[test]
689    fn video_with_description_sets_description() {
690        let video = Video::mp4(vec![1]).with_description("demo clip");
691        assert_eq!(video.description.as_deref(), Some("demo clip"));
692        assert_eq!(video.mime_type, "video/mp4");
693    }
694
695    // ── Convenience constructors ────────────────────────────────────
696
697    #[test]
698    fn image_webp_creates_correct_image() {
699        let img = Image::webp(vec![1, 2]);
700        assert_eq!(img.mime_type, "image/webp");
701        assert_eq!(img.data, vec![1, 2]);
702        assert!(img.description.is_none());
703    }
704
705    #[test]
706    fn image_gif_creates_correct_image() {
707        let img = Image::gif(vec![0x47, 0x49, 0x46]);
708        assert_eq!(img.mime_type, "image/gif");
709        assert_eq!(img.data, vec![0x47, 0x49, 0x46]);
710        assert!(img.description.is_none());
711    }
712
713    #[test]
714    fn audio_ogg_creates_correct_audio() {
715        let audio = Audio::ogg(vec![0x4F, 0x67]);
716        assert_eq!(audio.mime_type, "audio/ogg");
717        assert_eq!(audio.data, vec![0x4F, 0x67]);
718        assert!(audio.description.is_none());
719    }
720
721    #[test]
722    fn audio_flac_creates_correct_audio() {
723        let audio = Audio::flac(vec![0x66, 0x4C]);
724        assert_eq!(audio.mime_type, "audio/flac");
725        assert_eq!(audio.data, vec![0x66, 0x4C]);
726        assert!(audio.description.is_none());
727    }
728
729    #[test]
730    fn document_plain_text_creates_correct_document() {
731        let doc = Document::plain_text(b"hello".to_vec());
732        assert_eq!(doc.mime_type, "text/plain");
733        assert_eq!(doc.data, b"hello");
734        assert!(doc.description.is_none());
735    }
736
737    #[test]
738    fn document_json_creates_correct_document() {
739        let doc = Document::json(b"{}".to_vec());
740        assert_eq!(doc.mime_type, "application/json");
741        assert_eq!(doc.data, b"{}");
742        assert!(doc.description.is_none());
743    }
744
745    #[test]
746    fn video_webm_creates_correct_video() {
747        let video = Video::webm(vec![0x1A, 0x45]);
748        assert_eq!(video.mime_type, "video/webm");
749        assert_eq!(video.data, vec![0x1A, 0x45]);
750        assert!(video.description.is_none());
751    }
752
753    // ── from_file() — Image ────────────────────────────────────────
754
755    #[test]
756    fn image_from_file_success() {
757        let dir = tempfile::tempdir().unwrap();
758        let path = dir.path().join("photo.png");
759        std::fs::write(&path, b"\x89PNG").unwrap();
760        let img = Image::from_file(&path).unwrap();
761        assert_eq!(img.data, b"\x89PNG");
762        assert_eq!(img.mime_type, "image/png");
763        assert!(img.description.is_none());
764    }
765
766    #[test]
767    fn image_from_file_unknown_extension() {
768        let dir = tempfile::tempdir().unwrap();
769        let path = dir.path().join("photo.bmp");
770        std::fs::write(&path, b"BM").unwrap();
771        let err = Image::from_file(&path).unwrap_err();
772        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
773        assert!(
774            err.to_string().contains("unrecognized"),
775            "expected 'unrecognized' in: {err}"
776        );
777    }
778
779    #[test]
780    fn image_from_file_wrong_mime_prefix() {
781        let dir = tempfile::tempdir().unwrap();
782        let path = dir.path().join("not_image.mp3");
783        std::fs::write(&path, b"\xFF\xFB").unwrap();
784        let err = Image::from_file(&path).unwrap_err();
785        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
786        assert!(
787            err.to_string().contains("not an image type"),
788            "expected MIME prefix error in: {err}"
789        );
790    }
791
792    #[test]
793    fn image_from_file_missing_extension() {
794        let dir = tempfile::tempdir().unwrap();
795        let path = dir.path().join("noext");
796        std::fs::write(&path, b"data").unwrap();
797        let err = Image::from_file(&path).unwrap_err();
798        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
799        assert!(
800            err.to_string().contains("missing file extension"),
801            "expected 'missing file extension' in: {err}"
802        );
803    }
804
805    // ── from_file() — Document ─────────────────────────────────────
806
807    #[test]
808    fn document_from_file_success() {
809        let dir = tempfile::tempdir().unwrap();
810        let path = dir.path().join("report.pdf");
811        std::fs::write(&path, b"%PDF-1.4").unwrap();
812        let doc = Document::from_file(&path).unwrap();
813        assert_eq!(doc.data, b"%PDF-1.4");
814        assert_eq!(doc.mime_type, "application/pdf");
815        assert!(doc.description.is_none());
816    }
817
818    #[test]
819    fn document_from_file_text_extension() {
820        let dir = tempfile::tempdir().unwrap();
821        let path = dir.path().join("notes.txt");
822        std::fs::write(&path, b"hello world").unwrap();
823        let doc = Document::from_file(&path).unwrap();
824        assert_eq!(doc.data, b"hello world");
825        assert_eq!(doc.mime_type, "text/plain");
826    }
827
828    #[test]
829    fn document_from_file_json_extension() {
830        let dir = tempfile::tempdir().unwrap();
831        let path = dir.path().join("config.json");
832        std::fs::write(&path, b"{}").unwrap();
833        let doc = Document::from_file(&path).unwrap();
834        assert_eq!(doc.data, b"{}");
835        assert_eq!(doc.mime_type, "application/json");
836    }
837
838    #[test]
839    fn document_from_file_unknown_extension() {
840        let dir = tempfile::tempdir().unwrap();
841        let path = dir.path().join("data.xyz");
842        std::fs::write(&path, b"stuff").unwrap();
843        let err = Document::from_file(&path).unwrap_err();
844        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
845        assert!(
846            err.to_string().contains("unrecognized"),
847            "expected 'unrecognized' in: {err}"
848        );
849    }
850
851    #[test]
852    fn document_from_file_wrong_mime_prefix() {
853        let dir = tempfile::tempdir().unwrap();
854        let path = dir.path().join("not_doc.png");
855        std::fs::write(&path, b"\x89PNG").unwrap();
856        let err = Document::from_file(&path).unwrap_err();
857        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
858        assert!(
859            err.to_string().contains("not a document type"),
860            "expected MIME prefix error in: {err}"
861        );
862    }
863
864    #[test]
865    fn document_from_file_missing_extension() {
866        let dir = tempfile::tempdir().unwrap();
867        let path = dir.path().join("noext");
868        std::fs::write(&path, b"data").unwrap();
869        let err = Document::from_file(&path).unwrap_err();
870        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
871        assert!(
872            err.to_string().contains("missing file extension"),
873            "expected 'missing file extension' in: {err}"
874        );
875    }
876
877    // ── from_file() — Audio ────────────────────────────────────────
878
879    #[test]
880    fn audio_from_file_success() {
881        let dir = tempfile::tempdir().unwrap();
882        let path = dir.path().join("clip.mp3");
883        std::fs::write(&path, b"\xFF\xFB\x90").unwrap();
884        let audio = Audio::from_file(&path).unwrap();
885        assert_eq!(audio.data, b"\xFF\xFB\x90");
886        assert_eq!(audio.mime_type, "audio/mpeg");
887        assert!(audio.description.is_none());
888    }
889
890    #[test]
891    fn audio_from_file_wav_extension() {
892        let dir = tempfile::tempdir().unwrap();
893        let path = dir.path().join("sample.wav");
894        std::fs::write(&path, b"RIFF").unwrap();
895        let audio = Audio::from_file(&path).unwrap();
896        assert_eq!(audio.mime_type, "audio/wav");
897    }
898
899    #[test]
900    fn audio_from_file_unknown_extension() {
901        let dir = tempfile::tempdir().unwrap();
902        let path = dir.path().join("sound.aac");
903        std::fs::write(&path, b"data").unwrap();
904        let err = Audio::from_file(&path).unwrap_err();
905        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
906        assert!(
907            err.to_string().contains("unrecognized"),
908            "expected 'unrecognized' in: {err}"
909        );
910    }
911
912    #[test]
913    fn audio_from_file_wrong_mime_prefix() {
914        let dir = tempfile::tempdir().unwrap();
915        let path = dir.path().join("not_audio.png");
916        std::fs::write(&path, b"\x89PNG").unwrap();
917        let err = Audio::from_file(&path).unwrap_err();
918        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
919        assert!(
920            err.to_string().contains("not an audio type"),
921            "expected MIME prefix error in: {err}"
922        );
923    }
924
925    #[test]
926    fn audio_from_file_missing_extension() {
927        let dir = tempfile::tempdir().unwrap();
928        let path = dir.path().join("noext");
929        std::fs::write(&path, b"data").unwrap();
930        let err = Audio::from_file(&path).unwrap_err();
931        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
932        assert!(
933            err.to_string().contains("missing file extension"),
934            "expected 'missing file extension' in: {err}"
935        );
936    }
937
938    // ── from_file() — Video ────────────────────────────────────────
939
940    #[test]
941    fn video_from_file_success() {
942        let dir = tempfile::tempdir().unwrap();
943        let path = dir.path().join("clip.mp4");
944        std::fs::write(&path, b"\x00\x00\x00\x1Cftyp").unwrap();
945        let video = Video::from_file(&path).unwrap();
946        assert_eq!(video.data, b"\x00\x00\x00\x1Cftyp");
947        assert_eq!(video.mime_type, "video/mp4");
948        assert!(video.description.is_none());
949    }
950
951    #[test]
952    fn video_from_file_webm_extension() {
953        let dir = tempfile::tempdir().unwrap();
954        let path = dir.path().join("clip.webm");
955        std::fs::write(&path, b"\x1A\x45\xDF\xA3").unwrap();
956        let video = Video::from_file(&path).unwrap();
957        assert_eq!(video.mime_type, "video/webm");
958    }
959
960    #[test]
961    fn video_from_file_unknown_extension() {
962        let dir = tempfile::tempdir().unwrap();
963        let path = dir.path().join("movie.avi");
964        std::fs::write(&path, b"RIFF").unwrap();
965        let err = Video::from_file(&path).unwrap_err();
966        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
967        assert!(
968            err.to_string().contains("unrecognized"),
969            "expected 'unrecognized' in: {err}"
970        );
971    }
972
973    #[test]
974    fn video_from_file_wrong_mime_prefix() {
975        let dir = tempfile::tempdir().unwrap();
976        let path = dir.path().join("not_video.png");
977        std::fs::write(&path, b"\x89PNG").unwrap();
978        let err = Video::from_file(&path).unwrap_err();
979        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
980        assert!(
981            err.to_string().contains("not a video type"),
982            "expected MIME prefix error in: {err}"
983        );
984    }
985
986    #[test]
987    fn video_from_file_missing_extension() {
988        let dir = tempfile::tempdir().unwrap();
989        let path = dir.path().join("noext");
990        std::fs::write(&path, b"data").unwrap();
991        let err = Video::from_file(&path).unwrap_err();
992        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
993        assert!(
994            err.to_string().contains("missing file extension"),
995            "expected 'missing file extension' in: {err}"
996        );
997    }
998
999    // ── from_file() — file does not exist ──────────────────────────
1000
1001    #[test]
1002    fn image_from_file_nonexistent_file() {
1003        let err = Image::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.png").unwrap_err();
1004        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1005    }
1006
1007    #[test]
1008    fn document_from_file_nonexistent_file() {
1009        let err = Document::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.pdf").unwrap_err();
1010        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1011    }
1012
1013    #[test]
1014    fn audio_from_file_nonexistent_file() {
1015        let err = Audio::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.mp3").unwrap_err();
1016        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1017    }
1018
1019    #[test]
1020    fn video_from_file_nonexistent_file() {
1021        let err = Video::from_file("/tmp/agy_bridge_test_nonexistent_8f3a.mp4").unwrap_err();
1022        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1023    }
1024
1025    // ── mime::from_extension ────────────────────────────────────────
1026
1027    #[test]
1028    fn mime_from_extension_case_insensitive() {
1029        assert_eq!(mime::from_extension("PNG"), Some("image/png"));
1030        assert_eq!(mime::from_extension("Jpeg"), Some("image/jpeg"));
1031        assert_eq!(mime::from_extension("MP4"), Some("video/mp4"));
1032    }
1033
1034    #[test]
1035    fn mime_from_extension_unknown_returns_none() {
1036        assert_eq!(mime::from_extension("bmp"), None);
1037        assert_eq!(mime::from_extension("avi"), None);
1038        assert_eq!(mime::from_extension(""), None);
1039    }
1040}