zoe_app_primitives/file/
image.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{FileRef, Metadata};
6
7/// Image reference with metadata
8///
9/// Contains a file reference to an image along with image-specific metadata
10/// such as dimensions, format, and other properties useful for displaying images.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct Image {
13    /// Reference to the stored image file
14    pub file_ref: FileRef,
15
16    /// Image width in pixels (if known)
17    pub width: Option<u32>,
18
19    /// Image height in pixels (if known)
20    pub height: Option<u32>,
21
22    /// Alternative text for accessibility
23    pub alt_text: Option<String>,
24
25    /// Human-readable caption or description
26    pub caption: Option<String>,
27
28    /// Image format (e.g., "PNG", "JPEG", "WEBP")
29    /// This may differ from content_type in the FileRef as it's more specific to images
30    pub format: Option<String>,
31
32    /// Additional image-specific metadata using structured types
33    pub metadata: Vec<Metadata>,
34}
35
36impl Image {
37    /// Create a new Image with just a file reference
38    pub fn new(file_ref: FileRef) -> Self {
39        Self {
40            file_ref,
41            width: None,
42            height: None,
43            alt_text: None,
44            caption: None,
45            format: None,
46            metadata: vec![],
47        }
48    }
49
50    /// Set image dimensions
51    pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
52        self.width = Some(width);
53        self.height = Some(height);
54        self
55    }
56
57    /// Set alternative text for accessibility
58    pub fn with_alt_text(mut self, alt_text: String) -> Self {
59        self.alt_text = Some(alt_text);
60        self
61    }
62
63    /// Set image caption or description
64    pub fn with_caption(mut self, caption: String) -> Self {
65        self.caption = Some(caption);
66        self
67    }
68
69    /// Set image format
70    pub fn with_format(mut self, format: String) -> Self {
71        self.format = Some(format);
72        self
73    }
74
75    /// Add generic metadata to the image
76    pub fn with_metadata(mut self, key: String, value: String) -> Self {
77        self.metadata.push(Metadata::Generic { key, value });
78        self
79    }
80
81    /// Add structured metadata to the image
82    pub fn with_structured_metadata(mut self, metadata: Metadata) -> Self {
83        self.metadata.push(metadata);
84        self
85    }
86
87    /// Get all generic metadata as a key-value map for backward compatibility
88    ///
89    /// This method extracts only the `Metadata::Generic(key, value)` entries and returns them
90    /// as a `BTreeMap<String, String>` for backward compatibility with APIs that expect
91    /// key-value metadata.
92    pub fn generic_metadata(&self) -> BTreeMap<String, String> {
93        self.metadata
94            .iter()
95            .filter_map(|meta| match meta {
96                Metadata::Generic { key, value } => Some((key.clone(), value.clone())),
97                _ => None,
98            })
99            .collect()
100    }
101
102    /// Get the aspect ratio (width/height) if dimensions are available
103    pub fn aspect_ratio(&self) -> Option<f32> {
104        match (self.width, self.height) {
105            (Some(w), Some(h)) if h > 0 => Some(w as f32 / h as f32),
106            _ => None,
107        }
108    }
109
110    /// Check if this is a square image
111    pub fn is_square(&self) -> Option<bool> {
112        match (self.width, self.height) {
113            (Some(w), Some(h)) => Some(w == h),
114            _ => None,
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    use zoe_encrypted_storage::ConvergentEncryptionInfo;
124
125    fn create_test_file_ref() -> FileRef {
126        FileRef::new(
127            "image_hash_123".to_string(),
128            ConvergentEncryptionInfo {
129                key: [1u8; 32],
130                was_compressed: false,
131                source_size: 2048,
132            },
133            Some("test_image.png".to_string()),
134        )
135        .with_content_type("image/png".to_string())
136    }
137
138    #[test]
139    fn test_image_new() {
140        let file_ref = create_test_file_ref();
141        let image = Image::new(file_ref.clone());
142
143        assert_eq!(image.file_ref, file_ref);
144        assert_eq!(image.width, None);
145        assert_eq!(image.height, None);
146        assert_eq!(image.alt_text, None);
147        assert_eq!(image.caption, None);
148        assert_eq!(image.format, None);
149        assert!(image.metadata.is_empty());
150    }
151
152    #[test]
153    fn test_image_with_dimensions() {
154        let image = Image::new(create_test_file_ref()).with_dimensions(1920, 1080);
155
156        assert_eq!(image.width, Some(1920));
157        assert_eq!(image.height, Some(1080));
158    }
159
160    #[test]
161    fn test_image_with_alt_text() {
162        let alt_text = "A beautiful sunset over the mountains".to_string();
163        let image = Image::new(create_test_file_ref()).with_alt_text(alt_text.clone());
164
165        assert_eq!(image.alt_text, Some(alt_text));
166    }
167
168    #[test]
169    fn test_image_with_caption() {
170        let caption = "Sunset at Mountain View, 2023".to_string();
171        let image = Image::new(create_test_file_ref()).with_caption(caption.clone());
172
173        assert_eq!(image.caption, Some(caption));
174    }
175
176    #[test]
177    fn test_image_with_format() {
178        let format = "PNG".to_string();
179        let image = Image::new(create_test_file_ref()).with_format(format.clone());
180
181        assert_eq!(image.format, Some(format));
182    }
183
184    #[test]
185    fn test_image_with_metadata() {
186        let image = Image::new(create_test_file_ref())
187            .with_metadata("camera".to_string(), "Canon EOS R5".to_string())
188            .with_metadata("iso".to_string(), "200".to_string())
189            .with_metadata("aperture".to_string(), "f/8".to_string());
190
191        let generic_meta = image.generic_metadata();
192        assert_eq!(
193            generic_meta.get("camera"),
194            Some(&"Canon EOS R5".to_string())
195        );
196        assert_eq!(generic_meta.get("iso"), Some(&"200".to_string()));
197        assert_eq!(generic_meta.get("aperture"), Some(&"f/8".to_string()));
198        assert_eq!(image.metadata.len(), 3);
199    }
200
201    #[test]
202    fn test_image_aspect_ratio() {
203        // Test with standard 16:9 aspect ratio
204        let image_16_9 = Image::new(create_test_file_ref()).with_dimensions(1920, 1080);
205        assert_eq!(image_16_9.aspect_ratio(), Some(1920.0 / 1080.0));
206
207        // Test with square image
208        let image_square = Image::new(create_test_file_ref()).with_dimensions(500, 500);
209        assert_eq!(image_square.aspect_ratio(), Some(1.0));
210
211        // Test with portrait orientation
212        let image_portrait = Image::new(create_test_file_ref()).with_dimensions(1080, 1920);
213        assert_eq!(image_portrait.aspect_ratio(), Some(1080.0 / 1920.0));
214
215        // Test with no dimensions
216        let image_no_dims = Image::new(create_test_file_ref());
217        assert_eq!(image_no_dims.aspect_ratio(), None);
218
219        // Test with zero height (edge case)
220        let mut image_zero_height = Image::new(create_test_file_ref());
221        image_zero_height.width = Some(1920);
222        image_zero_height.height = Some(0);
223        assert_eq!(image_zero_height.aspect_ratio(), None);
224
225        // Test with only width
226        let mut image_only_width = Image::new(create_test_file_ref());
227        image_only_width.width = Some(1920);
228        assert_eq!(image_only_width.aspect_ratio(), None);
229
230        // Test with only height
231        let mut image_only_height = Image::new(create_test_file_ref());
232        image_only_height.height = Some(1080);
233        assert_eq!(image_only_height.aspect_ratio(), None);
234    }
235
236    #[test]
237    fn test_image_is_square() {
238        // Test square image
239        let image_square = Image::new(create_test_file_ref()).with_dimensions(500, 500);
240        assert_eq!(image_square.is_square(), Some(true));
241
242        // Test non-square image
243        let image_rectangle = Image::new(create_test_file_ref()).with_dimensions(1920, 1080);
244        assert_eq!(image_rectangle.is_square(), Some(false));
245
246        // Test with no dimensions
247        let image_no_dims = Image::new(create_test_file_ref());
248        assert_eq!(image_no_dims.is_square(), None);
249
250        // Test with only width
251        let mut image_only_width = Image::new(create_test_file_ref());
252        image_only_width.width = Some(500);
253        assert_eq!(image_only_width.is_square(), None);
254
255        // Test with only height
256        let mut image_only_height = Image::new(create_test_file_ref());
257        image_only_height.height = Some(500);
258        assert_eq!(image_only_height.is_square(), None);
259    }
260
261    #[test]
262    fn test_image_builder_pattern() {
263        let image = Image::new(create_test_file_ref())
264            .with_dimensions(1920, 1080)
265            .with_alt_text("Test image".to_string())
266            .with_caption("A test image for unit tests".to_string())
267            .with_format("PNG".to_string())
268            .with_metadata("created_by".to_string(), "test_suite".to_string());
269
270        assert_eq!(image.width, Some(1920));
271        assert_eq!(image.height, Some(1080));
272        assert_eq!(image.alt_text, Some("Test image".to_string()));
273        assert_eq!(
274            image.caption,
275            Some("A test image for unit tests".to_string())
276        );
277        assert_eq!(image.format, Some("PNG".to_string()));
278        let generic_meta = image.generic_metadata();
279        assert_eq!(
280            generic_meta.get("created_by"),
281            Some(&"test_suite".to_string())
282        );
283    }
284
285    #[test]
286    fn test_postcard_serialization_image() {
287        let image = Image::new(create_test_file_ref())
288            .with_dimensions(800, 600)
289            .with_alt_text("Serialization test".to_string())
290            .with_caption("Testing postcard serialization".to_string())
291            .with_format("JPEG".to_string())
292            .with_metadata("test".to_string(), "serialization".to_string());
293
294        let serialized = postcard::to_stdvec(&image).expect("Failed to serialize");
295        let deserialized: Image = postcard::from_bytes(&serialized).expect("Failed to deserialize");
296
297        assert_eq!(image, deserialized);
298    }
299
300    #[test]
301    fn test_image_with_complex_metadata() {
302        use crate::Metadata;
303
304        let complex_metadata = vec![
305            Metadata::Generic {
306                key: "exif_date".to_string(),
307                value: "2023-12-01T15:30:00Z".to_string(),
308            },
309            Metadata::Generic {
310                key: "location".to_string(),
311                value: "37.7749,-122.4194".to_string(),
312            },
313            Metadata::Generic {
314                key: "device".to_string(),
315                value: "iPhone 15 Pro".to_string(),
316            },
317        ];
318
319        let mut image = Image::new(create_test_file_ref());
320        image.metadata = complex_metadata;
321
322        // Add more metadata using the builder
323        let final_image = image
324            .with_metadata("edited".to_string(), "true".to_string())
325            .with_metadata("editor".to_string(), "Photoshop".to_string());
326
327        // Check metadata using generic_metadata() helper
328        let generic_meta = final_image.generic_metadata();
329
330        // Check original metadata is preserved
331        assert_eq!(
332            generic_meta.get("exif_date"),
333            Some(&"2023-12-01T15:30:00Z".to_string())
334        );
335        assert_eq!(
336            generic_meta.get("location"),
337            Some(&"37.7749,-122.4194".to_string())
338        );
339        assert_eq!(
340            generic_meta.get("device"),
341            Some(&"iPhone 15 Pro".to_string())
342        );
343
344        // Check new metadata is added
345        assert_eq!(generic_meta.get("edited"), Some(&"true".to_string()));
346        assert_eq!(generic_meta.get("editor"), Some(&"Photoshop".to_string()));
347
348        assert_eq!(final_image.metadata.len(), 5);
349    }
350
351    #[test]
352    fn test_image_edge_cases() {
353        // Test with very large dimensions
354        let large_image = Image::new(create_test_file_ref()).with_dimensions(u32::MAX, u32::MAX);
355
356        assert_eq!(large_image.width, Some(u32::MAX));
357        assert_eq!(large_image.height, Some(u32::MAX));
358        assert_eq!(large_image.is_square(), Some(true));
359        assert_eq!(large_image.aspect_ratio(), Some(1.0));
360
361        // Test with 1x1 pixel image
362        let tiny_image = Image::new(create_test_file_ref()).with_dimensions(1, 1);
363
364        assert_eq!(tiny_image.is_square(), Some(true));
365        assert_eq!(tiny_image.aspect_ratio(), Some(1.0));
366
367        // Test with very wide image
368        let wide_image = Image::new(create_test_file_ref()).with_dimensions(10000, 1);
369
370        assert_eq!(wide_image.is_square(), Some(false));
371        assert_eq!(wide_image.aspect_ratio(), Some(10000.0));
372
373        // Test with very tall image
374        let tall_image = Image::new(create_test_file_ref()).with_dimensions(1, 10000);
375
376        assert_eq!(tall_image.is_square(), Some(false));
377        assert_eq!(tall_image.aspect_ratio(), Some(0.0001));
378    }
379
380    #[test]
381    fn test_image_empty_strings() {
382        // Test with empty strings
383        let image = Image::new(create_test_file_ref())
384            .with_alt_text("".to_string())
385            .with_caption("".to_string())
386            .with_format("".to_string())
387            .with_metadata("".to_string(), "".to_string());
388
389        assert_eq!(image.alt_text, Some("".to_string()));
390        assert_eq!(image.caption, Some("".to_string()));
391        assert_eq!(image.format, Some("".to_string()));
392        let generic_meta = image.generic_metadata();
393        assert_eq!(generic_meta.get(""), Some(&"".to_string()));
394    }
395}