Skip to main content

rc_core/
traits.rs

1//! ObjectStore trait definition
2//!
3//! This trait defines the interface for S3-compatible storage operations.
4//! It allows the CLI to be decoupled from the specific S3 SDK implementation.
5
6use std::collections::HashMap;
7
8use async_trait::async_trait;
9use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11
12use crate::cors::CorsRule;
13use crate::error::Result;
14use crate::lifecycle::LifecycleRule;
15use crate::path::RemotePath;
16use crate::replication::ReplicationConfiguration;
17
18/// Metadata for an object version
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ObjectVersion {
21    /// Object key
22    pub key: String,
23
24    /// Version ID
25    pub version_id: String,
26
27    /// Whether this is the latest version
28    pub is_latest: bool,
29
30    /// Whether this is a delete marker
31    pub is_delete_marker: bool,
32
33    /// Last modified timestamp
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub last_modified: Option<Timestamp>,
36
37    /// Size in bytes
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub size_bytes: Option<i64>,
40
41    /// ETag
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub etag: Option<String>,
44}
45
46/// Metadata for an object or bucket
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ObjectInfo {
49    /// Object key or bucket name
50    pub key: String,
51
52    /// Size in bytes (None for buckets)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub size_bytes: Option<i64>,
55
56    /// Human-readable size
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub size_human: Option<String>,
59
60    /// Last modified timestamp
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub last_modified: Option<Timestamp>,
63
64    /// ETag (usually MD5 for single-part uploads)
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub etag: Option<String>,
67
68    /// Storage class
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub storage_class: Option<String>,
71
72    /// Content type
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub content_type: Option<String>,
75
76    /// User-defined metadata
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub metadata: Option<HashMap<String, String>>,
79
80    /// Whether this is a directory/prefix
81    pub is_dir: bool,
82}
83
84impl ObjectInfo {
85    /// Create a new ObjectInfo for a file
86    pub fn file(key: impl Into<String>, size: i64) -> Self {
87        Self {
88            key: key.into(),
89            size_bytes: Some(size),
90            size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
91            last_modified: None,
92            etag: None,
93            storage_class: None,
94            content_type: None,
95            metadata: None,
96            is_dir: false,
97        }
98    }
99
100    /// Create a new ObjectInfo for a directory/prefix
101    pub fn dir(key: impl Into<String>) -> Self {
102        Self {
103            key: key.into(),
104            size_bytes: None,
105            size_human: None,
106            last_modified: None,
107            etag: None,
108            storage_class: None,
109            content_type: None,
110            metadata: None,
111            is_dir: true,
112        }
113    }
114
115    /// Create a new ObjectInfo for a bucket
116    pub fn bucket(name: impl Into<String>) -> Self {
117        Self {
118            key: name.into(),
119            size_bytes: None,
120            size_human: None,
121            last_modified: None,
122            etag: None,
123            storage_class: None,
124            content_type: None,
125            metadata: None,
126            is_dir: true,
127        }
128    }
129}
130
131/// Result of a list operation
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct ListResult {
134    /// Listed objects
135    pub items: Vec<ObjectInfo>,
136
137    /// Whether the result is truncated (more items available)
138    pub truncated: bool,
139
140    /// Continuation token for pagination
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub continuation_token: Option<String>,
143}
144
145/// Options for list operations
146#[derive(Debug, Clone, Default)]
147pub struct ListOptions {
148    /// Maximum number of keys to return per request
149    pub max_keys: Option<i32>,
150
151    /// Delimiter for grouping (usually "/")
152    pub delimiter: Option<String>,
153
154    /// Prefix to filter by
155    pub prefix: Option<String>,
156
157    /// Continuation token for pagination
158    pub continuation_token: Option<String>,
159
160    /// Whether to list recursively (ignore delimiter)
161    pub recursive: bool,
162}
163
164/// Backend capability information
165#[derive(Debug, Clone, Default)]
166pub struct Capabilities {
167    /// Supports bucket versioning
168    pub versioning: bool,
169
170    /// Supports object lock/retention
171    pub object_lock: bool,
172
173    /// Supports object tagging
174    pub tagging: bool,
175
176    /// Supports anonymous bucket access policies
177    pub anonymous: bool,
178
179    /// Supports S3 Select
180    pub select: bool,
181
182    /// Supports event notifications
183    pub notifications: bool,
184
185    /// Supports lifecycle configuration
186    pub lifecycle: bool,
187
188    /// Supports bucket replication
189    pub replication: bool,
190
191    /// Supports bucket CORS configuration
192    pub cors: bool,
193}
194
195/// Bucket notification target type
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198pub enum NotificationTarget {
199    /// SQS queue target
200    Queue,
201    /// SNS topic target
202    Topic,
203    /// Lambda function target
204    Lambda,
205}
206
207/// Bucket notification rule
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209pub struct BucketNotification {
210    /// Optional rule id
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub id: Option<String>,
213    /// Notification target type
214    pub target: NotificationTarget,
215    /// Target ARN
216    pub arn: String,
217    /// Event patterns
218    pub events: Vec<String>,
219    /// Optional key prefix filter
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub prefix: Option<String>,
222    /// Optional key suffix filter
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub suffix: Option<String>,
225}
226
227/// Trait for S3-compatible storage operations
228///
229/// This trait is implemented by the S3 adapter and can be mocked for testing.
230#[async_trait]
231pub trait ObjectStore: Send + Sync {
232    /// List buckets
233    async fn list_buckets(&self) -> Result<Vec<ObjectInfo>>;
234
235    /// List objects in a bucket or prefix
236    async fn list_objects(&self, path: &RemotePath, options: ListOptions) -> Result<ListResult>;
237
238    /// Get object metadata
239    async fn head_object(&self, path: &RemotePath) -> Result<ObjectInfo>;
240
241    /// Check if a bucket exists
242    async fn bucket_exists(&self, bucket: &str) -> Result<bool>;
243
244    /// Create a bucket
245    async fn create_bucket(&self, bucket: &str) -> Result<()>;
246
247    /// Delete a bucket
248    async fn delete_bucket(&self, bucket: &str) -> Result<()>;
249
250    /// Get backend capabilities
251    async fn capabilities(&self) -> Result<Capabilities>;
252
253    /// Get object content as bytes
254    async fn get_object(&self, path: &RemotePath) -> Result<Vec<u8>>;
255
256    /// Upload object from bytes
257    async fn put_object(
258        &self,
259        path: &RemotePath,
260        data: Vec<u8>,
261        content_type: Option<&str>,
262    ) -> Result<ObjectInfo>;
263
264    /// Delete an object
265    async fn delete_object(&self, path: &RemotePath) -> Result<()>;
266
267    /// Delete multiple objects (batch delete)
268    async fn delete_objects(&self, bucket: &str, keys: Vec<String>) -> Result<Vec<String>>;
269
270    /// Copy object within S3 (server-side copy)
271    async fn copy_object(&self, src: &RemotePath, dst: &RemotePath) -> Result<ObjectInfo>;
272
273    /// Generate a presigned URL for an object
274    async fn presign_get(&self, path: &RemotePath, expires_secs: u64) -> Result<String>;
275
276    /// Generate a presigned URL for uploading an object
277    async fn presign_put(
278        &self,
279        path: &RemotePath,
280        expires_secs: u64,
281        content_type: Option<&str>,
282    ) -> Result<String>;
283
284    // Phase 5: Optional operations (capability-dependent)
285
286    /// Get bucket versioning status
287    async fn get_versioning(&self, bucket: &str) -> Result<Option<bool>>;
288
289    /// Set bucket versioning status
290    async fn set_versioning(&self, bucket: &str, enabled: bool) -> Result<()>;
291
292    /// List object versions
293    async fn list_object_versions(
294        &self,
295        path: &RemotePath,
296        max_keys: Option<i32>,
297    ) -> Result<Vec<ObjectVersion>>;
298
299    /// Get object tags
300    async fn get_object_tags(
301        &self,
302        path: &RemotePath,
303    ) -> Result<std::collections::HashMap<String, String>>;
304
305    /// Get bucket tags
306    async fn get_bucket_tags(
307        &self,
308        bucket: &str,
309    ) -> Result<std::collections::HashMap<String, String>>;
310
311    /// Set object tags
312    async fn set_object_tags(
313        &self,
314        path: &RemotePath,
315        tags: std::collections::HashMap<String, String>,
316    ) -> Result<()>;
317
318    /// Set bucket tags
319    async fn set_bucket_tags(
320        &self,
321        bucket: &str,
322        tags: std::collections::HashMap<String, String>,
323    ) -> Result<()>;
324
325    /// Delete object tags
326    async fn delete_object_tags(&self, path: &RemotePath) -> Result<()>;
327
328    /// Delete bucket tags
329    async fn delete_bucket_tags(&self, bucket: &str) -> Result<()>;
330
331    /// Get bucket policy as raw JSON string. Returns `None` when no policy exists.
332    async fn get_bucket_policy(&self, bucket: &str) -> Result<Option<String>>;
333
334    /// Replace bucket policy using raw JSON string.
335    async fn set_bucket_policy(&self, bucket: &str, policy: &str) -> Result<()>;
336
337    /// Remove bucket policy (set anonymous access to private).
338    async fn delete_bucket_policy(&self, bucket: &str) -> Result<()>;
339
340    /// Get bucket notification configuration as flat rules.
341    async fn get_bucket_notifications(&self, bucket: &str) -> Result<Vec<BucketNotification>>;
342
343    /// Replace bucket notification configuration with flat rules.
344    async fn set_bucket_notifications(
345        &self,
346        bucket: &str,
347        notifications: Vec<BucketNotification>,
348    ) -> Result<()>;
349
350    // Lifecycle operations (capability-dependent)
351
352    /// Get bucket lifecycle rules. Returns empty vec if no lifecycle config exists.
353    async fn get_bucket_lifecycle(&self, bucket: &str) -> Result<Vec<LifecycleRule>>;
354
355    /// Set bucket lifecycle configuration (replaces all rules).
356    async fn set_bucket_lifecycle(&self, bucket: &str, rules: Vec<LifecycleRule>) -> Result<()>;
357
358    /// Delete bucket lifecycle configuration.
359    async fn delete_bucket_lifecycle(&self, bucket: &str) -> Result<()>;
360
361    /// Restore a transitioned (archived) object.
362    async fn restore_object(&self, path: &RemotePath, days: i32) -> Result<()>;
363
364    // Replication operations (capability-dependent)
365
366    /// Get bucket replication configuration. Returns None if not configured.
367    async fn get_bucket_replication(
368        &self,
369        bucket: &str,
370    ) -> Result<Option<ReplicationConfiguration>>;
371
372    /// Set bucket replication configuration.
373    async fn set_bucket_replication(
374        &self,
375        bucket: &str,
376        config: ReplicationConfiguration,
377    ) -> Result<()>;
378
379    /// Delete bucket replication configuration.
380    async fn delete_bucket_replication(&self, bucket: &str) -> Result<()>;
381
382    /// Get bucket CORS rules. Returns empty vec if no CORS config exists.
383    async fn get_bucket_cors(&self, bucket: &str) -> Result<Vec<CorsRule>>;
384
385    /// Set bucket CORS configuration (replaces all rules).
386    async fn set_bucket_cors(&self, bucket: &str, rules: Vec<CorsRule>) -> Result<()>;
387
388    /// Delete bucket CORS configuration.
389    async fn delete_bucket_cors(&self, bucket: &str) -> Result<()>;
390    // async fn get_versioning(&self, bucket: &str) -> Result<bool>;
391    // async fn set_versioning(&self, bucket: &str, enabled: bool) -> Result<()>;
392    // async fn get_tags(&self, path: &RemotePath) -> Result<HashMap<String, String>>;
393    // async fn set_tags(&self, path: &RemotePath, tags: HashMap<String, String>) -> Result<()>;
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_object_info_file() {
402        let info = ObjectInfo::file("test.txt", 1024);
403        assert_eq!(info.key, "test.txt");
404        assert_eq!(info.size_bytes, Some(1024));
405        assert!(!info.is_dir);
406    }
407
408    #[test]
409    fn test_object_info_dir() {
410        let info = ObjectInfo::dir("path/to/dir/");
411        assert_eq!(info.key, "path/to/dir/");
412        assert!(info.is_dir);
413        assert!(info.size_bytes.is_none());
414    }
415
416    #[test]
417    fn test_object_info_bucket() {
418        let info = ObjectInfo::bucket("my-bucket");
419        assert_eq!(info.key, "my-bucket");
420        assert!(info.is_dir);
421    }
422
423    #[test]
424    fn test_object_info_metadata_default_none() {
425        let info = ObjectInfo::file("test.txt", 1024);
426        assert!(info.metadata.is_none());
427    }
428
429    #[test]
430    fn test_object_info_metadata_set() {
431        let mut info = ObjectInfo::file("test.txt", 1024);
432        let mut meta = HashMap::new();
433        meta.insert("content-disposition".to_string(), "attachment".to_string());
434        meta.insert("custom-key".to_string(), "custom-value".to_string());
435        info.metadata = Some(meta);
436
437        let metadata = info.metadata.as_ref().expect("metadata should be Some");
438        assert_eq!(metadata.len(), 2);
439        assert_eq!(metadata.get("content-disposition").unwrap(), "attachment");
440        assert_eq!(metadata.get("custom-key").unwrap(), "custom-value");
441    }
442}