1use 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}