Skip to main content

razeri_plugin_api/
storage.rs

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