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