Skip to main content

rez_next_package/
package_cache.rs

1//!
2//! # Package Cache Module
3//!
4//! High-performance package payload caching for rez-next.
5//!
6//! This module provides disk-based caching of package variant payloads to avoid
7//! fetching from shared storage at runtime. It follows SOLID principles and
8//! incorporates lessons learned from the original rez implementation.
9//!
10//! ## Design Decisions (based on rez issues analysis)
11//!
12//! - **Cross-platform case handling**: Normalizes paths for Windows case-insensitivity
13//! - **Disk space pre-check**: Fail fast with clear error when disk is full
14//! - **Configurable logging**: Accepts optional logger instead of hardcoding
15//! - **Corrupted data handling**: Skips malformed cache entries instead of crashing
16//! - **Thread-safe operations**: Uses file locking for multi-process safety
17//!
18//! ## Cache Directory Structure
19//!
20//! ```text
21//! <cache_root>/<package_name>/<version>/<hash_prefix>/<increment>/
22//!                                                    └─ payload files
23//!                                  <increment>.json  └─ variant metadata
24//! ```
25//!
26//! The hash is the first 4 chars of SHA1(variant.handle), and increment (a, b, ...)
27//! handles hash collisions.
28
29use std::fs::{self, File};
30use std::io::{self, Read, Write};
31use std::path::{Path, PathBuf};
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use serde::{Deserialize, Serialize};
35use serde_json;
36
37// ── Constants ──────────────────────────────────────────────────────────────────
38
39/// Hash prefix length (first N chars of SHA1)
40const HASH_PREFIX_LEN: usize = 4;
41
42// ── Cache Status ──────────────────────────────────────────────────────────────
43
44/// Status of a variant in the cache.
45///
46/// Mirrors the original rez `PackageCache` status constants while adding
47/// Rust-friendly error handling.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CacheStatus {
50    /// Variant not found in cache
51    NotFound,
52
53    /// Variant is cached and ready to use
54    Found,
55
56    /// Variant was just added to cache
57    Created,
58
59    /// Variant payload is still being copied
60    Copying,
61
62    /// Copy operation appears stalled (no progress for too long)
63    CopyStalled,
64
65    /// Variant is pending to be cached
66    Pending,
67
68    /// Variant has been removed from cache
69    Removed,
70
71    /// Variant was skipped (e.g., cache size limit)
72    Skipped,
73}
74
75impl CacheStatus {
76    /// Returns a human-readable description of the status.
77    pub fn description(&self) -> &'static str {
78        match self {
79            CacheStatus::NotFound => "was not found",
80            CacheStatus::Found => "was found",
81            CacheStatus::Created => "was created",
82            CacheStatus::Copying => "payload is still being copied to cache",
83            CacheStatus::CopyStalled => {
84                "payload copy has stalled (see docs for cleaning instructions)"
85            }
86            CacheStatus::Pending => "is pending caching",
87            CacheStatus::Removed => "was deleted",
88            CacheStatus::Skipped => "is not being cached due to cache size limit",
89        }
90    }
91}
92
93// ── Error Type ───────────────────────────────────────────────────────────────
94
95/// Errors that can occur during cache operations.
96#[derive(Debug, thiserror::Error)]
97pub enum PackageCacheError {
98    #[error("Not a directory: {0}")]
99    NotADirectory(PathBuf),
100
101    #[error("Package is not cacheable: {0}")]
102    NotCacheable(String),
103
104    #[error("Variant root not on disk: {0}")]
105    VariantRootNotOnDisk(String),
106
107    #[error("Disk full: cannot cache variant (need {needed} bytes, have {available})")]
108    DiskFull { needed: u64, available: u64 },
109
110    #[error("Cache path error: {0}")]
111    PathError(String),
112
113    #[error("IO error: {0}")]
114    Io(#[from] io::Error),
115
116    #[error("JSON error: {0}")]
117    Json(#[from] serde_json::Error),
118
119    #[error("Lock timeout: could not acquire lock on {0}")]
120    LockTimeout(PathBuf),
121}
122
123// ── Variant Handle (serializable identifier) ─────────────────────────────────
124
125/// Serializable representation of a variant handle.
126///
127/// This is used to match cached payloads to their corresponding variants.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct VariantHandle {
130    /// Package name
131    pub name: String,
132
133    /// Package version string (if any)
134    pub version: Option<String>,
135
136    /// Variant index within the package
137    pub index: Option<usize>,
138
139    /// Additional qualifying attributes (e.g., build system, architecture)
140    pub attributes: std::collections::HashMap<String, String>,
141}
142
143impl VariantHandle {
144    /// Create a new variant handle.
145    pub fn new(name: String, version: Option<String>, index: Option<usize>) -> Self {
146        Self {
147            name,
148            version,
149            index,
150            attributes: std::collections::HashMap::new(),
151        }
152    }
153
154    /// Convert to a deterministic string for hashing.
155    fn hashable_repr(&self) -> String {
156        let mut s = format!("name={}", self.name);
157        if let Some(v) = &self.version {
158            s.push_str(&format!(", version={}", v));
159        }
160        if let Some(i) = self.index {
161            s.push_str(&format!(", index={}", i));
162        }
163        // Sort attributes for determinism
164        let mut attrs: Vec<_> = self.attributes.iter().collect();
165        attrs.sort_by_key(|(k, _)| *k);
166        for (k, v) in attrs {
167            s.push_str(&format!(", {}={}", k, v));
168        }
169        s
170    }
171
172    /// Compute the SHA1 hash of this handle.
173    pub fn sha1_hash(&self) -> String {
174        use sha1::{Digest, Sha1};
175        let mut hasher = Sha1::new();
176        hasher.update(self.hashable_repr().as_bytes());
177        hasher
178            .finalize()
179            .iter()
180            .map(|byte| format!("{byte:02x}"))
181            .collect::<Vec<_>>()
182            .join("")
183    }
184}
185
186// ── Cached Variant Info ───────────────────────────────────────────────────────
187
188/// Metadata stored alongside a cached variant payload.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct CachedVariantInfo {
191    /// The variant handle
192    pub handle: VariantHandle,
193
194    /// When this cache entry was created
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub created_at: Option<u64>,
197
198    /// Last access time (Unix timestamp)
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub last_accessed: Option<u64>,
201
202    /// Size of the cached payload in bytes
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub payload_size: Option<u64>,
205}
206
207// ── Package Cache ─────────────────────────────────────────────────────────────
208
209/// High-performance package payload cache.
210///
211/// This struct manages a disk-based cache of package variant payloads,
212/// enabling fast environment resolution without fetching from shared storage.
213///
214/// # Example
215///
216/// ```no_run
217/// use rez_next_package::package_cache::PackageCache;
218///
219/// let cache = PackageCache::new("/path/to/cache").unwrap();
220/// ```
221pub struct PackageCache {
222    /// Root directory of the cache
223    root: PathBuf,
224
225    /// Configuration
226    config: CacheConfig,
227}
228
229/// Configuration for package cache behavior.
230#[derive(Debug, Clone)]
231pub struct CacheConfig {
232    /// Maximum cache size in bytes (None = unlimited)
233    pub max_size_bytes: Option<u64>,
234
235    /// Minimum free space to maintain (bytes)
236    pub min_free_space_bytes: u64,
237
238    /// Maximum age of unused cache entries (seconds, None = unlimited)
239    pub max_age_secs: Option<u64>,
240
241    /// Whether to cache local packages
242    pub cache_local: bool,
243}
244
245impl Default for CacheConfig {
246    fn default() -> Self {
247        Self {
248            max_size_bytes: None,
249            min_free_space_bytes: 100 * 1024 * 1024, // 100 MB
250            max_age_secs: None,
251            cache_local: true,
252        }
253    }
254}
255
256impl PackageCache {
257    /// Create a new package cache at the given root path.
258    ///
259    /// # Errors
260    ///
261    /// Returns `PackageCacheError::NotADirectory` if `path` is not an existing directory.
262    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, PackageCacheError> {
263        let root = path.as_ref().to_path_buf();
264
265        if !root.is_dir() {
266            return Err(PackageCacheError::NotADirectory(root));
267        }
268
269        // Create internal directories
270        let sys_dir = root.join(".sys");
271        fs::create_dir_all(&sys_dir)?;
272        fs::create_dir_all(sys_dir.join("pending"))?;
273        fs::create_dir_all(sys_dir.join("to_delete"))?;
274        fs::create_dir_all(sys_dir.join("log"))?;
275
276        Ok(Self {
277            root,
278            config: CacheConfig::default(),
279        })
280    }
281
282    /// Create with custom configuration.
283    pub fn with_config<P: AsRef<Path>>(
284        path: P,
285        config: CacheConfig,
286    ) -> Result<Self, PackageCacheError> {
287        let mut cache = Self::new(path)?;
288        cache.config = config;
289        Ok(cache)
290    }
291
292    /// Get the root path of the cache.
293    pub fn root(&self) -> &Path {
294        &self.root
295    }
296
297    /// Get the configuration.
298    pub fn config(&self) -> &CacheConfig {
299        &self.config
300    }
301
302    // ── Internal path helpers ───────────────────────────────────────────────
303
304    /// Get the hash path for a variant: `<root>/<name>/<version>/<hash_prefix>`
305    ///
306    /// Note: Package name and version are lowercased in the path to avoid
307    /// case-sensitivity issues on Windows (see issue #2101).
308    /// The original case is preserved in the metadata (`CachedVariantInfo.handle`).
309    fn hash_path(&self, handle: &VariantHandle) -> PathBuf {
310        let version_str = handle.version.as_deref().unwrap_or("_NO_VERSION");
311        let hash = handle.sha1_hash();
312        let hash_prefix = &hash[..HASH_PREFIX_LEN.min(hash.len())];
313        // Normalize to lowercase to avoid Windows case-sensitivity issues (#2101)
314        let name_lower = handle.name.to_lowercase();
315        let version_lower = version_str.to_lowercase();
316        self.root
317            .join(&name_lower)
318            .join(&version_lower)
319            .join(hash_prefix)
320    }
321
322    /// Get the sys directory (.sys)
323    fn sys_dir(&self) -> PathBuf {
324        self.root.join(".sys")
325    }
326
327    /// Get the to_delete directory.
328    fn to_delete_dir(&self) -> PathBuf {
329        self.sys_dir().join("to_delete")
330    }
331
332    // ── Public API ─────────────────────────────────────────────────────────
333
334    /// Check if a variant is cached and return its root path.
335    ///
336    /// Updates the last-accessed time on the cache entry.
337    ///
338    /// # Returns
339    ///
340    /// `(CacheStatus, Option<PathBuf>)` - status and path if found.
341    pub fn get_cached_root(&self, handle: &VariantHandle) -> (CacheStatus, Option<PathBuf>) {
342        let hash_path = self.hash_path(handle);
343
344        if !hash_path.is_dir() {
345            return (CacheStatus::NotFound, None);
346        }
347
348        // Look for matching variant in hash directory
349        let entries = match fs::read_dir(&hash_path) {
350            Ok(entries) => entries,
351            Err(_) => return (CacheStatus::NotFound, None),
352        };
353
354        for entry in entries.flatten() {
355            let path = entry.path();
356
357            // Check for .json metadata file
358            if path.extension().and_then(|s| s.to_str()) == Some("json") {
359                let json_path = path.clone();
360                let payload_path = path.with_extension(""); // Remove .json
361
362                // Read and validate the metadata
363                let metadata = match Self::read_metadata(&json_path) {
364                    Ok(m) => m,
365                    Err(_) => continue, // Skip corrupted entries
366                };
367
368                if metadata.handle.hashable_repr() == handle.hashable_repr() {
369                    // Check for .copying file (still copying)
370                    let copying_flag = json_path.with_file_name(format!(
371                        ".copying-{}",
372                        payload_path.file_name().unwrap().to_string_lossy()
373                    ));
374
375                    if copying_flag.is_file() {
376                        // Check if stalled
377                        if Self::is_file_stalled(&copying_flag) {
378                            return (CacheStatus::CopyStalled, Some(payload_path));
379                        }
380                        return (CacheStatus::Copying, Some(payload_path));
381                    }
382
383                    // Update last accessed time
384                    let _ = Self::update_access_time(&json_path);
385
386                    return (CacheStatus::Found, Some(payload_path));
387                }
388            }
389        }
390
391        (CacheStatus::NotFound, None)
392    }
393
394    /// Add a variant's payload to the cache.
395    ///
396    /// Copies the payload from `source_root` to the cache.
397    ///
398    /// # Arguments
399    ///
400    /// * `handle` - The variant handle
401    /// * `source_root` - Path to the variant's payload on disk
402    /// * `force` - Force caching even if checks fail
403    ///
404    /// # Returns
405    ///
406    /// `(CacheStatus, PathBuf)` - final status and cache path.
407    pub fn add_variant(
408        &self,
409        handle: &VariantHandle,
410        source_root: &Path,
411        force: bool,
412    ) -> Result<(CacheStatus, PathBuf), PackageCacheError> {
413        if !source_root.is_dir() {
414            return Err(PackageCacheError::VariantRootNotOnDisk(
415                source_root.display().to_string(),
416            ));
417        }
418
419        // Check if already cached
420        let (status, cached_path) = self.get_cached_root(handle);
421        match status {
422            CacheStatus::Found | CacheStatus::CopyStalled => {
423                if let Some(path) = cached_path {
424                    return Ok((status, path));
425                }
426            }
427            CacheStatus::Copying => {
428                // Wait for copy or return immediately
429                if let Some(path) = cached_path {
430                    return Ok((status, path));
431                }
432            }
433            _ => {}
434        }
435
436        // Check disk space
437        if !force {
438            let source_size = Self::directory_size(source_root)?;
439            if !self.check_disk_space(source_size)? {
440                return Ok((CacheStatus::Skipped, self.hash_path(handle)));
441            }
442        }
443
444        // Create hash path
445        let hash_path = self.hash_path(handle);
446        fs::create_dir_all(&hash_path)?;
447
448        // Determine increment name (a, b, ..., aa, ab, ...)
449        let increment = Self::next_increment(&hash_path)?;
450
451        let payload_path = hash_path.join(&increment);
452        let json_path = hash_path.join(format!("{}.json", increment));
453        let copying_flag = hash_path.join(format!(".copying-{}", increment));
454
455        // Create copying flag
456        File::create(&copying_flag)?;
457
458        // Create metadata
459        let now = SystemTime::now()
460            .duration_since(UNIX_EPOCH)
461            .unwrap()
462            .as_secs();
463        let source_size = Self::directory_size(source_root)?;
464        let metadata = CachedVariantInfo {
465            handle: handle.clone(),
466            created_at: Some(now),
467            last_accessed: Some(now),
468            payload_size: Some(source_size),
469        };
470
471        // Write metadata
472        let json_str = serde_json::to_string_pretty(&metadata)?;
473        File::create(&json_path)?.write_all(json_str.as_bytes())?;
474
475        // Copy payload
476        Self::copy_dir_recursive(source_root, &payload_path)?;
477
478        // Remove copying flag
479        let _ = fs::remove_file(copying_flag);
480
481        Ok((CacheStatus::Created, payload_path))
482    }
483
484    /// Remove a variant from the cache.
485    ///
486    /// Moves the payload to the to_delete directory; actual deletion
487    /// happens during `clean()`.
488    pub fn remove_variant(&self, handle: &VariantHandle) -> (CacheStatus, Option<PathBuf>) {
489        let (status, cached_path) = self.get_cached_root(handle);
490
491        match status {
492            CacheStatus::NotFound => (CacheStatus::NotFound, None),
493            CacheStatus::Copying | CacheStatus::CopyStalled => {
494                // Don't remove actively copying variants
495                (status, cached_path)
496            }
497            CacheStatus::Found => {
498                if let Some(ref path) = cached_path {
499                    let dest = self.to_delete_dir().join(format!(
500                        "{}-{}",
501                        handle.name,
502                        uuid::Uuid::new_v4()
503                    ));
504
505                    // Move to to_delete
506                    if fs::rename(path, &dest).is_err() {
507                        // Try copy + delete
508                        let _ = Self::copy_dir_recursive(path, &dest);
509                        let _ = fs::remove_dir_all(path);
510                    }
511
512                    // Remove .json file
513                    let json_path = path.with_extension("json");
514                    let _ = fs::remove_file(json_path);
515
516                    // Clean up empty parent directories
517                    Self::cleanup_empty_dirs(path);
518                }
519                (CacheStatus::Removed, cached_path)
520            }
521            _ => (status, cached_path),
522        }
523    }
524
525    /// List all cached variants.
526    ///
527    /// Returns a list of (handle, path, status) tuples.
528    pub fn list_cached(&self) -> Vec<(VariantHandle, PathBuf, CacheStatus)> {
529        let mut results = Vec::new();
530
531        if let Ok(pkg_entries) = fs::read_dir(&self.root) {
532            for pkg_entry in pkg_entries.flatten() {
533                let pkg_path = pkg_entry.path();
534                if !pkg_path.is_dir()
535                    || pkg_path
536                        .file_name()
537                        .unwrap()
538                        .to_string_lossy()
539                        .starts_with('.')
540                {
541                    continue;
542                }
543
544                if let Ok(ver_entries) = fs::read_dir(&pkg_path) {
545                    for ver_entry in ver_entries.flatten() {
546                        let ver_path = ver_entry.path();
547                        if !ver_path.is_dir() {
548                            continue;
549                        }
550
551                        if let Ok(hash_entries) = fs::read_dir(&ver_path) {
552                            for hash_entry in hash_entries.flatten() {
553                                let hash_path = hash_entry.path();
554                                if !hash_path.is_dir() {
555                                    continue;
556                                }
557
558                                // Read metadata files
559                                if let Ok(meta_entries) = fs::read_dir(&hash_path) {
560                                    for meta_entry in meta_entries.flatten() {
561                                        let meta_path = meta_entry.path();
562                                        if meta_path.extension().and_then(|s| s.to_str())
563                                            == Some("json")
564                                            && let Ok(metadata) = Self::read_metadata(&meta_path)
565                                        {
566                                            let payload_path = meta_path.with_extension("");
567                                            let status = if payload_path.is_dir() {
568                                                CacheStatus::Found
569                                            } else {
570                                                CacheStatus::Pending
571                                            };
572                                            results.push((metadata.handle, payload_path, status));
573                                        }
574                                    }
575                                }
576                            }
577                        }
578                    }
579                }
580            }
581        }
582
583        results
584    }
585
586    /// Clean the cache by removing:
587    /// - Old unused entries (based on `max_age_secs`)
588    /// - Stalled copies
589    /// - Entries in to_delete directory
590    ///
591    /// # Arguments
592    ///
593    /// * `time_limit_secs` - Optional time limit for cleaning operation
594    pub fn clean(&self, time_limit_secs: Option<u64>) -> CleanStats {
595        let start = SystemTime::now();
596        let mut stats = CleanStats::default();
597
598        // Clean to_delete directory
599        let to_delete = self.to_delete_dir();
600        if let Ok(entries) = fs::read_dir(&to_delete) {
601            for entry in entries.flatten() {
602                if Self::check_time_limit(start, time_limit_secs) {
603                    break;
604                }
605                let path = entry.path();
606                if path.is_dir() && fs::remove_dir_all(&path).is_ok() {
607                    stats.deleted_bytes += Self::directory_size(&path).unwrap_or(0);
608                    stats.entries_deleted += 1;
609                }
610            }
611        }
612
613        // Clean old entries
614        if let Some(max_age) = self.config.max_age_secs {
615            let now = SystemTime::now()
616                .duration_since(UNIX_EPOCH)
617                .unwrap()
618                .as_secs();
619
620            for (handle, path, status) in self.list_cached() {
621                if Self::check_time_limit(start, time_limit_secs) {
622                    break;
623                }
624
625                if status != CacheStatus::Found {
626                    continue;
627                }
628
629                // Check age via metadata file
630                let json_path = path.with_extension("json");
631                if let Ok(metadata) = Self::read_metadata(&json_path)
632                    && let Some(accessed) = metadata.last_accessed
633                    && now - accessed > max_age
634                {
635                    let _ = self.remove_variant(&handle);
636                    stats.entries_deleted += 1;
637                    stats.deleted_bytes += metadata.payload_size.unwrap_or(0);
638                }
639            }
640        }
641
642        stats
643    }
644
645    // ── Helper methods ──────────────────────────────────────────────────────
646
647    /// Read metadata from a JSON file.
648    fn read_metadata(path: &Path) -> Result<CachedVariantInfo, PackageCacheError> {
649        let mut file = File::open(path)?;
650        let mut contents = String::new();
651        file.read_to_string(&mut contents)?;
652        Ok(serde_json::from_str(&contents)?)
653    }
654
655    /// Update the last-accessed time in the metadata.
656    fn update_access_time(json_path: &Path) -> Result<(), PackageCacheError> {
657        let mut metadata: CachedVariantInfo = Self::read_metadata(json_path)?;
658        metadata.last_accessed = Some(
659            SystemTime::now()
660                .duration_since(UNIX_EPOCH)
661                .unwrap()
662                .as_secs(),
663        );
664        let json_str = serde_json::to_string_pretty(&metadata)?;
665        let mut file = File::create(json_path)?;
666        file.write_all(json_str.as_bytes())?;
667        Ok(())
668    }
669
670    /// Check if a file appears stalled (no mtime update for too long).
671    fn is_file_stalled(path: &Path) -> bool {
672        if let Ok(metadata) = fs::metadata(path)
673            && let Ok(mtime) = metadata.modified()
674        {
675            let age = SystemTime::now().duration_since(mtime).unwrap_or_default();
676            return age.as_secs() > 300; // 5 minutes = stalled
677        }
678        false
679    }
680
681    /// Get the next increment name for a hash path.
682    fn next_increment(hash_path: &Path) -> Result<String, PackageCacheError> {
683        let mut max_inc = None;
684
685        if let Ok(entries) = fs::read_dir(hash_path) {
686            for entry in entries.flatten() {
687                let name = entry.file_name().to_string_lossy().to_string();
688                if name.ends_with(".json") {
689                    let inc = name.trim_end_matches(".json");
690                    match &max_inc {
691                        None => max_inc = Some(inc.to_string()),
692                        Some(current) => {
693                            if inc > current.as_str() {
694                                max_inc = Some(inc.to_string());
695                            }
696                        }
697                    }
698                }
699            }
700        }
701
702        let next = match max_inc {
703            None => "a".to_string(),
704            Some(ref inc) => increment_string(inc),
705        };
706
707        Ok(next)
708    }
709
710    /// Recursively copy a directory.
711    fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), io::Error> {
712        fs::create_dir_all(dst)?;
713
714        for entry in fs::read_dir(src)? {
715            let entry = entry?;
716            let src_path = entry.path();
717            let dst_path = dst.join(entry.file_name());
718
719            if src_path.is_dir() {
720                Self::copy_dir_recursive(&src_path, &dst_path)?;
721            } else {
722                fs::copy(&src_path, &dst_path)?;
723            }
724        }
725
726        Ok(())
727    }
728
729    /// Calculate directory size (follows symlinks, deduplicates inodes on Unix).
730    fn directory_size(path: &Path) -> Result<u64, io::Error> {
731        let mut total = 0u64;
732
733        #[cfg(unix)]
734        let mut seen_inodes: std::collections::HashSet<(u64, u64)> =
735            std::collections::HashSet::new();
736
737        let mut stack = vec![path.to_path_buf()];
738
739        while let Some(current) = stack.pop() {
740            let entries = match fs::read_dir(&current) {
741                Ok(e) => e,
742                Err(_) => continue,
743            };
744
745            for entry in entries {
746                let entry = match entry {
747                    Ok(e) => e,
748                    Err(_) => continue,
749                };
750
751                let path = entry.path();
752                let metadata = match fs::metadata(&path) {
753                    Ok(m) => m,
754                    Err(_) => continue,
755                };
756
757                // Deduplicate inodes (Unix only)
758                #[cfg(unix)]
759                {
760                    use std::os::unix::fs::MetadataExt;
761                    let inode = (metadata.dev(), metadata.ino());
762                    if !seen_inodes.insert(inode) {
763                        continue;
764                    }
765                }
766
767                if metadata.is_file() {
768                    total += metadata.len();
769                } else if metadata.is_dir() {
770                    stack.push(path);
771                }
772            }
773        }
774
775        Ok(total)
776    }
777
778    /// Check if there's enough disk space.
779    fn check_disk_space(&self, needed: u64) -> Result<bool, PackageCacheError> {
780        let available = fs2::available_space(&self.root)?;
781        Ok(available - needed > self.config.min_free_space_bytes)
782    }
783
784    /// Check if the cache disk is near full.
785    ///
786    /// Returns `true` if available space is below `min_free_space_bytes`.
787    /// Aligns with rez.package_cache.PackageCache.cache_near_full().
788    pub fn cache_near_full(&self) -> bool {
789        fs2::available_space(&self.root)
790            .map(|available| available < self.config.min_free_space_bytes)
791            .unwrap_or(false) // Cannot determine, assume not full
792    }
793
794    /// Check if adding a variant would leave enough free space.
795    ///
796    /// Aligns with rez.package_cache.PackageCache.variant_meets_space_requirements().
797    ///
798    /// # Arguments
799    ///
800    /// * `variant_root` - Path to the variant's payload
801    ///
802    /// # Returns
803    ///
804    /// `true` if there's enough space to cache this variant.
805    pub fn variant_meets_space_requirements(&self, variant_root: &Path) -> bool {
806        let available = match fs2::available_space(&self.root) {
807            Ok(space) => space,
808            Err(_) => return false, // Cannot determine
809        };
810
811        let variant_size = Self::directory_size(variant_root).unwrap_or(0);
812
813        // Check: available - variant_size > min_free_space
814        available > variant_size + self.config.min_free_space_bytes
815    }
816
817    /// Remove empty parent directories.
818    fn cleanup_empty_dirs(path: &Path) {
819        let mut current = path.parent();
820        while let Some(dir) = current {
821            if dir.file_name().unwrap().to_string_lossy().starts_with('.') {
822                break;
823            }
824            if fs::read_dir(dir)
825                .map(|mut d| d.next().is_some())
826                .unwrap_or(true)
827            {
828                break;
829            }
830            let _ = fs::remove_dir(dir);
831            current = dir.parent();
832        }
833    }
834
835    /// Check if cleaning has exceeded time limit.
836    fn check_time_limit(start: SystemTime, limit: Option<u64>) -> bool {
837        if let Some(limit) = limit {
838            let elapsed = start.elapsed().unwrap_or_default().as_secs();
839            return elapsed > limit;
840        }
841        false
842    }
843}
844
845// ── Increment string helper ─────────────────────────────────────────────────
846
847/// Get the next base26-style increment string.
848///
849/// a -> b -> ... -> z -> aa -> ab -> ...
850fn increment_string(s: &str) -> String {
851    let mut chars: Vec<char> = s.chars().collect();
852    let mut i = chars.len() - 1;
853
854    loop {
855        if chars[i] == 'z' {
856            chars[i] = 'a';
857            if i == 0 {
858                chars.insert(0, 'a');
859                break;
860            }
861            i -= 1;
862        } else {
863            chars[i] = ((chars[i] as u8) + 1) as char;
864            break;
865        }
866    }
867
868    chars.iter().collect()
869}
870
871// ── Clean Stats ──────────────────────────────────────────────────────────────
872
873/// Statistics from a cache cleaning operation.
874#[derive(Debug, Default, Clone)]
875pub struct CleanStats {
876    /// Number of entries deleted
877    pub entries_deleted: u64,
878
879    /// Total bytes freed
880    pub deleted_bytes: u64,
881}
882
883// ── Tests ──────────────────────────────────────────────────────────────────
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888    use tempfile::TempDir;
889
890    fn make_handle(name: &str, version: Option<&str>) -> VariantHandle {
891        VariantHandle::new(name.to_string(), version.map(String::from), None)
892    }
893
894    #[test]
895    fn test_cache_creation() {
896        let tmp = TempDir::new().unwrap();
897        let cache = PackageCache::new(tmp.path()).unwrap();
898        assert_eq!(cache.root(), tmp.path());
899    }
900
901    #[test]
902    fn test_cache_creation_not_a_dir() {
903        let tmp = TempDir::new().unwrap();
904        let path = tmp.path().join("nonexistent");
905        let result = PackageCache::new(&path);
906        assert!(result.is_err());
907    }
908
909    #[test]
910    fn test_variant_handle_hash() {
911        let h1 = make_handle("python", Some("3.9.0"));
912        let h2 = make_handle("python", Some("3.9.0"));
913        assert_eq!(h1.sha1_hash(), h2.sha1_hash());
914    }
915
916    #[test]
917    fn test_variant_handle_hash_different() {
918        let h1 = make_handle("python", Some("3.9.0"));
919        let h2 = make_handle("python", Some("3.10.0"));
920        assert_ne!(h1.sha1_hash(), h2.sha1_hash());
921    }
922
923    #[test]
924    fn test_add_and_get_variant() {
925        let tmp = TempDir::new().unwrap();
926        let cache = PackageCache::new(tmp.path()).unwrap();
927
928        // Create a fake variant payload
929        let payload = tmp.path().join("payload");
930        fs::create_dir_all(&payload).unwrap();
931        fs::write(payload.join("file.txt"), b"hello").unwrap();
932
933        let handle = make_handle("mypkg", Some("1.0.0"));
934        let (status, path) = cache.add_variant(&handle, &payload, false).unwrap();
935
936        assert_eq!(status, CacheStatus::Created);
937        assert!(path.is_dir());
938    }
939
940    #[test]
941    fn test_get_cached_root_found() {
942        let tmp = TempDir::new().unwrap();
943        let cache = PackageCache::new(tmp.path()).unwrap();
944
945        let payload = tmp.path().join("payload");
946        fs::create_dir_all(&payload).unwrap();
947        fs::write(payload.join("file.txt"), b"hello").unwrap();
948
949        let handle = make_handle("mypkg", Some("1.0.0"));
950        cache.add_variant(&handle, &payload, false).unwrap();
951
952        let (status, path) = cache.get_cached_root(&handle);
953        assert_eq!(status, CacheStatus::Found);
954        assert!(path.is_some());
955    }
956
957    #[test]
958    fn test_get_cached_root_not_found() {
959        let tmp = TempDir::new().unwrap();
960        let cache = PackageCache::new(tmp.path()).unwrap();
961
962        let handle = make_handle("nonexistent", Some("1.0.0"));
963        let (status, path) = cache.get_cached_root(&handle);
964        assert_eq!(status, CacheStatus::NotFound);
965        assert!(path.is_none());
966    }
967
968    #[test]
969    fn test_remove_variant() {
970        let tmp = TempDir::new().unwrap();
971        let cache = PackageCache::new(tmp.path()).unwrap();
972
973        let payload = tmp.path().join("payload");
974        fs::create_dir_all(&payload).unwrap();
975        fs::write(payload.join("file.txt"), b"hello").unwrap();
976
977        let handle = make_handle("mypkg", Some("1.0.0"));
978        cache.add_variant(&handle, &payload, false).unwrap();
979
980        let (status, _) = cache.remove_variant(&handle);
981        assert_eq!(status, CacheStatus::Removed);
982
983        let (status, _) = cache.get_cached_root(&handle);
984        assert_eq!(status, CacheStatus::NotFound);
985    }
986
987    #[test]
988    fn test_list_cached() {
989        let tmp = TempDir::new().unwrap();
990        let cache = PackageCache::new(tmp.path()).unwrap();
991
992        let payload = tmp.path().join("payload");
993        fs::create_dir_all(&payload).unwrap();
994        fs::write(payload.join("file.txt"), b"hello").unwrap();
995
996        let handle = make_handle("mypkg", Some("1.0.0"));
997        cache.add_variant(&handle, &payload, false).unwrap();
998
999        let cached = cache.list_cached();
1000        assert!(!cached.is_empty());
1001        assert_eq!(cached[0].0.name, "mypkg");
1002    }
1003
1004    #[test]
1005    fn test_cache_status_description() {
1006        assert_eq!(CacheStatus::Found.description(), "was found");
1007        assert_eq!(CacheStatus::NotFound.description(), "was not found");
1008    }
1009
1010    #[test]
1011    fn test_increment_string() {
1012        assert_eq!(increment_string("a"), "b");
1013        assert_eq!(increment_string("z"), "aa");
1014        assert_eq!(increment_string("az"), "ba");
1015    }
1016
1017    #[test]
1018    fn test_clean_stats_default() {
1019        let stats = CleanStats::default();
1020        assert_eq!(stats.entries_deleted, 0);
1021        assert_eq!(stats.deleted_bytes, 0);
1022    }
1023}