oxur_repl/cache/
artifact.rs

1//! Persistent artifact cache with SHA256 content addressing
2//!
3//! Provides global disk-based caching of compiled dynamic libraries
4//! with content-addressed storage for deduplication and fast lookups.
5//!
6//! Based on ODD-0026 Section 5.1 (Global Artifact Cache)
7
8use fs2::FileExt;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::collections::HashMap;
12use std::fs::{self, OpenOptions};
13use std::path::{Path, PathBuf};
14use thiserror::Error;
15
16/// Cache errors
17#[derive(Debug, Error)]
18pub enum CacheError {
19    #[error("Failed to initialize cache directory: {0}")]
20    InitFailed(#[from] std::io::Error),
21
22    #[error("Cache directory not found")]
23    NotFound,
24
25    #[error("Failed to load cache index: {0}")]
26    IndexLoadFailed(String),
27
28    #[error("Failed to save cache index: {0}")]
29    IndexSaveFailed(String),
30
31    #[error("Artifact not found for key: {0}")]
32    ArtifactNotFound(String),
33
34    #[error("Failed to copy artifact: {0}")]
35    CopyFailed(String),
36}
37
38pub type Result<T> = std::result::Result<T, CacheError>;
39
40/// Cache index entry
41#[derive(Debug, Clone, Serialize, Deserialize)]
42struct CacheEntry {
43    /// SHA256 hash key
44    key: String,
45
46    /// Path to cached artifact
47    path: PathBuf,
48
49    /// Timestamp when cached
50    cached_at: u64,
51
52    /// Size in bytes
53    size_bytes: u64,
54}
55
56/// Cache index stored on disk
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58struct CacheIndex {
59    /// Map from cache key to entry
60    entries: HashMap<String, CacheEntry>,
61
62    /// Index format version
63    version: u32,
64}
65
66/// Detailed cache statistics
67#[derive(Debug, Clone)]
68pub struct CacheStats {
69    /// Number of entries in cache
70    pub entry_count: usize,
71
72    /// Total size of all cached artifacts in bytes
73    pub total_size_bytes: u64,
74
75    /// Timestamp of oldest entry (seconds since epoch)
76    pub oldest_entry_secs: u64,
77
78    /// Timestamp of newest entry (seconds since epoch)
79    pub newest_entry_secs: u64,
80
81    /// Cache directory path
82    pub cache_dir: PathBuf,
83}
84
85/// Global artifact cache
86///
87/// Provides persistent caching of compiled dynamic libraries using
88/// SHA256 content-addressed storage. The cache is shared across all
89/// REPL sessions and persists across restarts.
90///
91/// # Cache Key Algorithm (ODD-0026)
92///
93/// ```text
94/// SHA256(
95///     source_code +
96///     dependencies +
97///     optimization_level +
98///     source_map_config
99/// )
100/// ```
101///
102/// # Directory Structure
103///
104/// ```text
105/// ~/.cache/oxur/artifacts/
106/// ├── index.json                 # Cache index
107/// ├── <sha256-1>/
108/// │   ├── lib.so                 # Compiled artifact (Linux)
109/// │   └── metadata.json          # Entry metadata
110/// ├── <sha256-2>/
111/// │   ├── lib.dylib              # Compiled artifact (macOS)
112/// │   └── metadata.json
113/// └── ...
114/// ```
115///
116/// # Examples
117///
118/// ```
119/// use oxur_repl::cache::ArtifactCache;
120///
121/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
122/// let mut cache = ArtifactCache::new()?;
123///
124/// // Generate cache key from source
125/// let key = cache.generate_key("(+ 1 2)", &[], 0, "default");
126///
127/// // Check if artifact exists
128/// if let Some(path) = cache.get(&key)? {
129///     println!("Cache hit: {:?}", path);
130/// } else {
131///     println!("Cache miss, need to compile");
132/// }
133/// # Ok(())
134/// # }
135/// ```
136#[derive(Debug, Clone)]
137pub struct ArtifactCache {
138    /// Cache root directory
139    cache_dir: PathBuf,
140
141    /// In-memory index
142    index: CacheIndex,
143}
144
145impl ArtifactCache {
146    /// Create or open the global artifact cache
147    ///
148    /// Uses platform-specific cache directory:
149    /// - Linux: `~/.cache/oxur/artifacts/`
150    /// - macOS: `~/Library/Caches/oxur/artifacts/`
151    /// - Windows: `%LOCALAPPDATA%\oxur\artifacts\`
152    ///
153    /// Can be overridden with `OXUR_CACHE_DIR` environment variable.
154    ///
155    /// # Errors
156    ///
157    /// Returns error if cache directory cannot be created or index cannot be loaded
158    pub fn new() -> Result<Self> {
159        let cache_dir = Self::get_cache_dir()?;
160        fs::create_dir_all(&cache_dir)?;
161
162        let index = Self::load_index(&cache_dir)?;
163
164        Ok(Self { cache_dir, index })
165    }
166
167    /// Create a new artifact cache with a specific directory
168    ///
169    /// This is primarily useful for testing with isolated cache directories.
170    ///
171    /// # Arguments
172    ///
173    /// * `cache_dir` - Path to use as the cache directory
174    ///
175    /// # Errors
176    ///
177    /// Returns error if cache directory cannot be created or index cannot be loaded
178    pub fn with_directory(cache_dir: impl AsRef<Path>) -> Result<Self> {
179        let cache_dir = cache_dir.as_ref().to_path_buf();
180        fs::create_dir_all(&cache_dir)?;
181
182        let index = Self::load_index(&cache_dir)?;
183
184        Ok(Self { cache_dir, index })
185    }
186
187    /// Get the cache directory path
188    fn get_cache_dir() -> Result<PathBuf> {
189        // Check environment variable override
190        if let Ok(dir) = std::env::var("OXUR_CACHE_DIR") {
191            return Ok(PathBuf::from(dir).join("artifacts"));
192        }
193
194        // Use platform-specific cache directory
195        dirs::cache_dir().ok_or(CacheError::NotFound).map(|dir| dir.join("oxur").join("artifacts"))
196    }
197
198    /// Load cache index from disk with file locking
199    ///
200    /// Uses shared (read) locks on a lock file to allow concurrent reads while
201    /// preventing reads during writes. This prevents race conditions where the file
202    /// is read while being written (which causes "EOF while parsing" errors).
203    fn load_index(cache_dir: &Path) -> Result<CacheIndex> {
204        let index_path = cache_dir.join("index.json");
205
206        if !index_path.exists() {
207            // No index yet, create empty
208            return Ok(CacheIndex { version: 1, ..Default::default() });
209        }
210
211        // Use the same lock file as save_index for proper synchronization
212        let lock_path = cache_dir.join(".index.lock");
213        let lock_file =
214            OpenOptions::new().write(true).create(true).truncate(false).open(&lock_path).map_err(
215                |e| CacheError::IndexLoadFailed(format!("Failed to open lock file: {}", e)),
216            )?;
217
218        // Acquire shared lock (multiple readers allowed, blocks writers)
219        lock_file
220            .lock_shared()
221            .map_err(|e| CacheError::IndexLoadFailed(format!("Failed to lock index: {}", e)))?;
222
223        // Read the index file (now protected by lock)
224        let content = fs::read_to_string(&index_path)
225            .map_err(|e| CacheError::IndexLoadFailed(format!("Failed to read index: {}", e)))?;
226
227        // Parse JSON
228        let index = serde_json::from_str(&content).map_err(|e| {
229            CacheError::IndexLoadFailed(format!("Failed to parse index JSON: {}", e))
230        })?;
231
232        // Lock is automatically released when lock_file is dropped
233        Ok(index)
234    }
235
236    /// Save cache index to disk with atomic write and file locking
237    ///
238    /// Uses a separate lock file with exclusive locks and atomic file operations
239    /// (write-to-temp + rename) to prevent concurrent modifications and ensure
240    /// readers never see partial writes.
241    ///
242    /// # Implementation Details
243    ///
244    /// 1. Ensure cache directory exists
245    /// 2. Serialize index to JSON string
246    /// 3. Acquire exclusive lock on .index.lock
247    /// 4. Write to temporary file (.index.json.tmp)
248    /// 5. Atomically rename temp file to index.json
249    ///
250    /// This ensures that:
251    /// - Writers don't interfere with each other (exclusive lock on .index.lock)
252    /// - Readers never see partial writes (atomic rename)
253    /// - No "EOF while parsing" errors from concurrent access
254    /// - Lock file approach avoids holding handle to index.json during rename
255    fn save_index(&self) -> Result<()> {
256        // Ensure cache directory exists (it might not if this is the first save)
257        fs::create_dir_all(&self.cache_dir)?;
258
259        let index_path = self.cache_dir.join("index.json");
260        let temp_path = self.cache_dir.join(".index.json.tmp");
261
262        // Serialize to JSON
263        let content = serde_json::to_string_pretty(&self.index).map_err(|e| {
264            CacheError::IndexSaveFailed(format!("Failed to serialize index: {}", e))
265        })?;
266
267        // Use a separate lock file to avoid holding a handle to the index file
268        // during rename (which fails on some systems)
269        let lock_path = self.cache_dir.join(".index.lock");
270        let lock_file =
271            OpenOptions::new().write(true).create(true).truncate(false).open(&lock_path).map_err(
272                |e| CacheError::IndexSaveFailed(format!("Failed to open lock file: {}", e)),
273            )?;
274
275        // Acquire exclusive lock (blocks all other readers and writers)
276        lock_file
277            .lock_exclusive()
278            .map_err(|e| CacheError::IndexSaveFailed(format!("Failed to lock index: {}", e)))?;
279
280        // Write to temporary file (now protected by lock)
281        fs::write(&temp_path, &content).map_err(|e| {
282            CacheError::IndexSaveFailed(format!("Failed to write temp file: {}", e))
283        })?;
284
285        // Atomically replace the index file with the temp file
286        // This is atomic on Unix and mostly-atomic on Windows
287        fs::rename(&temp_path, &index_path).map_err(|e| {
288            CacheError::IndexSaveFailed(format!("Failed to rename temp file: {}", e))
289        })?;
290
291        // Lock is automatically released when lock_file is dropped
292        Ok(())
293    }
294
295    /// Generate cache key from compilation inputs
296    ///
297    /// # Arguments
298    ///
299    /// * `source` - Source code
300    /// * `dependencies` - List of dependency names and versions
301    /// * `opt_level` - Optimization level (0-3)
302    /// * `source_map_config` - Source map configuration identifier
303    ///
304    /// # Returns
305    ///
306    /// SHA256 hash as hex string
307    pub fn generate_key(
308        &self,
309        source: impl AsRef<str>,
310        dependencies: &[(&str, &str)],
311        opt_level: u8,
312        source_map_config: impl AsRef<str>,
313    ) -> String {
314        let mut hasher = Sha256::new();
315
316        // Hash source code
317        hasher.update(source.as_ref().as_bytes());
318
319        // Hash dependencies (sorted for consistency)
320        let mut deps = dependencies.to_vec();
321        deps.sort();
322        for (name, version) in deps {
323            hasher.update(name.as_bytes());
324            hasher.update(version.as_bytes());
325        }
326
327        // Hash optimization level
328        hasher.update([opt_level]);
329
330        // Hash source map config
331        hasher.update(source_map_config.as_ref().as_bytes());
332
333        format!("{:x}", hasher.finalize())
334    }
335
336    /// Get cached artifact by key
337    ///
338    /// Returns the path to the cached dynamic library if it exists.
339    ///
340    /// # Arguments
341    ///
342    /// * `key` - SHA256 cache key
343    ///
344    /// # Returns
345    ///
346    /// `Some(path)` if artifact exists, `None` otherwise
347    pub fn get(&self, key: impl AsRef<str>) -> Result<Option<PathBuf>> {
348        let key = key.as_ref();
349
350        if let Some(entry) = self.index.entries.get(key) {
351            if entry.path.exists() {
352                Ok(Some(entry.path.clone()))
353            } else {
354                // Entry exists but file is missing - index is stale
355                Ok(None)
356            }
357        } else {
358            Ok(None)
359        }
360    }
361
362    /// Insert artifact into cache
363    ///
364    /// Copies the artifact to cache directory and updates the index.
365    ///
366    /// # Arguments
367    ///
368    /// * `key` - SHA256 cache key
369    /// * `artifact_path` - Path to compiled artifact
370    ///
371    /// # Errors
372    ///
373    /// Returns error if artifact cannot be copied or index cannot be saved
374    pub fn insert(&mut self, key: impl AsRef<str>, artifact_path: &Path) -> Result<PathBuf> {
375        let key = key.as_ref();
376
377        // Create cache directory for this artifact
378        let cache_path = self.cache_dir.join(key);
379        fs::create_dir_all(&cache_path)?;
380
381        // Determine artifact filename based on platform
382        let artifact_filename = Self::artifact_filename();
383        let cached_artifact = cache_path.join(artifact_filename);
384
385        // Copy artifact to cache
386        fs::copy(artifact_path, &cached_artifact).map_err(|e| {
387            CacheError::CopyFailed(format!(
388                "Failed to copy {} to {}: {}",
389                artifact_path.display(),
390                cached_artifact.display(),
391                e
392            ))
393        })?;
394
395        // Get artifact metadata
396        let metadata = fs::metadata(&cached_artifact)?;
397        let size_bytes = metadata.len();
398        let cached_at = std::time::SystemTime::now()
399            .duration_since(std::time::UNIX_EPOCH)
400            .expect("System time is before UNIX epoch")
401            .as_secs();
402
403        // Update index
404        let entry = CacheEntry {
405            key: key.to_string(),
406            path: cached_artifact.clone(),
407            cached_at,
408            size_bytes,
409        };
410
411        self.index.entries.insert(key.to_string(), entry);
412        self.save_index()?;
413
414        // Check if we need to evict old entries to stay under size limit
415        let _ = self.check_and_evict(); // Best effort, don't fail insert on eviction issues
416
417        Ok(cached_artifact)
418    }
419
420    /// Get platform-specific artifact filename
421    #[cfg(target_os = "linux")]
422    fn artifact_filename() -> &'static str {
423        "lib.so"
424    }
425
426    #[cfg(target_os = "macos")]
427    fn artifact_filename() -> &'static str {
428        "lib.dylib"
429    }
430
431    #[cfg(target_os = "windows")]
432    fn artifact_filename() -> &'static str {
433        "lib.dll"
434    }
435
436    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
437    fn artifact_filename() -> &'static str {
438        "lib.so" // Default fallback
439    }
440
441    /// Clear all cached artifacts
442    ///
443    /// Removes all artifacts from cache directory and resets index.
444    pub fn clear(&mut self) -> Result<()> {
445        // Remove all artifact directories
446        for entry in self.index.entries.values() {
447            if let Some(parent) = entry.path.parent() {
448                let _ = fs::remove_dir_all(parent);
449            }
450        }
451
452        // Reset index
453        self.index.entries.clear();
454        self.save_index()?;
455
456        Ok(())
457    }
458
459    /// Get cache statistics
460    ///
461    /// Returns (entry_count, total_size_bytes)
462    pub fn stats(&self) -> (usize, u64) {
463        let count = self.index.entries.len();
464        let total_size = self.index.entries.values().map(|e| e.size_bytes).sum();
465        (count, total_size)
466    }
467
468    /// Get detailed cache statistics
469    ///
470    /// Returns comprehensive statistics including oldest/newest entries
471    pub fn detailed_stats(&self) -> CacheStats {
472        let count = self.index.entries.len();
473        let total_size = self.index.entries.values().map(|e| e.size_bytes).sum();
474
475        let oldest_entry_secs = self.index.entries.values().map(|e| e.cached_at).min().unwrap_or(0);
476
477        let newest_entry_secs = self.index.entries.values().map(|e| e.cached_at).max().unwrap_or(0);
478
479        CacheStats {
480            entry_count: count,
481            total_size_bytes: total_size,
482            oldest_entry_secs,
483            newest_entry_secs,
484            cache_dir: self.cache_dir.clone(),
485        }
486    }
487
488    /// List all cache keys
489    pub fn keys(&self) -> Vec<String> {
490        self.index.entries.keys().cloned().collect()
491    }
492
493    /// Evict least recently used cache entries
494    ///
495    /// Removes oldest entries until total cache size is below the limit.
496    /// Default limit is 1GB. Can be configured via `OXUR_CACHE_MAX_SIZE_MB` environment variable.
497    ///
498    /// # Arguments
499    ///
500    /// * `max_size_bytes` - Maximum allowed cache size in bytes. If None, uses default (1GB)
501    ///
502    /// # Returns
503    ///
504    /// Number of entries evicted
505    ///
506    /// # Examples
507    ///
508    /// ```no_run
509    /// use oxur_repl::cache::ArtifactCache;
510    ///
511    /// let mut cache = ArtifactCache::new()?;
512    /// let evicted = cache.evict_lru(Some(100 * 1024 * 1024))?; // Limit to 100MB
513    /// println!("Evicted {} cache entries", evicted);
514    /// # Ok::<(), Box<dyn std::error::Error>>(())
515    /// ```
516    pub fn evict_lru(&mut self, max_size_bytes: Option<u64>) -> Result<usize> {
517        // Get max size from parameter or environment, default to 1GB
518        let max_size = max_size_bytes.unwrap_or_else(|| {
519            std::env::var("OXUR_CACHE_MAX_SIZE_MB")
520                .ok()
521                .and_then(|s| s.parse::<u64>().ok())
522                .map(|mb| mb * 1024 * 1024)
523                .unwrap_or(1024 * 1024 * 1024) // 1GB default
524        });
525
526        let (_count, total_size) = self.stats();
527        if total_size <= max_size {
528            return Ok(0); // No eviction needed
529        }
530
531        // Sort entries by cached_at (oldest first) and collect keys to evict
532        let mut entries: Vec<_> =
533            self.index.entries.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
534        entries.sort_by_key(|(_, entry)| entry.cached_at);
535
536        let mut evicted = 0;
537        let mut current_size = total_size;
538        let mut keys_to_remove = Vec::new();
539
540        // Collect keys to evict until under limit
541        for (key, entry) in entries {
542            if current_size <= max_size {
543                break;
544            }
545
546            keys_to_remove.push(key.clone());
547            current_size -= entry.size_bytes;
548        }
549
550        // Remove artifacts and entries
551        for key in keys_to_remove {
552            if let Some(entry) = self.index.entries.get(&key) {
553                // Remove artifact directory (best effort, ignore errors)
554                let artifact_dir = self.cache_dir.join(&entry.key);
555                if artifact_dir.exists() {
556                    let _ = fs::remove_dir_all(&artifact_dir); // Ignore errors during eviction
557                }
558
559                // Remove from index
560                self.index.entries.remove(&key);
561                evicted += 1;
562            }
563        }
564
565        // Save updated index
566        if evicted > 0 {
567            self.save_index()?;
568        }
569
570        Ok(evicted)
571    }
572
573    /// Get total cache size in bytes
574    pub fn total_size_bytes(&self) -> u64 {
575        self.stats().1
576    }
577
578    /// Check if cache is over size limit and evict if needed
579    ///
580    /// This is automatically called after `insert()` operations.
581    /// Can also be called manually for periodic cleanup.
582    ///
583    /// # Returns
584    ///
585    /// Number of entries evicted
586    pub fn check_and_evict(&mut self) -> Result<usize> {
587        self.evict_lru(None)
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use std::env;
595    use std::io::Write;
596    use std::sync::atomic::{AtomicU64, Ordering};
597    use tempfile::NamedTempFile;
598
599    // Shared counter for generating unique cache directories
600    static COUNTER: AtomicU64 = AtomicU64::new(0);
601
602    fn setup_test_cache() -> (ArtifactCache, PathBuf) {
603        let id = COUNTER.fetch_add(1, Ordering::SeqCst);
604        let test_dir =
605            env::temp_dir().join(format!("oxur-test-cache-{}-{}", std::process::id(), id));
606
607        // Use with_directory to create isolated cache (no env var needed)
608        let mut cache = ArtifactCache::with_directory(&test_dir).expect("Failed to create cache");
609        cache.clear().expect("Failed to clear cache");
610        (cache, test_dir)
611    }
612
613    fn cleanup_test_cache(test_dir: PathBuf) {
614        let _ = fs::remove_dir_all(&test_dir);
615    }
616
617    #[test]
618    fn test_cache_creation() {
619        let (cache, test_dir) = setup_test_cache();
620        assert!(cache.cache_dir.exists());
621        cleanup_test_cache(test_dir);
622    }
623
624    #[test]
625    fn test_generate_key_deterministic() {
626        let (cache, test_dir) = setup_test_cache();
627
628        let key1 = cache.generate_key("(+ 1 2)", &[], 0, "default");
629        let key2 = cache.generate_key("(+ 1 2)", &[], 0, "default");
630
631        assert_eq!(key1, key2);
632        assert_eq!(key1.len(), 64); // SHA256 produces 64 hex chars
633
634        cleanup_test_cache(test_dir);
635    }
636
637    #[test]
638    fn test_generate_key_different_sources() {
639        let (cache, test_dir) = setup_test_cache();
640
641        let key1 = cache.generate_key("(+ 1 2)", &[], 0, "default");
642        let key2 = cache.generate_key("(+ 2 3)", &[], 0, "default");
643
644        assert_ne!(key1, key2);
645
646        cleanup_test_cache(test_dir);
647    }
648
649    #[test]
650    fn test_generate_key_different_dependencies() {
651        let (cache, test_dir) = setup_test_cache();
652
653        let key1 = cache.generate_key("(+ 1 2)", &[("foo", "1.0")], 0, "default");
654        let key2 = cache.generate_key("(+ 1 2)", &[("foo", "2.0")], 0, "default");
655
656        assert_ne!(key1, key2);
657
658        cleanup_test_cache(test_dir);
659    }
660
661    #[test]
662    fn test_insert_and_get() {
663        let (mut cache, test_dir) = setup_test_cache();
664
665        // Create a temporary artifact file (auto-cleanup on drop)
666        let mut temp_artifact = NamedTempFile::new().expect("Failed to create temp file");
667        temp_artifact.write_all(b"fake dylib content").expect("Failed to write to temp file");
668
669        // Generate key and insert
670        let key = cache.generate_key("(+ 1 2)", &[], 0, "default");
671        let cached_path =
672            cache.insert(&key, temp_artifact.path()).expect("Failed to insert artifact");
673
674        assert!(cached_path.exists());
675
676        // Retrieve from cache
677        let retrieved = cache.get(&key).expect("Failed to get artifact");
678        assert!(retrieved.is_some());
679        assert_eq!(retrieved.unwrap(), cached_path);
680
681        cleanup_test_cache(test_dir);
682        // temp_artifact automatically cleaned up on drop
683    }
684
685    #[test]
686    fn test_get_nonexistent() {
687        let (cache, test_dir) = setup_test_cache();
688
689        let result = cache.get("nonexistent-key").expect("Failed to query cache");
690        assert!(result.is_none());
691
692        cleanup_test_cache(test_dir);
693    }
694
695    #[test]
696    fn test_cache_stats() {
697        let (mut cache, test_dir) = setup_test_cache();
698
699        let mut temp_artifact = NamedTempFile::new().expect("Failed to create temp file");
700        temp_artifact.write_all(b"test content").expect("Failed to write to temp file");
701
702        let key = cache.generate_key("test", &[], 0, "default");
703        cache.insert(&key, temp_artifact.path()).expect("Failed to insert");
704
705        let (count, size) = cache.stats();
706        assert_eq!(count, 1);
707        assert!(size > 0);
708
709        cleanup_test_cache(test_dir);
710        // temp_artifact automatically cleaned up on drop
711    }
712
713    #[test]
714    fn test_cache_clear() {
715        let (mut cache, test_dir) = setup_test_cache();
716
717        let mut temp_artifact = NamedTempFile::new().expect("Failed to create temp file");
718        temp_artifact.write_all(b"test").expect("Failed to write to temp file");
719
720        let key = cache.generate_key("test", &[], 0, "default");
721        cache.insert(&key, temp_artifact.path()).expect("Failed to insert");
722
723        let (count_before, _) = cache.stats();
724        assert_eq!(count_before, 1);
725
726        cache.clear().expect("Failed to clear cache");
727
728        let (count_after, _) = cache.stats();
729        assert_eq!(count_after, 0);
730
731        cleanup_test_cache(test_dir);
732        // temp_artifact automatically cleaned up on drop
733    }
734
735    #[test]
736    fn test_cache_persistence() {
737        let id = COUNTER.fetch_add(1, Ordering::SeqCst);
738        let test_dir =
739            env::temp_dir().join(format!("oxur-test-persist-{}-{}", std::process::id(), id));
740
741        let mut temp_artifact = NamedTempFile::new().expect("Failed to create temp file");
742        temp_artifact.write_all(b"persist test").expect("Failed to write to temp file");
743
744        let (key, cached_path) = {
745            // Use with_directory for isolated test cache (no environment variable needed)
746            let mut cache =
747                ArtifactCache::with_directory(&test_dir).expect("Failed to create cache");
748
749            let key = cache.generate_key("persist", &[], 0, "default");
750            let cached_path = cache.insert(&key, temp_artifact.path()).expect("Failed to insert");
751
752            (key, cached_path)
753        }; // cache dropped here - index should be persisted to disk
754
755        // Verify the cached file actually exists
756        assert!(cached_path.exists(), "Cached file should exist at {:?}", cached_path);
757
758        // Create new cache instance with same directory - should load index from disk
759        let cache2 = ArtifactCache::with_directory(&test_dir).expect("Failed to create cache2");
760        let retrieved = cache2.get(&key).expect("Failed to get from cache2");
761
762        assert!(retrieved.is_some(), "Should retrieve cached artifact");
763        if let Some(path) = retrieved {
764            assert_eq!(path, cached_path, "Retrieved path should match cached path");
765        }
766
767        cleanup_test_cache(test_dir);
768        // temp_artifact automatically cleaned up on drop
769    }
770}