Skip to main content

rain_engine_blob/
lib.rs

1//! Blob backends for multimodal RainEngine attachments.
2//!
3//! The core only stores attachment references; this crate provides concrete
4//! local and in-memory storage implementations plus config wiring.
5
6use async_trait::async_trait;
7use rain_engine_core::{
8    AttachmentRef, BlobDescriptor, BlobStore, BlobStoreError, InMemoryBlobStore, MultimodalPayload,
9};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13use uuid::Uuid;
14
15pub use rain_engine_core::{BlobStore as BlobStoreTrait, InMemoryBlobStore as MemoryBlobStore};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(tag = "kind", rename_all = "snake_case")]
19pub enum BlobBackendConfig {
20    InMemory,
21    LocalDirectory {
22        path: String,
23    },
24    S3 {
25        bucket: String,
26        prefix: Option<String>,
27    },
28    Gcs {
29        bucket: String,
30        prefix: Option<String>,
31    },
32}
33
34#[derive(Clone)]
35pub struct LocalFileBlobStore {
36    root: PathBuf,
37}
38
39impl LocalFileBlobStore {
40    pub fn new(root: impl Into<PathBuf>) -> Result<Self, BlobStoreError> {
41        let root = root.into();
42        fs::create_dir_all(&root).map_err(|err| BlobStoreError::new(err.to_string()))?;
43        Ok(Self { root })
44    }
45
46    pub fn uri_for_path(path: &Path) -> String {
47        format!("file://{}", path.display())
48    }
49
50    pub fn path_from_uri(uri: &str) -> Result<PathBuf, BlobStoreError> {
51        uri.strip_prefix("file://")
52            .map(PathBuf::from)
53            .ok_or_else(|| BlobStoreError::new("unsupported local blob uri"))
54    }
55}
56
57#[async_trait]
58impl BlobStore for LocalFileBlobStore {
59    async fn put(
60        &self,
61        attachment_id: String,
62        payload: MultimodalPayload,
63    ) -> Result<AttachmentRef, BlobStoreError> {
64        let mut path = self
65            .root
66            .join(format!("{}_{}", attachment_id, Uuid::new_v4()));
67        if let Some(file_name) = &payload.file_name {
68            path.set_file_name(format!(
69                "{}_{}_{}",
70                attachment_id,
71                Uuid::new_v4(),
72                file_name
73            ));
74        }
75        fs::write(&path, &payload.data).map_err(|err| BlobStoreError::new(err.to_string()))?;
76        Ok(AttachmentRef::blob(
77            attachment_id,
78            payload.mime_type,
79            payload.file_name,
80            BlobDescriptor {
81                uri: Self::uri_for_path(&path),
82                size_bytes: payload.data.len(),
83            },
84        ))
85    }
86
87    async fn get(&self, descriptor: &BlobDescriptor) -> Result<MultimodalPayload, BlobStoreError> {
88        let path = Self::path_from_uri(&descriptor.uri)?;
89        let data = fs::read(&path).map_err(|err| BlobStoreError::new(err.to_string()))?;
90        Ok(MultimodalPayload {
91            mime_type: "application/octet-stream".to_string(),
92            file_name: path
93                .file_name()
94                .map(|name| name.to_string_lossy().to_string()),
95            data,
96        })
97    }
98}
99
100pub fn build_blob_store(config: &BlobBackendConfig) -> Result<Box<dyn BlobStore>, BlobStoreError> {
101    match config {
102        BlobBackendConfig::InMemory => Ok(Box::new(InMemoryBlobStore::new())),
103        BlobBackendConfig::LocalDirectory { path } => Ok(Box::new(LocalFileBlobStore::new(path)?)),
104        BlobBackendConfig::S3 { .. } => Err(BlobStoreError::new(
105            "S3 blob backend is not implemented in this workspace build",
106        )),
107        BlobBackendConfig::Gcs { .. } => Err(BlobStoreError::new(
108            "GCS blob backend is not implemented in this workspace build",
109        )),
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[tokio::test]
118    async fn local_file_store_round_trips() {
119        let temp_dir = std::env::temp_dir().join(format!("rain-engine-blob-{}", Uuid::new_v4()));
120        let store = LocalFileBlobStore::new(&temp_dir).expect("store");
121        let reference = store
122            .put(
123                "attachment-1".to_string(),
124                MultimodalPayload {
125                    mime_type: "text/plain".to_string(),
126                    file_name: Some("hello.txt".to_string()),
127                    data: b"hello".to_vec(),
128                },
129            )
130            .await
131            .expect("put");
132        let descriptor = match reference.content {
133            rain_engine_core::AttachmentContent::Blob { descriptor } => descriptor,
134            other => panic!("expected blob reference, got {other:?}"),
135        };
136        let payload = store.get(&descriptor).await.expect("get");
137        assert_eq!(payload.data, b"hello");
138    }
139}