Skip to main content

entrenar/storage/cloud/
local.rs

1//! Local filesystem artifact backend
2
3use crate::storage::cloud::error::{CloudError, Result};
4use crate::storage::cloud::metadata::ArtifactMetadata;
5use crate::storage::cloud::traits::{compute_hash, ArtifactBackend};
6use std::collections::HashMap;
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::sync::{Arc, RwLock};
10
11/// Local filesystem artifact backend
12#[derive(Debug)]
13pub struct LocalBackend {
14    base_path: PathBuf,
15    metadata: Arc<RwLock<HashMap<String, ArtifactMetadata>>>,
16}
17
18impl LocalBackend {
19    /// Create a new local backend
20    pub fn new(base_path: PathBuf) -> Self {
21        Self { base_path, metadata: Arc::new(RwLock::new(HashMap::new())) }
22    }
23
24    /// Create a new local backend and ensure directory exists
25    pub fn new_and_init(base_path: PathBuf) -> Result<Self> {
26        std::fs::create_dir_all(&base_path)?;
27        Ok(Self::new(base_path))
28    }
29
30    /// Get the file path for a hash
31    fn hash_to_path(&self, hash: &str) -> PathBuf {
32        // Use subdirectories based on hash prefix for better filesystem performance
33        let prefix = hash.get(..2).unwrap_or(hash);
34        self.base_path.join(prefix).join(hash)
35    }
36}
37
38impl ArtifactBackend for LocalBackend {
39    fn put(&self, name: &str, data: &[u8]) -> Result<String> {
40        let hash = compute_hash(data);
41        let path = self.hash_to_path(&hash);
42
43        // Create parent directory
44        if let Some(parent) = path.parent() {
45            std::fs::create_dir_all(parent)?;
46        }
47
48        // Write data
49        let mut file = std::fs::File::create(&path)?;
50        file.write_all(data)?;
51
52        // Store metadata
53        let metadata = ArtifactMetadata::new(name, &hash, data.len() as u64);
54        self.metadata
55            .write()
56            .expect("metadata RwLock must not be poisoned")
57            .insert(hash.clone(), metadata);
58
59        Ok(hash)
60    }
61
62    fn get(&self, hash: &str) -> Result<Vec<u8>> {
63        let path = self.hash_to_path(hash);
64
65        if !path.exists() {
66            return Err(CloudError::NotFound(hash.to_string()));
67        }
68
69        let mut file = std::fs::File::open(&path)?;
70        let mut data = Vec::new();
71        file.read_to_end(&mut data)?;
72
73        // Verify hash
74        let computed = compute_hash(&data);
75        if computed != hash {
76            return Err(CloudError::Backend(format!(
77                "Hash mismatch: expected {hash}, got {computed}"
78            )));
79        }
80
81        Ok(data)
82    }
83
84    fn exists(&self, hash: &str) -> Result<bool> {
85        let path = self.hash_to_path(hash);
86        Ok(path.exists())
87    }
88
89    fn delete(&self, hash: &str) -> Result<()> {
90        let path = self.hash_to_path(hash);
91
92        if !path.exists() {
93            return Err(CloudError::NotFound(hash.to_string()));
94        }
95
96        std::fs::remove_file(&path)?;
97        self.metadata.write().expect("metadata RwLock must not be poisoned").remove(hash);
98
99        Ok(())
100    }
101
102    fn get_metadata(&self, hash: &str) -> Result<ArtifactMetadata> {
103        self.metadata
104            .read()
105            .expect("metadata RwLock must not be poisoned")
106            .get(hash)
107            .cloned()
108            .ok_or_else(|| CloudError::NotFound(hash.to_string()))
109    }
110
111    fn list(&self) -> Result<Vec<ArtifactMetadata>> {
112        Ok(self
113            .metadata
114            .read()
115            .expect("metadata RwLock must not be poisoned")
116            .values()
117            .cloned()
118            .collect())
119    }
120
121    fn backend_type(&self) -> &'static str {
122        "local"
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use tempfile::TempDir;
130
131    #[test]
132    fn test_local_backend_put_get() {
133        let tmp = TempDir::new().expect("temp file creation should succeed");
134        let backend =
135            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
136
137        let data = b"local test data";
138        let hash = backend.put("test.bin", data).expect("operation should succeed");
139
140        let retrieved = backend.get(&hash).expect("key should exist");
141        assert_eq!(retrieved, data);
142    }
143
144    #[test]
145    fn test_local_backend_exists() {
146        let tmp = TempDir::new().expect("temp file creation should succeed");
147        let backend =
148            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
149
150        let hash = backend.put("test.bin", b"data").expect("operation should succeed");
151        assert!(backend.exists(&hash).expect("operation should succeed"));
152        assert!(!backend.exists("nonexistent").expect("operation should succeed"));
153    }
154
155    #[test]
156    fn test_local_backend_delete() {
157        let tmp = TempDir::new().expect("temp file creation should succeed");
158        let backend =
159            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
160
161        let hash = backend.put("test.bin", b"data").expect("operation should succeed");
162        backend.delete(&hash).expect("operation should succeed");
163        assert!(!backend.exists(&hash).expect("operation should succeed"));
164    }
165
166    #[test]
167    fn test_local_backend_type() {
168        let tmp = TempDir::new().expect("temp file creation should succeed");
169        let backend =
170            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
171        assert_eq!(backend.backend_type(), "local");
172    }
173
174    #[test]
175    fn test_local_backend_get_not_found() {
176        let tmp = TempDir::new().expect("temp file creation should succeed");
177        let backend =
178            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
179
180        let result = backend.get("nonexistent_hash");
181        assert!(result.is_err());
182        match result {
183            Err(CloudError::NotFound(hash)) => assert_eq!(hash, "nonexistent_hash"),
184            _ => panic!("Expected NotFound error"),
185        }
186    }
187
188    #[test]
189    fn test_local_backend_delete_not_found() {
190        let tmp = TempDir::new().expect("temp file creation should succeed");
191        let backend =
192            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
193
194        let result = backend.delete("nonexistent_hash");
195        assert!(result.is_err());
196    }
197
198    #[test]
199    fn test_local_backend_get_metadata() {
200        let tmp = TempDir::new().expect("temp file creation should succeed");
201        let backend =
202            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
203
204        let data = b"test data for metadata";
205        let hash = backend.put("test_file.bin", data).expect("operation should succeed");
206
207        let meta = backend.get_metadata(&hash).expect("operation should succeed");
208        assert_eq!(meta.name, "test_file.bin");
209        assert_eq!(meta.size, data.len() as u64);
210    }
211
212    #[test]
213    fn test_local_backend_get_metadata_not_found() {
214        let tmp = TempDir::new().expect("temp file creation should succeed");
215        let backend =
216            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
217
218        let result = backend.get_metadata("nonexistent");
219        assert!(result.is_err());
220    }
221
222    #[test]
223    fn test_local_backend_list() {
224        let tmp = TempDir::new().expect("temp file creation should succeed");
225        let backend =
226            LocalBackend::new_and_init(tmp.path().to_path_buf()).expect("operation should succeed");
227
228        backend.put("file1.bin", b"data1").expect("operation should succeed");
229        backend.put("file2.bin", b"data2").expect("operation should succeed");
230        backend.put("file3.bin", b"data3").expect("operation should succeed");
231
232        let list = backend.list().expect("operation should succeed");
233        assert_eq!(list.len(), 3);
234    }
235
236    #[test]
237    fn test_local_backend_new() {
238        let backend = LocalBackend::new(PathBuf::from("/tmp/test"));
239        assert_eq!(backend.backend_type(), "local");
240    }
241}