Skip to main content

razeri_plugin_api/
storage.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3//! Storage provider factory trait and registry.
4//!
5//! [`StorageProviderFactory`] is the extension point for custom storage backends.
6//! [`StorageRegistry`] provides dual-key lookup for built-in and third-party providers.
7
8use crate::error::StorageError;
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use razeri_plugin_contract::StoragePlatform;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15/// Entry returned by listing a directory.
16#[derive(Debug, Clone)]
17pub struct StorageListEntry {
18    /// File or directory name (just the last segment).
19    pub name: String,
20    /// Size in bytes (0 for directories).
21    pub size_bytes: u64,
22    /// Whether this entry is a directory/prefix.
23    pub is_directory: bool,
24}
25
26/// Metadata about a single file/object in storage.
27#[derive(Debug, Clone)]
28pub struct StorageMetadata {
29    /// Size in bytes.
30    pub size_bytes: u64,
31    /// Last modification time, if available.
32    pub modified: Option<DateTime<Utc>>,
33    /// MIME content type, if available.
34    pub content_type: Option<String>,
35    /// Checksum (e.g. `ETag`, MD5), if available.
36    pub checksum: Option<String>,
37}
38
39/// Factory that creates storage operation instances from configuration.
40///
41/// Third-party storage backends (e.g., Kafka, HDFS, proprietary systems)
42/// implement this trait to integrate with Razeri's file management.
43#[async_trait]
44pub trait StorageProviderFactory: Send + Sync {
45    /// Machine-readable provider name (e.g., "s3", "`azure_blob`", "kafka").
46    fn name(&self) -> &str;
47
48    /// Human-readable display name.
49    fn display_name(&self) -> &str;
50
51    /// Known enum variant, if this is a built-in platform.
52    fn known_platform(&self) -> Option<StoragePlatform>;
53
54    /// Create a storage operations instance from a key-value configuration.
55    async fn create(
56        &self,
57        config: &HashMap<String, String>,
58    ) -> Result<Box<dyn StorageOps>, StorageError>;
59
60    /// Validate configuration without creating a connection.
61    fn validate_config(&self, config: &HashMap<String, String>) -> Vec<String>;
62
63    /// List required configuration keys.
64    fn required_config_keys(&self) -> Vec<&str>;
65}
66
67/// Minimal storage operations interface for plugin-API use.
68///
69/// This parallels the full `StorageOperations` in `razeri-runtime` but is
70/// defined here so provider plugins don't depend on `razeri-runtime`.
71#[async_trait]
72pub trait StorageOps: Send + Sync {
73    /// List entries at the given path.
74    async fn list(&self, path: &str) -> Result<Vec<StorageListEntry>, StorageError>;
75
76    /// Check if a path exists.
77    async fn exists(&self, path: &str) -> Result<bool, StorageError>;
78
79    /// Read file contents.
80    async fn read(&self, path: &str) -> Result<Vec<u8>, StorageError>;
81
82    /// Write content to a path.
83    async fn write(&self, path: &str, content: &[u8]) -> Result<(), StorageError>;
84
85    /// Get file metadata (size, modified time, content type, checksum).
86    async fn metadata(&self, path: &str) -> Result<StorageMetadata, StorageError>;
87
88    /// Move/rename a file (atomic where supported, copy+delete where not).
89    async fn move_file(&self, src: &str, dst: &str) -> Result<(), StorageError>;
90
91    /// Delete a path.
92    async fn delete(&self, path: &str) -> Result<(), StorageError>;
93
94    /// Create a directory/prefix (no-op for object stores).
95    async fn mkdir(&self, path: &str) -> Result<(), StorageError>;
96}
97
98/// Registry for storage providers with dual-key lookup.
99pub struct StorageRegistry {
100    known: HashMap<StoragePlatform, Arc<dyn StorageProviderFactory>>,
101    extensions: HashMap<String, Arc<dyn StorageProviderFactory>>,
102}
103
104impl StorageRegistry {
105    /// Create an empty registry.
106    #[must_use]
107    pub fn new() -> Self {
108        Self { known: HashMap::new(), extensions: HashMap::new() }
109    }
110
111    /// Register a built-in storage provider (dual-keyed).
112    pub fn register_known(
113        &mut self,
114        platform: StoragePlatform,
115        factory: Arc<dyn StorageProviderFactory>,
116    ) {
117        let name = factory.name().to_string();
118        self.known.insert(platform, Arc::clone(&factory));
119        self.extensions.insert(name, factory);
120    }
121
122    /// Register a third-party storage extension (string-keyed only).
123    pub fn register_extension(&mut self, name: String, factory: Arc<dyn StorageProviderFactory>) {
124        self.extensions.insert(name, factory);
125    }
126
127    /// Resolve by enum key (fast path for built-in platforms).
128    #[must_use]
129    pub fn get(&self, platform: &StoragePlatform) -> Option<&Arc<dyn StorageProviderFactory>> {
130        self.known.get(platform)
131    }
132
133    /// Resolve by string name (extension path, also works for built-in).
134    #[must_use]
135    pub fn get_by_name(&self, name: &str) -> Option<&Arc<dyn StorageProviderFactory>> {
136        self.extensions.get(name)
137    }
138
139    /// Resolve by string — covers both known and extension lookups.
140    #[must_use]
141    pub fn resolve(&self, target: &str) -> Option<Arc<dyn StorageProviderFactory>> {
142        self.extensions.get(target).cloned()
143    }
144
145    /// List all registered providers as (name, `display_name`) pairs.
146    #[must_use]
147    pub fn list(&self) -> Vec<(&str, &str)> {
148        self.extensions.values().map(|f| (f.name(), f.display_name())).collect()
149    }
150}
151
152impl Default for StorageRegistry {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    struct MockStorageOps;
163
164    #[async_trait]
165    impl StorageOps for MockStorageOps {
166        async fn list(&self, _path: &str) -> Result<Vec<StorageListEntry>, StorageError> {
167            Ok(vec![StorageListEntry {
168                name: "file1.csv".to_string(),
169                size_bytes: 1024,
170                is_directory: false,
171            }])
172        }
173
174        async fn exists(&self, _path: &str) -> Result<bool, StorageError> {
175            Ok(true)
176        }
177
178        async fn read(&self, _path: &str) -> Result<Vec<u8>, StorageError> {
179            Ok(b"test data".to_vec())
180        }
181
182        async fn write(&self, _path: &str, _content: &[u8]) -> Result<(), StorageError> {
183            Ok(())
184        }
185
186        async fn metadata(&self, _path: &str) -> Result<StorageMetadata, StorageError> {
187            Ok(StorageMetadata {
188                size_bytes: 42,
189                modified: None,
190                content_type: None,
191                checksum: None,
192            })
193        }
194
195        async fn move_file(&self, _src: &str, _dst: &str) -> Result<(), StorageError> {
196            Ok(())
197        }
198
199        async fn delete(&self, _path: &str) -> Result<(), StorageError> {
200            Ok(())
201        }
202
203        async fn mkdir(&self, _path: &str) -> Result<(), StorageError> {
204            Ok(())
205        }
206    }
207
208    struct MockStorageFactory {
209        name: &'static str,
210        display_name: &'static str,
211        known_platform: Option<StoragePlatform>,
212    }
213
214    impl MockStorageFactory {
215        fn s3() -> Self {
216            Self {
217                name: "s3",
218                display_name: "Amazon S3",
219                known_platform: Some(StoragePlatform::S3),
220            }
221        }
222
223        fn kafka() -> Self {
224            Self { name: "kafka", display_name: "Apache Kafka", known_platform: None }
225        }
226    }
227
228    #[async_trait]
229    impl StorageProviderFactory for MockStorageFactory {
230        fn name(&self) -> &str {
231            self.name
232        }
233
234        fn display_name(&self) -> &str {
235            self.display_name
236        }
237
238        fn known_platform(&self) -> Option<StoragePlatform> {
239            self.known_platform
240        }
241
242        async fn create(
243            &self,
244            _config: &HashMap<String, String>,
245        ) -> Result<Box<dyn StorageOps>, StorageError> {
246            Ok(Box::new(MockStorageOps))
247        }
248
249        fn validate_config(&self, config: &HashMap<String, String>) -> Vec<String> {
250            let mut errors = Vec::new();
251            for key in self.required_config_keys() {
252                if !config.contains_key(key) {
253                    errors.push(format!("missing required key: {key}"));
254                }
255            }
256            errors
257        }
258
259        fn required_config_keys(&self) -> Vec<&str> {
260            vec!["bucket", "region"]
261        }
262    }
263
264    #[tokio::test]
265    async fn storage_factory_creates_operations() {
266        let factory = MockStorageFactory::s3();
267        let config = HashMap::from([
268            ("bucket".to_string(), "test-bucket".to_string()),
269            ("region".to_string(), "us-east-1".to_string()),
270        ]);
271        let ops = factory.create(&config).await.unwrap();
272        assert!(ops.exists("test").await.unwrap());
273    }
274
275    #[test]
276    fn storage_registry_dual_key_lookup() {
277        let mut registry = StorageRegistry::new();
278        registry.register_known(StoragePlatform::S3, Arc::new(MockStorageFactory::s3()));
279
280        assert!(registry.get(&StoragePlatform::S3).is_some());
281        assert!(registry.get_by_name("s3").is_some());
282    }
283
284    #[test]
285    fn storage_config_validation_catches_missing_keys() {
286        let factory = MockStorageFactory::s3();
287        let config = HashMap::new();
288        let errors = factory.validate_config(&config);
289        assert_eq!(errors.len(), 2);
290        assert!(errors[0].contains("bucket"));
291    }
292
293    #[test]
294    fn storage_extension_registration() {
295        let mut registry = StorageRegistry::new();
296        registry.register_extension("kafka".to_string(), Arc::new(MockStorageFactory::kafka()));
297        assert!(registry.get_by_name("kafka").is_some());
298    }
299
300    #[test]
301    fn storage_registry_list_shows_all_providers() {
302        let mut registry = StorageRegistry::new();
303        registry.register_known(StoragePlatform::S3, Arc::new(MockStorageFactory::s3()));
304        registry.register_extension("kafka".to_string(), Arc::new(MockStorageFactory::kafka()));
305        assert_eq!(registry.list().len(), 2);
306    }
307
308    #[test]
309    fn storage_registry_resolve_covers_both_known_and_extension() {
310        let mut registry = StorageRegistry::new();
311        registry.register_known(StoragePlatform::S3, Arc::new(MockStorageFactory::s3()));
312        registry.register_extension("kafka".to_string(), Arc::new(MockStorageFactory::kafka()));
313
314        assert!(registry.resolve("s3").is_some());
315        assert!(registry.resolve("kafka").is_some());
316        assert!(registry.resolve("nonexistent").is_none());
317    }
318}