Skip to main content

hashtree_fs/
lib.rs

1//! Filesystem-based content-addressed blob storage.
2//!
3//! Stores blobs in a directory structure similar to git:
4//! `{base_path}/{first 2 chars of hash}/{next 2 chars}/{remaining hash chars}`
5//!
6//! For example, a blob with hash `abcdef123...` would be stored at:
7//! `~/.hashtree/blobs/ab/cd/ef123...`
8
9use async_trait::async_trait;
10use hashtree_core::store::{Store, StoreError, StoreStats};
11use hashtree_core::types::Hash;
12use std::collections::HashMap;
13use std::fs;
14use std::io::{Read, Seek, SeekFrom};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::RwLock;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20/// Filesystem-backed blob store implementing hashtree's Store trait.
21///
22/// Stores blobs in a two-level sharded directory structure using
23/// the first 4 hex characters of the hash as directory prefixes.
24/// Legacy single-level shard paths remain readable.
25/// Supports storage limits with mtime-based FIFO eviction and pinning.
26pub struct FsBlobStore {
27    base_path: PathBuf,
28    max_bytes: AtomicU64,
29    /// Pin counts stored in memory, persisted to pins.json
30    pins: RwLock<HashMap<String, u32>>,
31}
32
33impl FsBlobStore {
34    /// Create a new filesystem blob store at the given path.
35    ///
36    /// Creates the directory if it doesn't exist.
37    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
38        let base_path = path.as_ref().to_path_buf();
39        fs::create_dir_all(&base_path)?;
40
41        // Load existing pins from disk
42        let pins = Self::load_pins(&base_path).unwrap_or_default();
43
44        Ok(Self {
45            base_path,
46            max_bytes: AtomicU64::new(0), // 0 = unlimited
47            pins: RwLock::new(pins),
48        })
49    }
50
51    /// Create a new store with a maximum size limit
52    pub fn with_max_bytes<P: AsRef<Path>>(path: P, max_bytes: u64) -> Result<Self, StoreError> {
53        let store = Self::new(path)?;
54        store.max_bytes.store(max_bytes, Ordering::Relaxed);
55        Ok(store)
56    }
57
58    /// Path to pins.json file
59    fn pins_path(&self) -> PathBuf {
60        self.base_path.join("pins.json")
61    }
62
63    /// Load pins from disk
64    fn load_pins(base_path: &Path) -> Option<HashMap<String, u32>> {
65        let pins_path = base_path.join("pins.json");
66        let contents = fs::read_to_string(pins_path).ok()?;
67        serde_json::from_str(&contents).ok()
68    }
69
70    /// Save pins to disk
71    fn save_pins(&self) -> Result<(), StoreError> {
72        let pins = self.pins.read().unwrap();
73        let json = serde_json::to_string(&*pins)
74            .map_err(|e| StoreError::Other(format!("Failed to serialize pins: {}", e)))?;
75        fs::write(self.pins_path(), json)?;
76        Ok(())
77    }
78
79    fn blob_path_from_hex(&self, hash_hex: &str) -> PathBuf {
80        let (prefix, rest) = hash_hex.split_at(2);
81        let (subdir, filename) = rest.split_at(2);
82        self.base_path.join(prefix).join(subdir).join(filename)
83    }
84
85    fn legacy_blob_path(&self, hash: &Hash) -> PathBuf {
86        let hex = hex::encode(hash);
87        let (prefix, rest) = hex.split_at(2);
88        self.base_path.join(prefix).join(rest)
89    }
90
91    /// Get the file path for a given hash.
92    ///
93    /// Format: `{base_path}/{first 2 hex chars}/{next 2 hex chars}/{remaining 60 hex chars}`
94    fn blob_path(&self, hash: &Hash) -> PathBuf {
95        self.blob_path_from_hex(&hex::encode(hash))
96    }
97
98    fn existing_blob_path(&self, hash: &Hash) -> Option<PathBuf> {
99        let primary = self.blob_path(hash);
100        if primary.exists() {
101            return Some(primary);
102        }
103
104        let legacy = self.legacy_blob_path(hash);
105        if legacy.exists() {
106            return Some(legacy);
107        }
108
109        None
110    }
111
112    fn hash_hex_for_blob_path(&self, path: &Path) -> Option<String> {
113        let relative = path.strip_prefix(&self.base_path).ok()?;
114        let mut hex = String::new();
115
116        for component in relative.iter() {
117            let part = component.to_str()?;
118            hex.push_str(part);
119        }
120
121        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
122            return None;
123        }
124
125        Some(hex.to_ascii_lowercase())
126    }
127
128    fn collect_blob_metadata_recursive(
129        &self,
130        dir: &Path,
131        blobs: &mut Vec<(PathBuf, String, fs::Metadata)>,
132    ) -> Result<(), StoreError> {
133        let entries = match fs::read_dir(dir) {
134            Ok(e) => e,
135            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
136            Err(e) => return Err(e.into()),
137        };
138
139        for entry in entries {
140            let entry = entry?;
141            let file_type = entry.file_type()?;
142            let path = entry.path();
143
144            if file_type.is_dir() {
145                self.collect_blob_metadata_recursive(&path, blobs)?;
146                continue;
147            }
148
149            if !file_type.is_file() {
150                continue;
151            }
152
153            if let Some(hex) = self.hash_hex_for_blob_path(&path) {
154                blobs.push((path, hex, entry.metadata()?));
155            }
156        }
157
158        Ok(())
159    }
160
161    fn collect_blob_metadata(&self) -> Result<Vec<(PathBuf, String, fs::Metadata)>, StoreError> {
162        let mut blobs = Vec::new();
163        self.collect_blob_metadata_recursive(&self.base_path, &mut blobs)?;
164        Ok(blobs)
165    }
166
167    /// Sync put operation.
168    pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
169        let path = self.blob_path(&hash);
170
171        // Check if already exists
172        if self.existing_blob_path(&hash).is_some() {
173            return Ok(false);
174        }
175
176        // Create parent directory if needed
177        if let Some(parent) = path.parent() {
178            fs::create_dir_all(parent)?;
179        }
180
181        // Write atomically using temp file + rename
182        let temp_path = path.with_extension("tmp");
183        fs::write(&temp_path, data)?;
184        fs::rename(&temp_path, &path)?;
185
186        Ok(true)
187    }
188
189    /// Sync get operation.
190    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
191        if let Some(path) = self.existing_blob_path(hash) {
192            Ok(Some(fs::read(&path)?))
193        } else {
194            Ok(None)
195        }
196    }
197
198    pub fn get_range_sync(
199        &self,
200        hash: &Hash,
201        start: u64,
202        end_inclusive: u64,
203    ) -> Result<Option<Vec<u8>>, StoreError> {
204        let Some(path) = self.existing_blob_path(hash) else {
205            return Ok(None);
206        };
207
208        let mut file = fs::File::open(path)?;
209        let len = file.metadata()?.len();
210        if len == 0 || start >= len || end_inclusive < start {
211            return Ok(Some(Vec::new()));
212        }
213
214        let actual_end = end_inclusive.min(len - 1);
215        let read_len = actual_end.saturating_sub(start).saturating_add(1);
216        let read_len = usize::try_from(read_len)
217            .map_err(|_| StoreError::Other("blob range is too large to read".to_string()))?;
218        let mut data = vec![0; read_len];
219        file.seek(SeekFrom::Start(start))?;
220        file.read_exact(&mut data)?;
221        Ok(Some(data))
222    }
223
224    pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
225        let Some(path) = self.existing_blob_path(hash) else {
226            return Ok(None);
227        };
228        Ok(Some(fs::metadata(path)?.len()))
229    }
230
231    pub fn touch_accessed_sync(&self, _hash: &Hash, _now: u64) -> Result<bool, StoreError> {
232        Ok(false)
233    }
234
235    pub fn touch_many_accessed_sync(
236        &self,
237        _hashes: &[Hash],
238        _now: u64,
239    ) -> Result<usize, StoreError> {
240        Ok(0)
241    }
242
243    pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
244        let Some(path) = self.existing_blob_path(hash) else {
245            return Ok(None);
246        };
247        let metadata = fs::metadata(path)?;
248        Ok(Some(system_time_to_unix_secs(
249            metadata.accessed().or_else(|_| metadata.modified())?,
250        )))
251    }
252
253    pub fn many_last_accessed_at_sync(
254        &self,
255        hashes: &[Hash],
256    ) -> Result<Vec<(Hash, u64)>, StoreError> {
257        let mut results = Vec::new();
258        for hash in hashes {
259            if let Some(last_accessed_at) = self.last_accessed_at_sync(hash)? {
260                results.push((*hash, last_accessed_at));
261            }
262        }
263        Ok(results)
264    }
265
266    /// Check if a hash exists.
267    pub fn exists(&self, hash: &Hash) -> bool {
268        self.existing_blob_path(hash).is_some()
269    }
270
271    /// Sync delete operation.
272    pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
273        let primary = self.blob_path(hash);
274        let legacy = self.legacy_blob_path(hash);
275        let mut deleted = false;
276
277        for path in [primary, legacy] {
278            if path.exists() {
279                fs::remove_file(path)?;
280                deleted = true;
281            }
282        }
283
284        Ok(deleted)
285    }
286
287    /// List all hashes in the store.
288    pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
289        let mut hashes = Vec::new();
290
291        for (_, full_hex, _) in self.collect_blob_metadata()? {
292            if let Ok(bytes) = hex::decode(&full_hex) {
293                if bytes.len() == 32 {
294                    let mut hash = [0u8; 32];
295                    hash.copy_from_slice(&bytes);
296                    hashes.push(hash);
297                }
298            }
299        }
300
301        Ok(hashes)
302    }
303
304    /// Get storage statistics.
305    pub fn stats(&self) -> Result<FsStats, StoreError> {
306        let pins = self.pins.read().unwrap();
307        let mut count = 0usize;
308        let mut total_bytes = 0u64;
309        let mut pinned_count = 0usize;
310        let mut pinned_bytes = 0u64;
311
312        for (_, hex, metadata) in self.collect_blob_metadata()? {
313            let size = metadata.len();
314            count += 1;
315            total_bytes += size;
316
317            if pins.get(&hex).copied().unwrap_or(0) > 0 {
318                pinned_count += 1;
319                pinned_bytes += size;
320            }
321        }
322
323        Ok(FsStats {
324            count,
325            total_bytes,
326            pinned_count,
327            pinned_bytes,
328        })
329    }
330
331    /// Collect all blobs with their mtime and size for eviction
332    fn collect_blobs_for_eviction(&self) -> Vec<(PathBuf, String, SystemTime, u64)> {
333        self.collect_blob_metadata()
334            .map(|blobs| {
335                blobs
336                    .into_iter()
337                    .map(|(path, hex, metadata)| {
338                        let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
339                        let size = metadata.len();
340                        (path, hex, mtime, size)
341                    })
342                    .collect()
343            })
344            .unwrap_or_default()
345    }
346
347    /// Evict unpinned blobs until storage is under target_bytes
348    fn evict_to_target(&self, target_bytes: u64) -> u64 {
349        let pins = self.pins.read().unwrap();
350
351        // Collect all blobs
352        let mut blobs = self.collect_blobs_for_eviction();
353
354        // Filter to unpinned only
355        blobs.retain(|(_, hex, _, _)| pins.get(hex).copied().unwrap_or(0) == 0);
356
357        // Sort by mtime (oldest first)
358        blobs.sort_by_key(|(_, _, mtime, _)| *mtime);
359
360        drop(pins); // Release lock before deleting
361
362        // Calculate current total
363        let current_bytes: u64 = self
364            .collect_blobs_for_eviction()
365            .iter()
366            .map(|(_, _, _, size)| *size)
367            .sum();
368
369        if current_bytes <= target_bytes {
370            return 0;
371        }
372
373        let to_free = current_bytes - target_bytes;
374        let mut freed = 0u64;
375
376        for (path, _, _, size) in blobs {
377            if freed >= to_free {
378                break;
379            }
380            if fs::remove_file(&path).is_ok() {
381                freed += size;
382            }
383        }
384
385        freed
386    }
387}
388
389fn system_time_to_unix_secs(time: SystemTime) -> u64 {
390    time.duration_since(UNIX_EPOCH)
391        .unwrap_or_default()
392        .as_secs()
393}
394
395/// Storage statistics.
396#[derive(Debug, Clone)]
397pub struct FsStats {
398    pub count: usize,
399    pub total_bytes: u64,
400    pub pinned_count: usize,
401    pub pinned_bytes: u64,
402}
403
404#[async_trait]
405impl Store for FsBlobStore {
406    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
407        self.put_sync(hash, &data)
408    }
409
410    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
411        self.get_sync(hash)
412    }
413
414    async fn get_range(
415        &self,
416        hash: &Hash,
417        start: u64,
418        end_inclusive: u64,
419    ) -> Result<Option<Vec<u8>>, StoreError> {
420        self.get_range_sync(hash, start, end_inclusive)
421    }
422
423    async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
424        self.blob_size_sync(hash)
425    }
426
427    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
428        Ok(self.exists(hash))
429    }
430
431    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
432        let hex = hex::encode(hash);
433        // Remove pin entry if exists
434        {
435            let mut pins = self.pins.write().unwrap();
436            pins.remove(&hex);
437        }
438        let _ = self.save_pins(); // Best effort
439        self.delete_sync(hash)
440    }
441
442    fn set_max_bytes(&self, max: u64) {
443        self.max_bytes.store(max, Ordering::Relaxed);
444    }
445
446    fn max_bytes(&self) -> Option<u64> {
447        let max = self.max_bytes.load(Ordering::Relaxed);
448        if max > 0 {
449            Some(max)
450        } else {
451            None
452        }
453    }
454
455    async fn stats(&self) -> StoreStats {
456        match self.stats() {
457            Ok(fs_stats) => StoreStats {
458                count: fs_stats.count as u64,
459                bytes: fs_stats.total_bytes,
460                pinned_count: fs_stats.pinned_count as u64,
461                pinned_bytes: fs_stats.pinned_bytes,
462            },
463            Err(_) => StoreStats::default(),
464        }
465    }
466
467    async fn evict_if_needed(&self) -> Result<u64, StoreError> {
468        let max = self.max_bytes.load(Ordering::Relaxed);
469        if max == 0 {
470            return Ok(0); // No limit set
471        }
472
473        let current = match self.stats() {
474            Ok(s) => s.total_bytes,
475            Err(_) => return Ok(0),
476        };
477
478        if current <= max {
479            return Ok(0);
480        }
481
482        // Evict to 90% of max
483        let target = max * 9 / 10;
484        Ok(self.evict_to_target(target))
485    }
486
487    async fn pin(&self, hash: &Hash) -> Result<(), StoreError> {
488        let hex = hex::encode(hash);
489        {
490            let mut pins = self.pins.write().unwrap();
491            *pins.entry(hex).or_insert(0) += 1;
492        }
493        self.save_pins()
494    }
495
496    async fn unpin(&self, hash: &Hash) -> Result<(), StoreError> {
497        let hex = hex::encode(hash);
498        {
499            let mut pins = self.pins.write().unwrap();
500            if let Some(count) = pins.get_mut(&hex) {
501                if *count > 0 {
502                    *count -= 1;
503                }
504                if *count == 0 {
505                    pins.remove(&hex);
506                }
507            }
508        }
509        self.save_pins()
510    }
511
512    fn pin_count(&self, hash: &Hash) -> u32 {
513        let hex = hex::encode(hash);
514        self.pins.read().unwrap().get(&hex).copied().unwrap_or(0)
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use hashtree_core::sha256;
522    use tempfile::TempDir;
523
524    #[tokio::test]
525    async fn test_put_get() {
526        let temp = TempDir::new().unwrap();
527        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
528
529        let data = b"hello filesystem";
530        let hash = sha256(data);
531        store.put(hash, data.to_vec()).await.unwrap();
532
533        assert!(store.has(&hash).await.unwrap());
534        assert_eq!(store.get(&hash).await.unwrap(), Some(data.to_vec()));
535    }
536
537    #[tokio::test]
538    async fn test_get_missing() {
539        let temp = TempDir::new().unwrap();
540        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
541
542        let hash = [0u8; 32];
543        assert!(!store.has(&hash).await.unwrap());
544        assert_eq!(store.get(&hash).await.unwrap(), None);
545    }
546
547    #[tokio::test]
548    async fn test_delete() {
549        let temp = TempDir::new().unwrap();
550        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
551
552        let data = b"delete me";
553        let hash = sha256(data);
554        store.put(hash, data.to_vec()).await.unwrap();
555        assert!(store.has(&hash).await.unwrap());
556
557        assert!(store.delete(&hash).await.unwrap());
558        assert!(!store.has(&hash).await.unwrap());
559        assert!(!store.delete(&hash).await.unwrap());
560    }
561
562    #[tokio::test]
563    async fn test_deduplication() {
564        let temp = TempDir::new().unwrap();
565        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
566
567        let data = b"same content";
568        let hash = sha256(data);
569
570        // First put returns true (newly stored)
571        assert!(store.put(hash, data.to_vec()).await.unwrap());
572        // Second put returns false (already existed)
573        assert!(!store.put(hash, data.to_vec()).await.unwrap());
574
575        assert_eq!(store.list().unwrap().len(), 1);
576    }
577
578    #[tokio::test]
579    async fn test_list() {
580        let temp = TempDir::new().unwrap();
581        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
582
583        let d1 = b"one";
584        let d2 = b"two";
585        let d3 = b"three";
586        let h1 = sha256(d1);
587        let h2 = sha256(d2);
588        let h3 = sha256(d3);
589
590        store.put(h1, d1.to_vec()).await.unwrap();
591        store.put(h2, d2.to_vec()).await.unwrap();
592        store.put(h3, d3.to_vec()).await.unwrap();
593
594        let hashes = store.list().unwrap();
595        assert_eq!(hashes.len(), 3);
596        assert!(hashes.contains(&h1));
597        assert!(hashes.contains(&h2));
598        assert!(hashes.contains(&h3));
599    }
600
601    #[tokio::test]
602    async fn test_stats() {
603        let temp = TempDir::new().unwrap();
604        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
605
606        let d1 = b"hello";
607        let d2 = b"world";
608        let h1 = sha256(d1);
609        store.put(h1, d1.to_vec()).await.unwrap();
610        store.put(sha256(d2), d2.to_vec()).await.unwrap();
611
612        let stats = store.stats().unwrap();
613        assert_eq!(stats.count, 2);
614        assert_eq!(stats.total_bytes, 10);
615        assert_eq!(stats.pinned_count, 0);
616        assert_eq!(stats.pinned_bytes, 0);
617
618        // Pin one item and check stats
619        store.pin(&h1).await.unwrap();
620        let stats = store.stats().unwrap();
621        assert_eq!(stats.pinned_count, 1);
622        assert_eq!(stats.pinned_bytes, 5);
623    }
624
625    #[tokio::test]
626    async fn test_directory_structure() {
627        let temp = TempDir::new().unwrap();
628        let blobs_path = temp.path().join("blobs");
629        let store = FsBlobStore::new(&blobs_path).unwrap();
630
631        let data = b"test data";
632        let hash = sha256(data);
633        let hex = hex::encode(hash);
634
635        store.put(hash, data.to_vec()).await.unwrap();
636
637        // Verify the file exists at the correct path
638        let prefix = &hex[..2];
639        let subdir = &hex[2..4];
640        let rest = &hex[4..];
641        let expected_path = blobs_path.join(prefix).join(subdir).join(rest);
642
643        assert!(
644            expected_path.exists(),
645            "Blob should be at {:?}",
646            expected_path
647        );
648        assert_eq!(fs::read(&expected_path).unwrap(), data);
649    }
650
651    #[test]
652    fn test_blob_path_format() {
653        let temp = TempDir::new().unwrap();
654        let store = FsBlobStore::new(temp.path()).unwrap();
655
656        // Hash: 0x00112233...
657        let mut hash = [0u8; 32];
658        hash[0] = 0x00;
659        hash[1] = 0x11;
660        hash[2] = 0x22;
661
662        let path = store.blob_path(&hash);
663        let path_str = path.to_string_lossy();
664
665        // Should have "00" as directory prefix
666        assert!(
667            path_str.contains("/00/"),
668            "Path should contain /00/ directory: {}",
669            path_str
670        );
671        // Should also include a second shard level based on the next byte.
672        assert!(
673            path_str.contains("/11/"),
674            "Path should contain /11/ directory: {}",
675            path_str
676        );
677        // File name should be remaining 60 chars
678        assert!(path.file_name().unwrap().len() == 60);
679    }
680
681    #[tokio::test]
682    async fn test_legacy_single_level_layout_remains_readable() {
683        let temp = TempDir::new().unwrap();
684        let blobs_path = temp.path().join("blobs");
685        let store = FsBlobStore::new(&blobs_path).unwrap();
686
687        let data = b"legacy blob";
688        let hash = sha256(data);
689        let hex = hex::encode(hash);
690        let legacy_path = blobs_path.join(&hex[..2]).join(&hex[2..]);
691        fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
692        fs::write(&legacy_path, data).unwrap();
693
694        assert!(store.has(&hash).await.unwrap());
695        assert_eq!(store.get(&hash).await.unwrap(), Some(data.to_vec()));
696
697        let listed = store.list().unwrap();
698        assert_eq!(listed, vec![hash]);
699
700        let stats = store.stats().unwrap();
701        assert_eq!(stats.count, 1);
702        assert_eq!(stats.total_bytes, data.len() as u64);
703
704        assert!(!store.put(hash, data.to_vec()).await.unwrap());
705        assert!(store.delete(&hash).await.unwrap());
706        assert!(!legacy_path.exists());
707    }
708
709    #[tokio::test]
710    async fn test_empty_store_stats() {
711        let temp = TempDir::new().unwrap();
712        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
713
714        let stats = store.stats().unwrap();
715        assert_eq!(stats.count, 0);
716        assert_eq!(stats.total_bytes, 0);
717    }
718
719    #[tokio::test]
720    async fn test_empty_store_list() {
721        let temp = TempDir::new().unwrap();
722        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
723
724        let hashes = store.list().unwrap();
725        assert!(hashes.is_empty());
726    }
727
728    #[tokio::test]
729    async fn test_pin_and_unpin() {
730        let temp = TempDir::new().unwrap();
731        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
732
733        let data = b"pin me";
734        let hash = sha256(data);
735        store.put(hash, data.to_vec()).await.unwrap();
736
737        // Initially not pinned
738        assert!(!store.is_pinned(&hash));
739        assert_eq!(store.pin_count(&hash), 0);
740
741        // Pin
742        store.pin(&hash).await.unwrap();
743        assert!(store.is_pinned(&hash));
744        assert_eq!(store.pin_count(&hash), 1);
745
746        // Unpin
747        store.unpin(&hash).await.unwrap();
748        assert!(!store.is_pinned(&hash));
749        assert_eq!(store.pin_count(&hash), 0);
750    }
751
752    #[tokio::test]
753    async fn test_pin_ref_counting() {
754        let temp = TempDir::new().unwrap();
755        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
756
757        let data = b"multi pin";
758        let hash = sha256(data);
759        store.put(hash, data.to_vec()).await.unwrap();
760
761        // Pin multiple times
762        store.pin(&hash).await.unwrap();
763        store.pin(&hash).await.unwrap();
764        store.pin(&hash).await.unwrap();
765        assert_eq!(store.pin_count(&hash), 3);
766
767        // Unpin once
768        store.unpin(&hash).await.unwrap();
769        assert_eq!(store.pin_count(&hash), 2);
770        assert!(store.is_pinned(&hash));
771
772        // Unpin remaining
773        store.unpin(&hash).await.unwrap();
774        store.unpin(&hash).await.unwrap();
775        assert_eq!(store.pin_count(&hash), 0);
776    }
777
778    #[tokio::test]
779    async fn test_pins_persist_across_reload() {
780        let temp = TempDir::new().unwrap();
781        let blobs_path = temp.path().join("blobs");
782
783        let data = b"persist me";
784        let hash = sha256(data);
785
786        // Create store and pin
787        {
788            let store = FsBlobStore::new(&blobs_path).unwrap();
789            store.put(hash, data.to_vec()).await.unwrap();
790            store.pin(&hash).await.unwrap();
791            store.pin(&hash).await.unwrap();
792            assert_eq!(store.pin_count(&hash), 2);
793        }
794
795        // Reload store
796        {
797            let store = FsBlobStore::new(&blobs_path).unwrap();
798            assert_eq!(store.pin_count(&hash), 2);
799            assert!(store.is_pinned(&hash));
800        }
801    }
802
803    #[tokio::test]
804    async fn test_max_bytes() {
805        let temp = TempDir::new().unwrap();
806        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
807
808        assert!(store.max_bytes().is_none());
809
810        store.set_max_bytes(1000);
811        assert_eq!(store.max_bytes(), Some(1000));
812
813        store.set_max_bytes(0);
814        assert!(store.max_bytes().is_none());
815    }
816
817    #[tokio::test]
818    async fn test_with_max_bytes() {
819        let temp = TempDir::new().unwrap();
820        let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 500).unwrap();
821        assert_eq!(store.max_bytes(), Some(500));
822    }
823
824    #[tokio::test]
825    async fn test_eviction_respects_pins() {
826        let temp = TempDir::new().unwrap();
827        // 20 byte limit
828        let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 20).unwrap();
829
830        // Add items (5 bytes each = 15 total)
831        let d1 = b"aaaaa"; // oldest - will be pinned
832        let d2 = b"bbbbb";
833        let d3 = b"ccccc";
834        let h1 = sha256(d1);
835        let h2 = sha256(d2);
836        let h3 = sha256(d3);
837
838        store.put(h1, d1.to_vec()).await.unwrap();
839        std::thread::sleep(std::time::Duration::from_millis(10)); // Ensure different mtime
840        store.put(h2, d2.to_vec()).await.unwrap();
841        std::thread::sleep(std::time::Duration::from_millis(10));
842        store.put(h3, d3.to_vec()).await.unwrap();
843
844        // Pin the oldest
845        store.pin(&h1).await.unwrap();
846
847        // Add more to exceed limit (15 + 5 = 20, at limit)
848        let d4 = b"ddddd";
849        let h4 = sha256(d4);
850        std::thread::sleep(std::time::Duration::from_millis(10));
851        store.put(h4, d4.to_vec()).await.unwrap();
852
853        // Add one more to exceed (20 + 5 = 25 > 20)
854        let d5 = b"eeeee";
855        let h5 = sha256(d5);
856        std::thread::sleep(std::time::Duration::from_millis(10));
857        store.put(h5, d5.to_vec()).await.unwrap();
858
859        // Evict
860        let freed = store.evict_if_needed().await.unwrap();
861        assert!(freed > 0, "Should have freed some bytes");
862
863        // Pinned item should still exist
864        assert!(store.has(&h1).await.unwrap(), "Pinned item should exist");
865        // Oldest unpinned (h2) should be evicted
866        assert!(
867            !store.has(&h2).await.unwrap(),
868            "Oldest unpinned should be evicted"
869        );
870        // Newest should exist
871        assert!(store.has(&h5).await.unwrap(), "Newest should exist");
872    }
873
874    #[tokio::test]
875    async fn test_no_eviction_when_under_limit() {
876        let temp = TempDir::new().unwrap();
877        let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 1000).unwrap();
878
879        let data = b"small";
880        let hash = sha256(data);
881        store.put(hash, data.to_vec()).await.unwrap();
882
883        let freed = store.evict_if_needed().await.unwrap();
884        assert_eq!(freed, 0);
885        assert!(store.has(&hash).await.unwrap());
886    }
887
888    #[tokio::test]
889    async fn test_no_eviction_without_limit() {
890        let temp = TempDir::new().unwrap();
891        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
892
893        for i in 0..10u8 {
894            let data = vec![i; 100];
895            let hash = sha256(&data);
896            store.put(hash, data).await.unwrap();
897        }
898
899        let freed = store.evict_if_needed().await.unwrap();
900        assert_eq!(freed, 0);
901        assert_eq!(store.list().unwrap().len(), 10);
902    }
903
904    #[tokio::test]
905    async fn test_delete_removes_pin() {
906        let temp = TempDir::new().unwrap();
907        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
908
909        let data = b"delete pinned";
910        let hash = sha256(data);
911        store.put(hash, data.to_vec()).await.unwrap();
912        store.pin(&hash).await.unwrap();
913        assert!(store.is_pinned(&hash));
914
915        store.delete(&hash).await.unwrap();
916        assert_eq!(store.pin_count(&hash), 0);
917    }
918}