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
16pub struct HashStore {
18 db: Arc<sled::Db>,
19}
20
21impl HashStore {
22 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 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 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 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 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 pub fn flush(&self) -> Result<()> {
80 self.db.flush().context("Failed to flush database")?;
81 Ok(())
82 }
83
84 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 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 pub fn clear(&self) -> Result<()> {
103 self.db.clear().context("Failed to clear database")?;
104 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 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 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 #[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 #[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
173impl 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 store.insert(hash, file.clone()).unwrap();
228
229 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 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 let count_before = store.count().unwrap();
261 assert_eq!(count_before, 5);
262
263 store.clear().unwrap();
265
266 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 assert_eq!(store.count().unwrap(), 0);
278
279 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 assert_eq!(store.size().unwrap(), 0);
296
297 let file = create_test_file(Utc::now());
299 store.insert("hash1", file).unwrap();
300
301 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 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 let file1 = create_test_file(Utc::now());
362 store.insert("hash1", file1).unwrap();
363 let size1 = store.stats().unwrap().total_size;
364
365 let file2 = create_test_file(Utc::now());
367 store.insert("hash2", file2).unwrap();
368 let size2 = store.stats().unwrap().total_size;
369
370 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 let store2 = store1.clone();
384
385 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 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 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 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 assert_eq!(store.count().unwrap(), 1);
451 }
452}