1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
42struct CacheEntry {
43 key: String,
45
46 path: PathBuf,
48
49 cached_at: u64,
51
52 size_bytes: u64,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58struct CacheIndex {
59 entries: HashMap<String, CacheEntry>,
61
62 version: u32,
64}
65
66#[derive(Debug, Clone)]
68pub struct CacheStats {
69 pub entry_count: usize,
71
72 pub total_size_bytes: u64,
74
75 pub oldest_entry_secs: u64,
77
78 pub newest_entry_secs: u64,
80
81 pub cache_dir: PathBuf,
83}
84
85#[derive(Debug, Clone)]
137pub struct ArtifactCache {
138 cache_dir: PathBuf,
140
141 index: CacheIndex,
143}
144
145impl ArtifactCache {
146 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 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 fn get_cache_dir() -> Result<PathBuf> {
189 if let Ok(dir) = std::env::var("OXUR_CACHE_DIR") {
191 return Ok(PathBuf::from(dir).join("artifacts"));
192 }
193
194 dirs::cache_dir().ok_or(CacheError::NotFound).map(|dir| dir.join("oxur").join("artifacts"))
196 }
197
198 fn load_index(cache_dir: &Path) -> Result<CacheIndex> {
204 let index_path = cache_dir.join("index.json");
205
206 if !index_path.exists() {
207 return Ok(CacheIndex { version: 1, ..Default::default() });
209 }
210
211 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 lock_file
220 .lock_shared()
221 .map_err(|e| CacheError::IndexLoadFailed(format!("Failed to lock index: {}", e)))?;
222
223 let content = fs::read_to_string(&index_path)
225 .map_err(|e| CacheError::IndexLoadFailed(format!("Failed to read index: {}", e)))?;
226
227 let index = serde_json::from_str(&content).map_err(|e| {
229 CacheError::IndexLoadFailed(format!("Failed to parse index JSON: {}", e))
230 })?;
231
232 Ok(index)
234 }
235
236 fn save_index(&self) -> Result<()> {
256 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 let content = serde_json::to_string_pretty(&self.index).map_err(|e| {
264 CacheError::IndexSaveFailed(format!("Failed to serialize index: {}", e))
265 })?;
266
267 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 lock_file
277 .lock_exclusive()
278 .map_err(|e| CacheError::IndexSaveFailed(format!("Failed to lock index: {}", e)))?;
279
280 fs::write(&temp_path, &content).map_err(|e| {
282 CacheError::IndexSaveFailed(format!("Failed to write temp file: {}", e))
283 })?;
284
285 fs::rename(&temp_path, &index_path).map_err(|e| {
288 CacheError::IndexSaveFailed(format!("Failed to rename temp file: {}", e))
289 })?;
290
291 Ok(())
293 }
294
295 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 hasher.update(source.as_ref().as_bytes());
318
319 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 hasher.update([opt_level]);
329
330 hasher.update(source_map_config.as_ref().as_bytes());
332
333 format!("{:x}", hasher.finalize())
334 }
335
336 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 Ok(None)
356 }
357 } else {
358 Ok(None)
359 }
360 }
361
362 pub fn insert(&mut self, key: impl AsRef<str>, artifact_path: &Path) -> Result<PathBuf> {
375 let key = key.as_ref();
376
377 let cache_path = self.cache_dir.join(key);
379 fs::create_dir_all(&cache_path)?;
380
381 let artifact_filename = Self::artifact_filename();
383 let cached_artifact = cache_path.join(artifact_filename);
384
385 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 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 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 let _ = self.check_and_evict(); Ok(cached_artifact)
418 }
419
420 #[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" }
440
441 pub fn clear(&mut self) -> Result<()> {
445 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 self.index.entries.clear();
454 self.save_index()?;
455
456 Ok(())
457 }
458
459 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 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 pub fn keys(&self) -> Vec<String> {
490 self.index.entries.keys().cloned().collect()
491 }
492
493 pub fn evict_lru(&mut self, max_size_bytes: Option<u64>) -> Result<usize> {
517 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) });
525
526 let (_count, total_size) = self.stats();
527 if total_size <= max_size {
528 return Ok(0); }
530
531 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 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 for key in keys_to_remove {
552 if let Some(entry) = self.index.entries.get(&key) {
553 let artifact_dir = self.cache_dir.join(&entry.key);
555 if artifact_dir.exists() {
556 let _ = fs::remove_dir_all(&artifact_dir); }
558
559 self.index.entries.remove(&key);
561 evicted += 1;
562 }
563 }
564
565 if evicted > 0 {
567 self.save_index()?;
568 }
569
570 Ok(evicted)
571 }
572
573 pub fn total_size_bytes(&self) -> u64 {
575 self.stats().1
576 }
577
578 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 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 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); 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 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 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 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 }
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 }
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 }
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 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 }; assert!(cached_path.exists(), "Cached file should exist at {:?}", cached_path);
757
758 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 }
770}