Skip to main content

simple_gal/imaging/
backend.rs

1//! Image processing backend trait and shared types.
2//!
3//! The [`ImageBackend`] trait defines the four operations every backend must
4//! support: identify, read_metadata, resize, and thumbnail.
5//!
6//! The production implementation is
7//! [`RustBackend`](super::rust_backend::RustBackend) — pure Rust, zero
8//! external dependencies. Everything is statically linked into the binary.
9
10use super::params::{ResizeParams, ThumbnailParams};
11use std::path::Path;
12use thiserror::Error;
13
14#[derive(Error, Debug)]
15pub enum BackendError {
16    #[error("IO error: {0}")]
17    Io(#[from] std::io::Error),
18    #[error("Processing failed: {0}")]
19    ProcessingFailed(String),
20}
21
22/// Result of an identify operation.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Dimensions {
25    pub width: u32,
26    pub height: u32,
27}
28
29/// Embedded image metadata extracted from IPTC fields.
30///
31/// Field mapping:
32/// - `title`: IPTC Object Name (`2:05`) — the "Title" field in Lightroom/Capture One
33/// - `description`: IPTC Caption-Abstract (`2:120`) — the "Caption" field in Lightroom
34/// - `keywords`: IPTC Keywords (`2:25`) — repeatable field, one entry per keyword
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct ImageMetadata {
37    pub title: Option<String>,
38    pub description: Option<String>,
39    pub keywords: Vec<String>,
40}
41
42/// Trait for image processing backends.
43///
44/// Every backend must implement all four operations — identify, read_metadata,
45/// resize, and thumbnail — so the rest of the codebase is backend-agnostic.
46/// See the [module docs](self) for the parity table.
47pub trait ImageBackend: Sync {
48    /// Get image dimensions.
49    fn identify(&self, path: &Path) -> Result<Dimensions, BackendError>;
50
51    /// Read embedded IPTC/EXIF metadata (title, description).
52    fn read_metadata(&self, path: &Path) -> Result<ImageMetadata, BackendError>;
53
54    /// Execute a resize operation.
55    fn resize(&self, params: &ResizeParams) -> Result<(), BackendError>;
56
57    /// Execute a thumbnail operation (resize + center crop).
58    fn thumbnail(&self, params: &ThumbnailParams) -> Result<(), BackendError>;
59}
60
61#[cfg(test)]
62pub mod tests {
63    use super::*;
64    use crate::imaging::Sharpening;
65    use std::sync::Mutex;
66
67    /// Mock backend that records operations without executing them.
68    /// Uses Mutex (not RefCell) so it is Sync and works with rayon's par_iter.
69    #[derive(Default)]
70    pub struct MockBackend {
71        pub identify_results: Mutex<Vec<Dimensions>>,
72        pub metadata_results: Mutex<Vec<ImageMetadata>>,
73        pub operations: Mutex<Vec<RecordedOp>>,
74    }
75
76    #[derive(Debug, Clone, PartialEq)]
77    pub enum RecordedOp {
78        Identify(String),
79        ReadMetadata(String),
80        Resize {
81            source: String,
82            output: String,
83            width: u32,
84            height: u32,
85            quality: u32,
86        },
87        Thumbnail {
88            source: String,
89            output: String,
90            crop_width: u32,
91            crop_height: u32,
92            quality: u32,
93            sharpening: Option<(f32, i32)>,
94        },
95    }
96
97    impl MockBackend {
98        pub fn new() -> Self {
99            Self::default()
100        }
101
102        pub fn with_dimensions(dims: Vec<Dimensions>) -> Self {
103            Self {
104                identify_results: Mutex::new(dims),
105                metadata_results: Mutex::new(Vec::new()),
106                operations: Mutex::new(Vec::new()),
107            }
108        }
109
110        pub fn with_metadata(dims: Vec<Dimensions>, metadata: Vec<ImageMetadata>) -> Self {
111            Self {
112                identify_results: Mutex::new(dims),
113                metadata_results: Mutex::new(metadata),
114                operations: Mutex::new(Vec::new()),
115            }
116        }
117
118        pub fn get_operations(&self) -> Vec<RecordedOp> {
119            self.operations.lock().unwrap().clone()
120        }
121    }
122
123    impl ImageBackend for MockBackend {
124        fn identify(&self, path: &Path) -> Result<Dimensions, BackendError> {
125            self.operations
126                .lock()
127                .unwrap()
128                .push(RecordedOp::Identify(path.to_string_lossy().to_string()));
129
130            self.identify_results
131                .lock()
132                .unwrap()
133                .pop()
134                .ok_or_else(|| BackendError::ProcessingFailed("No mock dimensions".to_string()))
135        }
136
137        fn read_metadata(&self, path: &Path) -> Result<ImageMetadata, BackendError> {
138            self.operations
139                .lock()
140                .unwrap()
141                .push(RecordedOp::ReadMetadata(path.to_string_lossy().to_string()));
142
143            Ok(self
144                .metadata_results
145                .lock()
146                .unwrap()
147                .pop()
148                .unwrap_or_default())
149        }
150
151        fn resize(&self, params: &ResizeParams) -> Result<(), BackendError> {
152            self.operations.lock().unwrap().push(RecordedOp::Resize {
153                source: params.source.to_string_lossy().to_string(),
154                output: params.output.to_string_lossy().to_string(),
155                width: params.width,
156                height: params.height,
157                quality: params.quality.value(),
158            });
159            Ok(())
160        }
161
162        fn thumbnail(&self, params: &ThumbnailParams) -> Result<(), BackendError> {
163            self.operations.lock().unwrap().push(RecordedOp::Thumbnail {
164                source: params.source.to_string_lossy().to_string(),
165                output: params.output.to_string_lossy().to_string(),
166                crop_width: params.crop_width,
167                crop_height: params.crop_height,
168                quality: params.quality.value(),
169                sharpening: params.sharpening.map(|s| (s.sigma, s.threshold)),
170            });
171            Ok(())
172        }
173    }
174
175    #[test]
176    fn mock_records_identify() {
177        let backend = MockBackend::with_dimensions(vec![Dimensions {
178            width: 800,
179            height: 600,
180        }]);
181
182        let result = backend.identify(Path::new("/test/image.jpg")).unwrap();
183        assert_eq!(result.width, 800);
184        assert_eq!(result.height, 600);
185
186        let ops = backend.get_operations();
187        assert_eq!(ops.len(), 1);
188        assert!(matches!(&ops[0], RecordedOp::Identify(p) if p == "/test/image.jpg"));
189    }
190
191    #[test]
192    fn mock_records_resize() {
193        let backend = MockBackend::new();
194
195        backend
196            .resize(&ResizeParams {
197                source: "/source.jpg".into(),
198                output: "/output.avif".into(),
199                width: 800,
200                height: 600,
201                quality: super::super::params::Quality::new(90),
202            })
203            .unwrap();
204
205        let ops = backend.get_operations();
206        assert_eq!(ops.len(), 1);
207        assert!(matches!(
208            &ops[0],
209            RecordedOp::Resize {
210                width: 800,
211                height: 600,
212                quality: 90,
213                ..
214            }
215        ));
216    }
217
218    #[test]
219    fn mock_records_thumbnail_with_sharpening() {
220        let backend = MockBackend::new();
221
222        backend
223            .thumbnail(&ThumbnailParams {
224                source: "/source.jpg".into(),
225                output: "/thumb.webp".into(),
226                crop_width: 400,
227                crop_height: 500,
228                quality: super::super::params::Quality::new(85),
229                sharpening: Some(Sharpening::light()),
230            })
231            .unwrap();
232
233        let ops = backend.get_operations();
234        assert_eq!(ops.len(), 1);
235        assert!(matches!(
236            &ops[0],
237            RecordedOp::Thumbnail {
238                crop_width: 400,
239                crop_height: 500,
240                sharpening: Some((0.5, 0)),
241                ..
242            }
243        ));
244    }
245}