Skip to main content

smugmug_cli/cache/
hash_store.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5
6#[derive(Debug, Serialize, Deserialize, Clone)]
7pub struct UploadedFile {
8    pub smugmug_uri: String,
9    pub album_key: String,
10    pub image_key: String,
11    pub uploaded_at: DateTime<Utc>,
12    pub file_size: u64,
13    pub original_path: String,
14}
15
16/// Thread-safe hash store using sled embedded database
17pub struct HashStore {
18    db: Arc<sled::Db>,
19}
20
21impl HashStore {
22    /// Create a new HashStore at the specified path
23    pub fn new(path: &str) -> Result<Self> {
24        let db = sled::open(path).context("Failed to open sled database")?;
25
26        Ok(HashStore { db: Arc::new(db) })
27    }
28
29    /// Get an uploaded file by its hash
30    pub fn get(&self, hash: &str) -> Result<Option<UploadedFile>> {
31        let value = self
32            .db
33            .get(hash.as_bytes())
34            .context("Failed to read from database")?;
35
36        match value {
37            Some(bytes) => {
38                let file: UploadedFile =
39                    serde_json::from_slice(&bytes).context("Failed to deserialize UploadedFile")?;
40                Ok(Some(file))
41            }
42            None => Ok(None),
43        }
44    }
45
46    /// Insert a new uploaded file record
47    pub fn insert(&self, hash: &str, file: UploadedFile) -> Result<()> {
48        let serialized = serde_json::to_vec(&file).context("Failed to serialize UploadedFile")?;
49
50        self.db
51            .insert(hash.as_bytes(), serialized)
52            .context("Failed to insert into database")?;
53
54        self.db.flush().context("Failed to flush database")?;
55
56        Ok(())
57    }
58
59    /// Insert without flushing. Sled flushes on its own every half second,
60    /// so a crash loses at most that much; for runs that insert many records
61    /// and call `flush` when done.
62    pub fn insert_deferred(&self, hash: &str, file: &UploadedFile) -> Result<()> {
63        let serialized = serde_json::to_vec(file).context("Failed to serialize UploadedFile")?;
64        self.db
65            .insert(hash.as_bytes(), serialized)
66            .context("Failed to insert into database")?;
67        Ok(())
68    }
69
70    /// Forget one file without flushing (see `insert_deferred`).
71    pub fn remove_deferred(&self, hash: &str) -> Result<()> {
72        self.db
73            .remove(hash.as_bytes())
74            .context("Failed to remove from database")?;
75        Ok(())
76    }
77
78    /// Write everything to disk.
79    pub fn flush(&self) -> Result<()> {
80        self.db.flush().context("Failed to flush database")?;
81        Ok(())
82    }
83
84    /// Another keyspace in the same database (e.g. the per-path file index),
85    /// since sled lets only one process open it at a time.
86    pub fn open_tree(&self, name: &str) -> Result<sled::Tree> {
87        self.db
88            .open_tree(name)
89            .with_context(|| format!("Failed to open database tree '{}'", name))
90    }
91
92    /// Forget one file, e.g. when the image it points to no longer exists
93    pub fn remove(&self, hash: &str) -> Result<()> {
94        self.db
95            .remove(hash.as_bytes())
96            .context("Failed to remove from database")?;
97        self.db.flush().context("Failed to flush database")?;
98        Ok(())
99    }
100
101    /// Clear all entries from the cache
102    pub fn clear(&self) -> Result<()> {
103        self.db.clear().context("Failed to clear database")?;
104        // And every other keyspace (the file index), which would otherwise
105        // keep files counted as uploaded.
106        for name in self.db.tree_names() {
107            if name.as_ref() != b"__sled__default" {
108                self.db
109                    .drop_tree(&name)
110                    .context("Failed to clear database tree")?;
111            }
112        }
113
114        self.db.flush().context("Failed to flush database")?;
115
116        Ok(())
117    }
118
119    /// Get cache statistics
120    pub fn stats(&self) -> Result<CacheStats> {
121        let mut total_entries = 0;
122        let mut total_size = 0u64;
123        let mut oldest: Option<DateTime<Utc>> = None;
124        let mut newest: Option<DateTime<Utc>> = None;
125
126        for item in self.db.iter() {
127            let (_key, value) = item.context("Failed to read database entry")?;
128            total_entries += 1;
129
130            // Deserialize to get file size and timestamp
131            if let Ok(file) = serde_json::from_slice::<UploadedFile>(&value) {
132                total_size += file.file_size;
133                let timestamp = file.uploaded_at;
134
135                oldest = Some(oldest.map_or(timestamp, |old| old.min(timestamp)));
136                newest = Some(newest.map_or(timestamp, |new| new.max(timestamp)));
137            }
138        }
139
140        Ok(CacheStats {
141            total_entries,
142            total_size,
143            oldest_entry: oldest,
144            newest_entry: newest,
145        })
146    }
147
148    /// Get the total number of entries in the cache
149    #[allow(dead_code)]
150    pub fn count(&self) -> Result<usize> {
151        let mut count = 0;
152        for item in self.db.iter() {
153            item.context("Failed to read database entry")?;
154            count += 1;
155        }
156        Ok(count)
157    }
158
159    /// Get the total size of cached data in bytes (sum of file_size fields)
160    #[allow(dead_code)]
161    pub fn size(&self) -> Result<u64> {
162        let mut total_size = 0u64;
163        for item in self.db.iter() {
164            let (_key, value) = item.context("Failed to read database entry")?;
165            if let Ok(file) = serde_json::from_slice::<UploadedFile>(&value) {
166                total_size += file.file_size;
167            }
168        }
169        Ok(total_size)
170    }
171}
172
173// Implement Clone for HashStore since it uses Arc internally
174impl Clone for HashStore {
175    fn clone(&self) -> Self {
176        HashStore {
177            db: Arc::clone(&self.db),
178        }
179    }
180}
181
182#[derive(Debug, Clone)]
183pub struct CacheStats {
184    pub total_entries: usize,
185    pub total_size: u64,
186    pub oldest_entry: Option<DateTime<Utc>>,
187    pub newest_entry: Option<DateTime<Utc>>,
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use chrono::TimeZone;
194
195    fn temp_dir() -> String {
196        let dir = tempfile::tempdir().unwrap();
197        dir.path().to_str().unwrap().to_string()
198    }
199
200    fn create_test_file(timestamp: DateTime<Utc>) -> UploadedFile {
201        UploadedFile {
202            smugmug_uri: "https://api.smugmug.com/api/v2/image/test".to_string(),
203            album_key: "album123".to_string(),
204            image_key: "img456".to_string(),
205            uploaded_at: timestamp,
206            file_size: 1024,
207            original_path: "/test/path.jpg".to_string(),
208        }
209    }
210
211    #[test]
212    fn test_new_store() {
213        let dir = temp_dir();
214        let store = HashStore::new(&dir);
215        assert!(store.is_ok());
216    }
217
218    #[test]
219    fn test_insert_and_get() {
220        let dir = temp_dir();
221        let store = HashStore::new(&dir).unwrap();
222
223        let file = create_test_file(Utc::now());
224        let hash = "abc123";
225
226        // Insert
227        store.insert(hash, file.clone()).unwrap();
228
229        // Retrieve
230        let retrieved = store.get(hash).unwrap();
231        assert!(retrieved.is_some());
232        let retrieved = retrieved.unwrap();
233        assert_eq!(retrieved.smugmug_uri, file.smugmug_uri);
234        assert_eq!(retrieved.album_key, file.album_key);
235        assert_eq!(retrieved.image_key, file.image_key);
236    }
237
238    #[test]
239    fn test_get_nonexistent() {
240        let dir = temp_dir();
241        let store = HashStore::new(&dir).unwrap();
242
243        let result = store.get("nonexistent").unwrap();
244        assert!(result.is_none());
245    }
246
247    #[test]
248    fn test_clear() {
249        let dir = temp_dir();
250        let store = HashStore::new(&dir).unwrap();
251
252        // Insert multiple entries
253        for i in 0..5 {
254            let hash = format!("hash{}", i);
255            let file = create_test_file(Utc::now());
256            store.insert(&hash, file).unwrap();
257        }
258
259        // Verify entries exist
260        let count_before = store.count().unwrap();
261        assert_eq!(count_before, 5);
262
263        // Clear
264        store.clear().unwrap();
265
266        // Verify cleared
267        let count_after = store.count().unwrap();
268        assert_eq!(count_after, 0);
269    }
270
271    #[test]
272    fn test_count() {
273        let dir = temp_dir();
274        let store = HashStore::new(&dir).unwrap();
275
276        // Empty store
277        assert_eq!(store.count().unwrap(), 0);
278
279        // Add entries
280        for i in 0..10 {
281            let hash = format!("hash{}", i);
282            let file = create_test_file(Utc::now());
283            store.insert(&hash, file).unwrap();
284        }
285
286        assert_eq!(store.count().unwrap(), 10);
287    }
288
289    #[test]
290    fn test_size() {
291        let dir = temp_dir();
292        let store = HashStore::new(&dir).unwrap();
293
294        // Empty store
295        assert_eq!(store.size().unwrap(), 0);
296
297        // Add entries
298        let file = create_test_file(Utc::now());
299        store.insert("hash1", file).unwrap();
300
301        // Size should be greater than 0
302        let size = store.size().unwrap();
303        assert!(size > 0);
304    }
305
306    #[test]
307    fn test_stats_empty() {
308        let dir = temp_dir();
309        let store = HashStore::new(&dir).unwrap();
310
311        let stats = store.stats().unwrap();
312        assert_eq!(stats.total_entries, 0);
313        assert_eq!(stats.total_size, 0);
314        assert!(stats.oldest_entry.is_none());
315        assert!(stats.newest_entry.is_none());
316    }
317
318    #[test]
319    fn test_stats_single_entry() {
320        let dir = temp_dir();
321        let store = HashStore::new(&dir).unwrap();
322
323        let timestamp = Utc.with_ymd_and_hms(2024, 3, 15, 12, 0, 0).unwrap();
324        let file = create_test_file(timestamp);
325        store.insert("hash1", file).unwrap();
326
327        let stats = store.stats().unwrap();
328        assert_eq!(stats.total_entries, 1);
329        assert!(stats.total_size > 0);
330        assert_eq!(stats.oldest_entry, Some(timestamp));
331        assert_eq!(stats.newest_entry, Some(timestamp));
332    }
333
334    #[test]
335    fn test_stats_multiple_entries() {
336        let dir = temp_dir();
337        let store = HashStore::new(&dir).unwrap();
338
339        // Create entries with different timestamps
340        let oldest = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
341        let middle = Utc.with_ymd_and_hms(2024, 2, 15, 12, 0, 0).unwrap();
342        let newest = Utc.with_ymd_and_hms(2024, 3, 15, 12, 0, 0).unwrap();
343
344        store.insert("hash1", create_test_file(middle)).unwrap();
345        store.insert("hash2", create_test_file(oldest)).unwrap();
346        store.insert("hash3", create_test_file(newest)).unwrap();
347
348        let stats = store.stats().unwrap();
349        assert_eq!(stats.total_entries, 3);
350        assert!(stats.total_size > 0);
351        assert_eq!(stats.oldest_entry, Some(oldest));
352        assert_eq!(stats.newest_entry, Some(newest));
353    }
354
355    #[test]
356    fn test_stats_size_calculation() {
357        let dir = temp_dir();
358        let store = HashStore::new(&dir).unwrap();
359
360        // Insert one entry
361        let file1 = create_test_file(Utc::now());
362        store.insert("hash1", file1).unwrap();
363        let size1 = store.stats().unwrap().total_size;
364
365        // Insert another entry
366        let file2 = create_test_file(Utc::now());
367        store.insert("hash2", file2).unwrap();
368        let size2 = store.stats().unwrap().total_size;
369
370        // Size should have increased
371        assert!(size2 > size1);
372    }
373
374    #[test]
375    fn test_clone() {
376        let dir = temp_dir();
377        let store1 = HashStore::new(&dir).unwrap();
378
379        let file = create_test_file(Utc::now());
380        store1.insert("hash1", file).unwrap();
381
382        // Clone the store
383        let store2 = store1.clone();
384
385        // Both should be able to read the same data
386        assert!(store1.get("hash1").unwrap().is_some());
387        assert!(store2.get("hash1").unwrap().is_some());
388        assert_eq!(store1.count().unwrap(), store2.count().unwrap());
389    }
390
391    #[test]
392    fn test_thread_safety() {
393        use std::thread;
394
395        let dir = temp_dir();
396        let store = HashStore::new(&dir).unwrap();
397
398        let store1 = store.clone();
399        let store2 = store.clone();
400
401        let handle1 = thread::spawn(move || {
402            for i in 0..50 {
403                let hash = format!("thread1_hash{}", i);
404                let file = create_test_file(Utc::now());
405                store1.insert(&hash, file).unwrap();
406            }
407        });
408
409        let handle2 = thread::spawn(move || {
410            for i in 0..50 {
411                let hash = format!("thread2_hash{}", i);
412                let file = create_test_file(Utc::now());
413                store2.insert(&hash, file).unwrap();
414            }
415        });
416
417        handle1.join().unwrap();
418        handle2.join().unwrap();
419
420        // Should have 100 entries total
421        assert_eq!(store.count().unwrap(), 100);
422    }
423
424    #[test]
425    fn test_update_existing_entry() {
426        let dir = temp_dir();
427        let store = HashStore::new(&dir).unwrap();
428
429        let hash = "hash1";
430        let file1 = create_test_file(Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap());
431        let file2 = create_test_file(Utc.with_ymd_and_hms(2024, 2, 1, 12, 0, 0).unwrap());
432
433        // Insert first version
434        store.insert(hash, file1).unwrap();
435        let retrieved1 = store.get(hash).unwrap().unwrap();
436        assert_eq!(
437            retrieved1.uploaded_at,
438            Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap()
439        );
440
441        // Update with second version
442        store.insert(hash, file2).unwrap();
443        let retrieved2 = store.get(hash).unwrap().unwrap();
444        assert_eq!(
445            retrieved2.uploaded_at,
446            Utc.with_ymd_and_hms(2024, 2, 1, 12, 0, 0).unwrap()
447        );
448
449        // Should still have only 1 entry
450        assert_eq!(store.count().unwrap(), 1);
451    }
452}