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