Skip to main content

rustrails_storage/
preview.rs

1//! Preview generation for non-image blobs.
2
3use std::{sync::Arc, time::Duration};
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use rustrails_support::runtime;
8use thiserror::Error;
9use url::Url;
10
11use crate::{
12    Blob, replace_extension,
13    service::{StorageError, StorageService},
14    sha256_hex,
15};
16
17/// Errors returned by preview generation.
18#[derive(Debug, Error)]
19pub enum PreviewError {
20    /// No previewer accepted the blob.
21    #[error("no previewer accepted blob")]
22    Unsupported,
23    /// The storage backend failed.
24    #[error(transparent)]
25    Storage(#[from] StorageError),
26}
27
28/// Preview artifact metadata.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Preview {
31    blob: Blob,
32    key: String,
33    filename: String,
34    content_type: String,
35}
36
37impl Preview {
38    /// Creates a preview placeholder for a blob.
39    #[must_use]
40    pub fn new(blob: Blob) -> Self {
41        let digest = sha256_hex(blob.key());
42        Self {
43            blob,
44            key: format!("previews/{digest}"),
45            filename: replace_extension("preview", "png"),
46            content_type: "image/png".to_owned(),
47        }
48    }
49
50    /// Returns the source blob.
51    #[must_use]
52    pub fn blob(&self) -> &Blob {
53        &self.blob
54    }
55
56    /// Returns the preview storage key.
57    #[must_use]
58    pub fn key(&self) -> &str {
59        &self.key
60    }
61
62    /// Returns the preview filename.
63    #[must_use]
64    pub fn filename(&self) -> &str {
65        &self.filename
66    }
67
68    /// Returns the preview content type.
69    #[must_use]
70    pub fn content_type(&self) -> &str {
71        &self.content_type
72    }
73
74    /// Returns whether the preview has been stored already.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error when the storage existence check fails.
79    pub async fn is_processed<S: StorageService + ?Sized>(
80        &self,
81        service: &S,
82    ) -> Result<bool, PreviewError> {
83        Ok(service.exists(&self.key).await?)
84    }
85
86    /// Returns whether the preview has been stored already using the thread-local runtime.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error when the storage existence check fails.
91    pub fn is_processed_sync<S: StorageService + ?Sized>(
92        &self,
93        service: &S,
94    ) -> Result<bool, PreviewError> {
95        runtime::block_on(self.is_processed(service))
96    }
97
98    /// Stores preview bytes when missing.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error when the storage upload fails.
103    pub async fn processed<S: StorageService + ?Sized>(
104        &self,
105        service: &S,
106        data: Bytes,
107    ) -> Result<Self, PreviewError> {
108        if !service.exists(&self.key).await? {
109            service.upload(&self.key, data).await?;
110        }
111        Ok(self.clone())
112    }
113
114    /// Stores preview bytes when missing using the thread-local runtime.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error when the storage upload fails.
119    pub fn processed_sync<S: StorageService + ?Sized>(
120        &self,
121        service: &S,
122        data: Bytes,
123    ) -> Result<Self, PreviewError> {
124        runtime::block_on(self.processed(service, data))
125    }
126
127    /// Generates a preview URL.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error when the storage service cannot build a URL.
132    pub async fn url<S: StorageService + ?Sized>(
133        &self,
134        service: &S,
135        expires_in: Duration,
136    ) -> Result<Url, PreviewError> {
137        Ok(service.url(&self.key, expires_in).await?)
138    }
139
140    /// Generates a preview URL using the thread-local runtime.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error when the storage service cannot build a URL.
145    pub fn url_sync<S: StorageService + ?Sized>(
146        &self,
147        service: &S,
148        expires_in: Duration,
149    ) -> Result<Url, PreviewError> {
150        runtime::block_on(self.url(service, expires_in))
151    }
152}
153
154/// Previewer trait for generating preview bytes.
155#[async_trait]
156pub trait BlobPreviewer: Send + Sync {
157    /// Returns the previewer name.
158    fn name(&self) -> &str;
159
160    /// Returns whether this previewer supports the blob.
161    fn accepts(&self, blob: &Blob) -> bool;
162
163    /// Produces preview bytes.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error when the preview cannot be generated.
168    async fn preview(&self, blob: &Blob, data: &Bytes) -> Result<Bytes, PreviewError>;
169}
170
171/// PDF previewer.
172#[derive(Debug, Clone, Copy)]
173pub struct PdfPreviewer;
174
175/// Video previewer.
176#[derive(Debug, Clone, Copy)]
177pub struct VideoPreviewer;
178
179/// Registry of previewers.
180#[derive(Default, Clone)]
181pub struct PreviewRegistry {
182    previewers: Vec<Arc<dyn BlobPreviewer>>,
183}
184
185impl std::fmt::Debug for PreviewRegistry {
186    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        formatter
188            .debug_struct("PreviewRegistry")
189            .field("previewers", &self.previewers.len())
190            .finish()
191    }
192}
193
194impl PreviewRegistry {
195    /// Creates a registry with the default previewers.
196    #[must_use]
197    pub fn with_defaults() -> Self {
198        Self {
199            previewers: vec![Arc::new(PdfPreviewer), Arc::new(VideoPreviewer)],
200        }
201    }
202
203    /// Generates a preview using the first matching previewer.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error when no previewer accepts the blob.
208    pub async fn generate<S: StorageService + ?Sized>(
209        &self,
210        blob: Blob,
211        source: &Bytes,
212        service: &S,
213    ) -> Result<Preview, PreviewError> {
214        for previewer in &self.previewers {
215            if previewer.accepts(&blob) {
216                let bytes = previewer.preview(&blob, source).await?;
217                return Preview::new(blob).processed(service, bytes).await;
218            }
219        }
220        Err(PreviewError::Unsupported)
221    }
222
223    /// Generates a preview using the first matching previewer with the thread-local runtime.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error when no previewer accepts the blob.
228    pub fn generate_sync<S: StorageService + ?Sized>(
229        &self,
230        blob: Blob,
231        source: &Bytes,
232        service: &S,
233    ) -> Result<Preview, PreviewError> {
234        runtime::block_on(self.generate(blob, source, service))
235    }
236}
237
238#[async_trait]
239impl BlobPreviewer for PdfPreviewer {
240    fn name(&self) -> &str {
241        "pdf"
242    }
243
244    fn accepts(&self, blob: &Blob) -> bool {
245        blob.content_type() == Some("application/pdf")
246    }
247
248    async fn preview(&self, blob: &Blob, _data: &Bytes) -> Result<Bytes, PreviewError> {
249        Ok(Bytes::from(
250            format!("pdf-preview:{}", blob.filename()).into_bytes(),
251        ))
252    }
253}
254
255#[async_trait]
256impl BlobPreviewer for VideoPreviewer {
257    fn name(&self) -> &str {
258        "video"
259    }
260
261    fn accepts(&self, blob: &Blob) -> bool {
262        blob.is_video()
263    }
264
265    async fn preview(&self, blob: &Blob, _data: &Bytes) -> Result<Bytes, PreviewError> {
266        Ok(Bytes::from(
267            format!("video-preview:{}", blob.filename()).into_bytes(),
268        ))
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use bytes::Bytes;
275    use rustrails_support::runtime;
276
277    use super::*;
278    use crate::{Blob, service::memory::MemoryService, test_support::run_sync_test};
279
280    fn blob(filename: &str, content_type: Option<&str>) -> Blob {
281        Blob::create(
282            Bytes::from(filename.as_bytes().to_vec()),
283            filename,
284            content_type,
285            Default::default(),
286            "memory",
287        )
288        .expect("blob should build")
289    }
290
291    #[tokio::test]
292    async fn test_pdf_previewer_generates_preview_bytes() {
293        let bytes = PdfPreviewer
294            .preview(
295                &blob("report.pdf", Some("application/pdf")),
296                &Bytes::from_static(b"%PDF-1.4"),
297            )
298            .await
299            .expect("preview should succeed");
300        assert_eq!(bytes, Bytes::from_static(b"pdf-preview:report.pdf"));
301    }
302
303    #[tokio::test]
304    async fn test_video_previewer_generates_preview_bytes() {
305        let bytes = VideoPreviewer
306            .preview(
307                &blob("movie.mp4", Some("video/mp4")),
308                &Bytes::from_static(b"mp4"),
309            )
310            .await
311            .expect("preview should succeed");
312        assert_eq!(bytes, Bytes::from_static(b"video-preview:movie.mp4"));
313    }
314
315    #[tokio::test]
316    async fn test_preview_registry_processes_pdf_preview() {
317        let registry = PreviewRegistry::with_defaults();
318        let service = MemoryService::new("memory").expect("service should build");
319        let preview = registry
320            .generate(
321                blob("report.pdf", Some("application/pdf")),
322                &Bytes::from_static(b"%PDF-1.4"),
323                &service,
324            )
325            .await
326            .expect("preview should succeed");
327        assert!(
328            service
329                .exists(preview.key())
330                .await
331                .expect("exists should succeed")
332        );
333    }
334
335    #[test]
336    fn test_preview_registry_generate_sync_processes_pdf_preview() {
337        run_sync_test(|| {
338            let registry = PreviewRegistry::with_defaults();
339            let service = MemoryService::new("memory").expect("service should build");
340            let preview = registry
341                .generate_sync(
342                    blob("report.pdf", Some("application/pdf")),
343                    &Bytes::from_static(b"%PDF-1.4"),
344                    &service,
345                )
346                .expect("preview should succeed");
347            assert!(
348                runtime::block_on(service.exists(preview.key())).expect("exists should succeed")
349            );
350        });
351    }
352
353    #[tokio::test]
354    async fn test_preview_registry_rejects_unsupported_blob() {
355        let registry = PreviewRegistry::with_defaults();
356        let service = MemoryService::new("memory").expect("service should build");
357        let error = registry
358            .generate(
359                blob("image.png", Some("image/png")),
360                &Bytes::from_static(b"png"),
361                &service,
362            )
363            .await
364            .expect_err("preview should fail");
365        assert!(matches!(error, PreviewError::Unsupported));
366    }
367
368    #[tokio::test]
369    async fn test_preview_url_delegates_to_service() {
370        let service = MemoryService::new("memory").expect("service should build");
371        let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
372        let url = preview
373            .url(&service, Duration::from_secs(30))
374            .await
375            .expect("url should build");
376        assert!(url.as_str().contains("expires_in=30"));
377    }
378
379    #[test]
380    fn test_preview_is_processed_sync_reports_state() {
381        run_sync_test(|| {
382            let service = MemoryService::new("memory").expect("service should build");
383            let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
384            assert!(
385                !preview
386                    .is_processed_sync(&service)
387                    .expect("status should succeed")
388            );
389            runtime::block_on(service.upload(preview.key(), Bytes::from_static(b"preview")))
390                .expect("upload should succeed");
391            assert!(
392                preview
393                    .is_processed_sync(&service)
394                    .expect("status should succeed")
395            );
396        });
397    }
398
399    #[test]
400    fn test_preview_processed_sync_uploads_preview_when_missing() {
401        run_sync_test(|| {
402            let service = MemoryService::new("memory").expect("service should build");
403            let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
404
405            let processed = preview
406                .processed_sync(&service, Bytes::from_static(b"preview"))
407                .expect("processed_sync should succeed");
408
409            assert_eq!(processed, preview);
410            assert!(
411                runtime::block_on(service.exists(preview.key())).expect("exists should succeed")
412            );
413        });
414    }
415
416    #[test]
417    fn test_preview_url_sync_delegates_to_service() {
418        run_sync_test(|| {
419            let service = MemoryService::new("memory").expect("service should build");
420            let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
421            let url = preview
422                .url_sync(&service, Duration::from_secs(30))
423                .expect("url_sync should build");
424            assert!(url.as_str().contains("expires_in=30"));
425        });
426    }
427}