1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Dimensions {
25 pub width: u32,
26 pub height: u32,
27}
28
29#[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
42pub trait ImageBackend: Sync {
48 fn identify(&self, path: &Path) -> Result<Dimensions, BackendError>;
50
51 fn read_metadata(&self, path: &Path) -> Result<ImageMetadata, BackendError>;
53
54 fn resize(&self, params: &ResizeParams) -> Result<(), BackendError>;
56
57 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 #[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}