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