uv_cache/
lib.rs

1use std::fmt::{Display, Formatter};
2use std::io;
3use std::io::Write;
4use std::ops::Deref;
5use std::path::{Path, PathBuf};
6use std::str::FromStr;
7use std::sync::Arc;
8
9use rustc_hash::FxHashMap;
10use tracing::{debug, trace, warn};
11
12use uv_cache_info::Timestamp;
13use uv_fs::{LockedFile, LockedFileError, LockedFileMode, Simplified, cachedir, directories};
14use uv_normalize::PackageName;
15use uv_pypi_types::ResolutionMetadata;
16
17pub use crate::by_timestamp::CachedByTimestamp;
18#[cfg(feature = "clap")]
19pub use crate::cli::CacheArgs;
20use crate::removal::Remover;
21pub use crate::removal::{Removal, rm_rf};
22pub use crate::wheel::WheelCache;
23use crate::wheel::WheelCacheKind;
24pub use archive::ArchiveId;
25
26mod archive;
27mod by_timestamp;
28#[cfg(feature = "clap")]
29mod cli;
30mod removal;
31mod wheel;
32
33/// The version of the archive bucket.
34///
35/// Must be kept in-sync with the version in [`CacheBucket::to_str`].
36pub const ARCHIVE_VERSION: u8 = 0;
37
38/// A [`CacheEntry`] which may or may not exist yet.
39#[derive(Debug, Clone)]
40pub struct CacheEntry(PathBuf);
41
42impl CacheEntry {
43    /// Create a new [`CacheEntry`] from a directory and a file name.
44    pub fn new(dir: impl Into<PathBuf>, file: impl AsRef<Path>) -> Self {
45        Self(dir.into().join(file))
46    }
47
48    /// Create a new [`CacheEntry`] from a path.
49    pub fn from_path(path: impl Into<PathBuf>) -> Self {
50        Self(path.into())
51    }
52
53    /// Return the cache entry's parent directory.
54    pub fn shard(&self) -> CacheShard {
55        CacheShard(self.dir().to_path_buf())
56    }
57
58    /// Convert the [`CacheEntry`] into a [`PathBuf`].
59    #[inline]
60    pub fn into_path_buf(self) -> PathBuf {
61        self.0
62    }
63
64    /// Return the path to the [`CacheEntry`].
65    #[inline]
66    pub fn path(&self) -> &Path {
67        &self.0
68    }
69
70    /// Return the cache entry's parent directory.
71    #[inline]
72    pub fn dir(&self) -> &Path {
73        self.0.parent().expect("Cache entry has no parent")
74    }
75
76    /// Create a new [`CacheEntry`] with the given file name.
77    #[must_use]
78    pub fn with_file(&self, file: impl AsRef<Path>) -> Self {
79        Self(self.dir().join(file))
80    }
81
82    /// Acquire the [`CacheEntry`] as an exclusive lock.
83    pub async fn lock(&self) -> Result<LockedFile, LockedFileError> {
84        fs_err::create_dir_all(self.dir())?;
85        LockedFile::acquire(
86            self.path(),
87            LockedFileMode::Exclusive,
88            self.path().display(),
89        )
90        .await
91    }
92}
93
94impl AsRef<Path> for CacheEntry {
95    fn as_ref(&self) -> &Path {
96        &self.0
97    }
98}
99
100/// A subdirectory within the cache.
101#[derive(Debug, Clone)]
102pub struct CacheShard(PathBuf);
103
104impl CacheShard {
105    /// Return a [`CacheEntry`] within this shard.
106    pub fn entry(&self, file: impl AsRef<Path>) -> CacheEntry {
107        CacheEntry::new(&self.0, file)
108    }
109
110    /// Return a [`CacheShard`] within this shard.
111    #[must_use]
112    pub fn shard(&self, dir: impl AsRef<Path>) -> Self {
113        Self(self.0.join(dir.as_ref()))
114    }
115
116    /// Acquire the cache entry as an exclusive lock.
117    pub async fn lock(&self) -> Result<LockedFile, LockedFileError> {
118        fs_err::create_dir_all(self.as_ref())?;
119        LockedFile::acquire(
120            self.join(".lock"),
121            LockedFileMode::Exclusive,
122            self.display(),
123        )
124        .await
125    }
126
127    /// Return the [`CacheShard`] as a [`PathBuf`].
128    pub fn into_path_buf(self) -> PathBuf {
129        self.0
130    }
131}
132
133impl AsRef<Path> for CacheShard {
134    fn as_ref(&self) -> &Path {
135        &self.0
136    }
137}
138
139impl Deref for CacheShard {
140    type Target = Path;
141
142    fn deref(&self) -> &Self::Target {
143        &self.0
144    }
145}
146
147/// The main cache abstraction.
148///
149/// While the cache is active, it holds a read (shared) lock that prevents cache cleaning
150#[derive(Debug, Clone)]
151pub struct Cache {
152    /// The cache directory.
153    root: PathBuf,
154    /// The refresh strategy to use when reading from the cache.
155    refresh: Refresh,
156    /// A temporary cache directory, if the user requested `--no-cache`.
157    ///
158    /// Included to ensure that the temporary directory exists for the length of the operation, but
159    /// is dropped at the end as appropriate.
160    temp_dir: Option<Arc<tempfile::TempDir>>,
161    /// Ensure that `uv cache` operations don't remove items from the cache that are used by another
162    /// uv process.
163    lock_file: Option<Arc<LockedFile>>,
164}
165
166impl Cache {
167    /// A persistent cache directory at `root`.
168    pub fn from_path(root: impl Into<PathBuf>) -> Self {
169        Self {
170            root: root.into(),
171            refresh: Refresh::None(Timestamp::now()),
172            temp_dir: None,
173            lock_file: None,
174        }
175    }
176
177    /// Create a temporary cache directory.
178    pub fn temp() -> Result<Self, io::Error> {
179        let temp_dir = tempfile::tempdir()?;
180        Ok(Self {
181            root: temp_dir.path().to_path_buf(),
182            refresh: Refresh::None(Timestamp::now()),
183            temp_dir: Some(Arc::new(temp_dir)),
184            lock_file: None,
185        })
186    }
187
188    /// Set the [`Refresh`] policy for the cache.
189    #[must_use]
190    pub fn with_refresh(self, refresh: Refresh) -> Self {
191        Self { refresh, ..self }
192    }
193
194    /// Acquire a lock that allows removing entries from the cache.
195    pub async fn with_exclusive_lock(self) -> Result<Self, LockedFileError> {
196        let Self {
197            root,
198            refresh,
199            temp_dir,
200            lock_file,
201        } = self;
202
203        // Release the existing lock, avoid deadlocks from a cloned cache.
204        if let Some(lock_file) = lock_file {
205            drop(
206                Arc::try_unwrap(lock_file).expect(
207                    "cloning the cache before acquiring an exclusive lock causes a deadlock",
208                ),
209            );
210        }
211        let lock_file = LockedFile::acquire(
212            root.join(".lock"),
213            LockedFileMode::Exclusive,
214            root.simplified_display(),
215        )
216        .await?;
217
218        Ok(Self {
219            root,
220            refresh,
221            temp_dir,
222            lock_file: Some(Arc::new(lock_file)),
223        })
224    }
225
226    /// Acquire a lock that allows removing entries from the cache, if available.
227    ///
228    /// If the lock is not immediately available, returns [`Err`] with self.
229    pub fn with_exclusive_lock_no_wait(self) -> Result<Self, Self> {
230        let Self {
231            root,
232            refresh,
233            temp_dir,
234            lock_file,
235        } = self;
236
237        match LockedFile::acquire_no_wait(
238            root.join(".lock"),
239            LockedFileMode::Exclusive,
240            root.simplified_display(),
241        ) {
242            Some(lock_file) => Ok(Self {
243                root,
244                refresh,
245                temp_dir,
246                lock_file: Some(Arc::new(lock_file)),
247            }),
248            None => Err(Self {
249                root,
250                refresh,
251                temp_dir,
252                lock_file,
253            }),
254        }
255    }
256
257    /// Return the root of the cache.
258    pub fn root(&self) -> &Path {
259        &self.root
260    }
261
262    /// Return the [`Refresh`] policy for the cache.
263    pub fn refresh(&self) -> &Refresh {
264        &self.refresh
265    }
266
267    /// The folder for a specific cache bucket
268    pub fn bucket(&self, cache_bucket: CacheBucket) -> PathBuf {
269        self.root.join(cache_bucket.to_str())
270    }
271
272    /// Compute an entry in the cache.
273    pub fn shard(&self, cache_bucket: CacheBucket, dir: impl AsRef<Path>) -> CacheShard {
274        CacheShard(self.bucket(cache_bucket).join(dir.as_ref()))
275    }
276
277    /// Compute an entry in the cache.
278    pub fn entry(
279        &self,
280        cache_bucket: CacheBucket,
281        dir: impl AsRef<Path>,
282        file: impl AsRef<Path>,
283    ) -> CacheEntry {
284        CacheEntry::new(self.bucket(cache_bucket).join(dir), file)
285    }
286
287    /// Return the path to an archive in the cache.
288    pub fn archive(&self, id: &ArchiveId) -> PathBuf {
289        self.bucket(CacheBucket::Archive).join(id)
290    }
291
292    /// Create a temporary directory to be used as a Python virtual environment.
293    pub fn venv_dir(&self) -> io::Result<tempfile::TempDir> {
294        fs_err::create_dir_all(self.bucket(CacheBucket::Builds))?;
295        tempfile::tempdir_in(self.bucket(CacheBucket::Builds))
296    }
297
298    /// Create a temporary directory to be used for executing PEP 517 source distribution builds.
299    pub fn build_dir(&self) -> io::Result<tempfile::TempDir> {
300        fs_err::create_dir_all(self.bucket(CacheBucket::Builds))?;
301        tempfile::tempdir_in(self.bucket(CacheBucket::Builds))
302    }
303
304    /// Returns `true` if a cache entry must be revalidated given the [`Refresh`] policy.
305    pub fn must_revalidate_package(&self, package: &PackageName) -> bool {
306        match &self.refresh {
307            Refresh::None(_) => false,
308            Refresh::All(_) => true,
309            Refresh::Packages(packages, _, _) => packages.contains(package),
310        }
311    }
312
313    /// Returns `true` if a cache entry must be revalidated given the [`Refresh`] policy.
314    pub fn must_revalidate_path(&self, path: &Path) -> bool {
315        match &self.refresh {
316            Refresh::None(_) => false,
317            Refresh::All(_) => true,
318            Refresh::Packages(_, paths, _) => paths
319                .iter()
320                .any(|target| same_file::is_same_file(path, target).unwrap_or(false)),
321        }
322    }
323
324    /// Returns the [`Freshness`] for a cache entry, validating it against the [`Refresh`] policy.
325    ///
326    /// A cache entry is considered fresh if it was created after the cache itself was
327    /// initialized, or if the [`Refresh`] policy does not require revalidation.
328    pub fn freshness(
329        &self,
330        entry: &CacheEntry,
331        package: Option<&PackageName>,
332        path: Option<&Path>,
333    ) -> io::Result<Freshness> {
334        // Grab the cutoff timestamp, if it's relevant.
335        let timestamp = match &self.refresh {
336            Refresh::None(_) => return Ok(Freshness::Fresh),
337            Refresh::All(timestamp) => timestamp,
338            Refresh::Packages(packages, paths, timestamp) => {
339                if package.is_none_or(|package| packages.contains(package))
340                    || path.is_some_and(|path| {
341                        paths
342                            .iter()
343                            .any(|target| same_file::is_same_file(path, target).unwrap_or(false))
344                    })
345                {
346                    timestamp
347                } else {
348                    return Ok(Freshness::Fresh);
349                }
350            }
351        };
352
353        match fs_err::metadata(entry.path()) {
354            Ok(metadata) => {
355                if Timestamp::from_metadata(&metadata) >= *timestamp {
356                    Ok(Freshness::Fresh)
357                } else {
358                    Ok(Freshness::Stale)
359                }
360            }
361            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Freshness::Missing),
362            Err(err) => Err(err),
363        }
364    }
365
366    /// Persist a temporary directory to the artifact store, returning its unique ID.
367    pub async fn persist(
368        &self,
369        temp_dir: impl AsRef<Path>,
370        path: impl AsRef<Path>,
371    ) -> io::Result<ArchiveId> {
372        // Create a unique ID for the artifact.
373        // TODO(charlie): Support content-addressed persistence via SHAs.
374        let id = ArchiveId::new();
375
376        // Move the temporary directory into the directory store.
377        let archive_entry = self.entry(CacheBucket::Archive, "", &id);
378        fs_err::create_dir_all(archive_entry.dir())?;
379        uv_fs::rename_with_retry(temp_dir.as_ref(), archive_entry.path()).await?;
380
381        // Create a symlink to the directory store.
382        fs_err::create_dir_all(path.as_ref().parent().expect("Cache entry to have parent"))?;
383        self.create_link(&id, path.as_ref())?;
384
385        Ok(id)
386    }
387
388    /// Returns `true` if the [`Cache`] is temporary.
389    pub fn is_temporary(&self) -> bool {
390        self.temp_dir.is_some()
391    }
392
393    /// Populate the cache scaffold.
394    fn create_base_files(root: &PathBuf) -> Result<(), io::Error> {
395        // Create the cache directory, if it doesn't exist.
396        fs_err::create_dir_all(root)?;
397
398        // Add the CACHEDIR.TAG.
399        cachedir::ensure_tag(root)?;
400
401        // Add the .gitignore.
402        match fs_err::OpenOptions::new()
403            .write(true)
404            .create_new(true)
405            .open(root.join(".gitignore"))
406        {
407            Ok(mut file) => file.write_all(b"*")?,
408            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
409            Err(err) => return Err(err),
410        }
411
412        // Add an empty .gitignore to the build bucket, to ensure that the cache's own .gitignore
413        // doesn't interfere with source distribution builds. Build backends (like hatchling) will
414        // traverse upwards to look for .gitignore files.
415        fs_err::create_dir_all(root.join(CacheBucket::SourceDistributions.to_str()))?;
416        match fs_err::OpenOptions::new()
417            .write(true)
418            .create_new(true)
419            .open(
420                root.join(CacheBucket::SourceDistributions.to_str())
421                    .join(".gitignore"),
422            ) {
423            Ok(_) => {}
424            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
425            Err(err) => return Err(err),
426        }
427
428        // Add a phony .git, if it doesn't exist, to ensure that the cache isn't considered to be
429        // part of a Git repository. (Some packages will include Git metadata (like a hash) in the
430        // built version if they're in a Git repository, but the cache should be viewed as an
431        // isolated store.).
432        // We have to put this below the gitignore. Otherwise, if the build backend uses the rust
433        // ignore crate it will walk up to the top level .gitignore and ignore its python source
434        // files.
435        fs_err::OpenOptions::new().create(true).write(true).open(
436            root.join(CacheBucket::SourceDistributions.to_str())
437                .join(".git"),
438        )?;
439
440        Ok(())
441    }
442
443    /// Initialize the [`Cache`].
444    pub async fn init(self) -> Result<Self, LockedFileError> {
445        let root = &self.root;
446
447        Self::create_base_files(root)?;
448
449        // Block cache removal operations from interfering.
450        let lock_file = match LockedFile::acquire(
451            root.join(".lock"),
452            LockedFileMode::Shared,
453            root.simplified_display(),
454        )
455        .await
456        {
457            Ok(lock_file) => Some(Arc::new(lock_file)),
458            Err(err)
459                if err
460                    .as_io_error()
461                    .is_some_and(|err| err.kind() == io::ErrorKind::Unsupported) =>
462            {
463                warn!(
464                    "Shared locking is not supported by the current platform or filesystem, \
465                        reduced parallel process safety with `uv cache clean` and `uv cache prune`."
466                );
467                None
468            }
469            Err(err) => return Err(err),
470        };
471
472        Ok(Self {
473            root: std::path::absolute(root)?,
474            lock_file,
475            ..self
476        })
477    }
478
479    /// Initialize the [`Cache`], assuming that there are no other uv processes running.
480    pub fn init_no_wait(self) -> Result<Option<Self>, io::Error> {
481        let root = &self.root;
482
483        Self::create_base_files(root)?;
484
485        // Block cache removal operations from interfering.
486        let Some(lock_file) = LockedFile::acquire_no_wait(
487            root.join(".lock"),
488            LockedFileMode::Shared,
489            root.simplified_display(),
490        ) else {
491            return Ok(None);
492        };
493        Ok(Some(Self {
494            root: std::path::absolute(root)?,
495            lock_file: Some(Arc::new(lock_file)),
496            ..self
497        }))
498    }
499
500    /// Clear the cache, removing all entries.
501    pub fn clear(self, reporter: Box<dyn CleanReporter>) -> Result<Removal, io::Error> {
502        // Remove everything but `.lock`, Windows does not allow removal of a locked file
503        let mut removal = Remover::new(reporter).rm_rf(&self.root, true)?;
504        let Self {
505            root, lock_file, ..
506        } = self;
507
508        // Remove the `.lock` file, unlocking it first
509        if let Some(lock) = lock_file {
510            drop(lock);
511            fs_err::remove_file(root.join(".lock"))?;
512        }
513        removal.num_files += 1;
514
515        // Remove the root directory
516        match fs_err::remove_dir(root) {
517            Ok(()) => {
518                removal.num_dirs += 1;
519            }
520            // On Windows, when `--force` is used, the `.lock` file can exist and be unremovable,
521            // so we make this non-fatal
522            Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => {
523                trace!("Failed to remove root cache directory: not empty");
524            }
525            Err(err) => return Err(err),
526        }
527
528        Ok(removal)
529    }
530
531    /// Remove a package from the cache.
532    ///
533    /// Returns the number of entries removed from the cache.
534    pub fn remove(&self, name: &PackageName) -> Result<Removal, io::Error> {
535        // Collect the set of referenced archives.
536        let references = self.find_archive_references()?;
537
538        // Remove any entries for the package from the cache.
539        let mut summary = Removal::default();
540        for bucket in CacheBucket::iter() {
541            summary += bucket.remove(self, name)?;
542        }
543
544        // Remove any archives that are no longer referenced.
545        for (target, references) in references {
546            if references.iter().all(|path| !path.exists()) {
547                debug!("Removing dangling cache entry: {}", target.display());
548                summary += rm_rf(target)?;
549            }
550        }
551
552        Ok(summary)
553    }
554
555    /// Run the garbage collector on the cache, removing any dangling entries.
556    pub fn prune(&self, ci: bool) -> Result<Removal, io::Error> {
557        let mut summary = Removal::default();
558
559        // First, remove any top-level directories that are unused. These typically represent
560        // outdated cache buckets (e.g., `wheels-v0`, when latest is `wheels-v1`).
561        for entry in fs_err::read_dir(&self.root)? {
562            let entry = entry?;
563            let metadata = entry.metadata()?;
564
565            if entry.file_name() == "CACHEDIR.TAG"
566                || entry.file_name() == ".gitignore"
567                || entry.file_name() == ".git"
568                || entry.file_name() == ".lock"
569            {
570                continue;
571            }
572
573            if metadata.is_dir() {
574                // If the directory is not a cache bucket, remove it.
575                if CacheBucket::iter().all(|bucket| entry.file_name() != bucket.to_str()) {
576                    let path = entry.path();
577                    debug!("Removing dangling cache bucket: {}", path.display());
578                    summary += rm_rf(path)?;
579                }
580            } else {
581                // If the file is not a marker file, remove it.
582                let path = entry.path();
583                debug!("Removing dangling cache bucket: {}", path.display());
584                summary += rm_rf(path)?;
585            }
586        }
587
588        // Second, remove any cached environments. These are never referenced by symlinks, so we can
589        // remove them directly.
590        match fs_err::read_dir(self.bucket(CacheBucket::Environments)) {
591            Ok(entries) => {
592                for entry in entries {
593                    let entry = entry?;
594                    let path = fs_err::canonicalize(entry.path())?;
595                    debug!("Removing dangling cache environment: {}", path.display());
596                    summary += rm_rf(path)?;
597                }
598            }
599            Err(err) if err.kind() == io::ErrorKind::NotFound => (),
600            Err(err) => return Err(err),
601        }
602
603        // Third, if enabled, remove all unzipped wheels, leaving only the wheel archives.
604        if ci {
605            // Remove the entire pre-built wheel cache, since every entry is an unzipped wheel.
606            match fs_err::read_dir(self.bucket(CacheBucket::Wheels)) {
607                Ok(entries) => {
608                    for entry in entries {
609                        let entry = entry?;
610                        let path = fs_err::canonicalize(entry.path())?;
611                        if path.is_dir() {
612                            debug!("Removing unzipped wheel entry: {}", path.display());
613                            summary += rm_rf(path)?;
614                        }
615                    }
616                }
617                Err(err) if err.kind() == io::ErrorKind::NotFound => (),
618                Err(err) => return Err(err),
619            }
620
621            for entry in walkdir::WalkDir::new(self.bucket(CacheBucket::SourceDistributions)) {
622                let entry = entry?;
623
624                // If the directory contains a `metadata.msgpack`, then it's a built wheel revision.
625                if !entry.file_type().is_dir() {
626                    continue;
627                }
628
629                if !entry.path().join("metadata.msgpack").exists() {
630                    continue;
631                }
632
633                // Remove everything except the built wheel archive and the metadata.
634                for entry in fs_err::read_dir(entry.path())? {
635                    let entry = entry?;
636                    let path = entry.path();
637
638                    // Retain the resolved metadata (`metadata.msgpack`).
639                    if path
640                        .file_name()
641                        .is_some_and(|file_name| file_name == "metadata.msgpack")
642                    {
643                        continue;
644                    }
645
646                    // Retain any built wheel archives.
647                    if path
648                        .extension()
649                        .is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
650                    {
651                        continue;
652                    }
653
654                    debug!("Removing unzipped built wheel entry: {}", path.display());
655                    summary += rm_rf(path)?;
656                }
657            }
658        }
659
660        // Fourth, remove any unused archives (by searching for archives that are not symlinked).
661        let references = self.find_archive_references()?;
662
663        match fs_err::read_dir(self.bucket(CacheBucket::Archive)) {
664            Ok(entries) => {
665                for entry in entries {
666                    let entry = entry?;
667                    let path = fs_err::canonicalize(entry.path())?;
668                    if !references.contains_key(&path) {
669                        debug!("Removing dangling cache archive: {}", path.display());
670                        summary += rm_rf(path)?;
671                    }
672                }
673            }
674            Err(err) if err.kind() == io::ErrorKind::NotFound => (),
675            Err(err) => return Err(err),
676        }
677
678        Ok(summary)
679    }
680
681    /// Find all references to entries in the archive bucket.
682    ///
683    /// Archive entries are often referenced by symlinks in other cache buckets. This method
684    /// searches for all such references.
685    ///
686    /// Returns a map from archive path to paths that reference it.
687    fn find_archive_references(&self) -> Result<FxHashMap<PathBuf, Vec<PathBuf>>, io::Error> {
688        let mut references = FxHashMap::<PathBuf, Vec<PathBuf>>::default();
689        for bucket in [CacheBucket::SourceDistributions, CacheBucket::Wheels] {
690            let bucket_path = self.bucket(bucket);
691            if bucket_path.is_dir() {
692                let walker = walkdir::WalkDir::new(&bucket_path).into_iter();
693                for entry in walker.filter_entry(|entry| {
694                    !(
695                        // As an optimization, ignore any `.lock`, `.whl`, `.msgpack`, `.rev`, or
696                        // `.http` files, along with the `src` directory, which represents the
697                        // unpacked source distribution.
698                        entry.file_name() == "src"
699                            || entry.file_name() == ".lock"
700                            || entry.file_name() == ".gitignore"
701                            || entry.path().extension().is_some_and(|ext| {
702                                ext.eq_ignore_ascii_case("lock")
703                                    || ext.eq_ignore_ascii_case("whl")
704                                    || ext.eq_ignore_ascii_case("http")
705                                    || ext.eq_ignore_ascii_case("rev")
706                                    || ext.eq_ignore_ascii_case("msgpack")
707                            })
708                    )
709                }) {
710                    let entry = entry?;
711
712                    // On Unix, archive references use symlinks.
713                    if cfg!(unix) {
714                        if !entry.file_type().is_symlink() {
715                            continue;
716                        }
717                    }
718
719                    // On Windows, archive references are files containing structured data.
720                    if cfg!(windows) {
721                        if !entry.file_type().is_file() {
722                            continue;
723                        }
724                    }
725
726                    if let Ok(target) = self.resolve_link(entry.path()) {
727                        references
728                            .entry(target)
729                            .or_default()
730                            .push(entry.path().to_path_buf());
731                    }
732                }
733            }
734        }
735        Ok(references)
736    }
737
738    /// Create a link to a directory in the archive bucket.
739    ///
740    /// On Windows, we write structured data ([`Link`]) to a file containing the archive ID and
741    /// version. On Unix, we create a symlink to the target directory.
742    #[cfg(windows)]
743    pub fn create_link(&self, id: &ArchiveId, dst: impl AsRef<Path>) -> io::Result<()> {
744        // Serialize the link.
745        let link = Link::new(id.clone());
746        let contents = link.to_string();
747
748        // First, attempt to create a file at the location, but fail if it already exists.
749        match fs_err::OpenOptions::new()
750            .write(true)
751            .create_new(true)
752            .open(dst.as_ref())
753        {
754            Ok(mut file) => {
755                // Write the target path to the file.
756                file.write_all(contents.as_bytes())?;
757                Ok(())
758            }
759            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
760                // Write to a temporary file, then move it into place.
761                let temp_dir = tempfile::tempdir_in(dst.as_ref().parent().unwrap())?;
762                let temp_file = temp_dir.path().join("link");
763                fs_err::write(&temp_file, contents.as_bytes())?;
764
765                // Move the symlink into the target location.
766                fs_err::rename(&temp_file, dst.as_ref())?;
767
768                Ok(())
769            }
770            Err(err) => Err(err),
771        }
772    }
773
774    /// Resolve an archive link, returning the fully-resolved path.
775    ///
776    /// Returns an error if the link target does not exist.
777    #[cfg(windows)]
778    pub fn resolve_link(&self, path: impl AsRef<Path>) -> io::Result<PathBuf> {
779        // Deserialize the link.
780        let contents = fs_err::read_to_string(path.as_ref())?;
781        let link = Link::from_str(&contents)?;
782
783        // Ignore stale links.
784        if link.version != ARCHIVE_VERSION {
785            return Err(io::Error::new(
786                io::ErrorKind::NotFound,
787                "The link target does not exist.",
788            ));
789        }
790
791        // Reconstruct the path.
792        let path = self.archive(&link.id);
793        path.canonicalize()
794    }
795
796    /// Create a link to a directory in the archive bucket.
797    ///
798    /// On Windows, we write structured data ([`Link`]) to a file containing the archive ID and
799    /// version. On Unix, we create a symlink to the target directory.
800    #[cfg(unix)]
801    pub fn create_link(&self, id: &ArchiveId, dst: impl AsRef<Path>) -> io::Result<()> {
802        // Construct the link target.
803        let src = self.archive(id);
804        let dst = dst.as_ref();
805
806        // Attempt to create the symlink directly.
807        match fs_err::os::unix::fs::symlink(&src, dst) {
808            Ok(()) => Ok(()),
809            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
810                // Create a symlink, using a temporary file to ensure atomicity.
811                let temp_dir = tempfile::tempdir_in(dst.parent().unwrap())?;
812                let temp_file = temp_dir.path().join("link");
813                fs_err::os::unix::fs::symlink(&src, &temp_file)?;
814
815                // Move the symlink into the target location.
816                fs_err::rename(&temp_file, dst)?;
817
818                Ok(())
819            }
820            Err(err) => Err(err),
821        }
822    }
823
824    /// Resolve an archive link, returning the fully-resolved path.
825    ///
826    /// Returns an error if the link target does not exist.
827    #[cfg(unix)]
828    pub fn resolve_link(&self, path: impl AsRef<Path>) -> io::Result<PathBuf> {
829        path.as_ref().canonicalize()
830    }
831}
832
833/// An archive (unzipped wheel) that exists in the local cache.
834#[derive(Debug, Clone)]
835#[allow(unused)]
836struct Link {
837    /// The unique ID of the entry in the archive bucket.
838    id: ArchiveId,
839    /// The version of the archive bucket.
840    version: u8,
841}
842
843#[allow(unused)]
844impl Link {
845    /// Create a new [`Archive`] with the given ID and hashes.
846    fn new(id: ArchiveId) -> Self {
847        Self {
848            id,
849            version: ARCHIVE_VERSION,
850        }
851    }
852}
853
854impl Display for Link {
855    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
856        write!(f, "archive-v{}/{}", self.version, self.id)
857    }
858}
859
860impl FromStr for Link {
861    type Err = io::Error;
862
863    fn from_str(s: &str) -> Result<Self, Self::Err> {
864        let mut parts = s.splitn(2, '/');
865        let version = parts
866            .next()
867            .filter(|s| !s.is_empty())
868            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing version"))?;
869        let id = parts
870            .next()
871            .filter(|s| !s.is_empty())
872            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing ID"))?;
873
874        // Parse the archive version from `archive-v{version}/{id}`.
875        let version = version
876            .strip_prefix("archive-v")
877            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing version prefix"))?;
878        let version = u8::from_str(version).map_err(|err| {
879            io::Error::new(
880                io::ErrorKind::InvalidData,
881                format!("failed to parse version: {err}"),
882            )
883        })?;
884
885        // Parse the ID from `archive-v{version}/{id}`.
886        let id = ArchiveId::from_str(id).map_err(|err| {
887            io::Error::new(
888                io::ErrorKind::InvalidData,
889                format!("failed to parse ID: {err}"),
890            )
891        })?;
892
893        Ok(Self { id, version })
894    }
895}
896
897pub trait CleanReporter: Send + Sync {
898    /// Called after one file or directory is removed.
899    fn on_clean(&self);
900
901    /// Called after all files and directories are removed.
902    fn on_complete(&self);
903}
904
905/// The different kinds of data in the cache are stored in different bucket, which in our case
906/// are subdirectories of the cache root.
907#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
908pub enum CacheBucket {
909    /// Wheels (excluding built wheels), alongside their metadata and cache policy.
910    ///
911    /// There are three kinds from cache entries: Wheel metadata and policy as `MsgPack` files, the
912    /// wheels themselves, and the unzipped wheel archives. If a wheel file is over an in-memory
913    /// size threshold, we first download the zip file into the cache, then unzip it into a
914    /// directory with the same name (exclusive of the `.whl` extension).
915    ///
916    /// Cache structure:
917    ///  * `wheel-metadata-v0/pypi/foo/{foo-1.0.0-py3-none-any.msgpack, foo-1.0.0-py3-none-any.whl}`
918    ///  * `wheel-metadata-v0/<digest(index-url)>/foo/{foo-1.0.0-py3-none-any.msgpack, foo-1.0.0-py3-none-any.whl}`
919    ///  * `wheel-metadata-v0/url/<digest(url)>/foo/{foo-1.0.0-py3-none-any.msgpack, foo-1.0.0-py3-none-any.whl}`
920    ///
921    /// See `uv_client::RegistryClient::wheel_metadata` for information on how wheel metadata
922    /// is fetched.
923    ///
924    /// # Example
925    ///
926    /// Consider the following `requirements.in`:
927    /// ```text
928    /// # pypi wheel
929    /// pandas
930    /// # url wheel
931    /// flask @ https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl
932    /// ```
933    ///
934    /// When we run `pip compile`, it will only fetch and cache the metadata (and cache policy), it
935    /// doesn't need the actual wheels yet:
936    /// ```text
937    /// wheel-v0
938    /// ├── pypi
939    /// │   ...
940    /// │   ├── pandas
941    /// │   │   └── pandas-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.msgpack
942    /// │   ...
943    /// └── url
944    ///     └── 4b8be67c801a7ecb
945    ///         └── flask
946    ///             └── flask-3.0.0-py3-none-any.msgpack
947    /// ```
948    ///
949    /// We get the following `requirement.txt` from `pip compile`:
950    ///
951    /// ```text
952    /// [...]
953    /// flask @ https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl
954    /// [...]
955    /// pandas==2.1.3
956    /// [...]
957    /// ```
958    ///
959    /// If we run `pip sync` on `requirements.txt` on a different machine, it also fetches the
960    /// wheels:
961    ///
962    /// TODO(konstin): This is still wrong, we need to store the cache policy too!
963    /// ```text
964    /// wheel-v0
965    /// ├── pypi
966    /// │   ...
967    /// │   ├── pandas
968    /// │   │   ├── pandas-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
969    /// │   │   ├── pandas-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64
970    /// │   ...
971    /// └── url
972    ///     └── 4b8be67c801a7ecb
973    ///         └── flask
974    ///             └── flask-3.0.0-py3-none-any.whl
975    ///                 ├── flask
976    ///                 │   └── ...
977    ///                 └── flask-3.0.0.dist-info
978    ///                     └── ...
979    /// ```
980    ///
981    /// If we run first `pip compile` and then `pip sync` on the same machine, we get both:
982    ///
983    /// ```text
984    /// wheels-v0
985    /// ├── pypi
986    /// │   ├── ...
987    /// │   ├── pandas
988    /// │   │   ├── pandas-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.msgpack
989    /// │   │   ├── pandas-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
990    /// │   │   └── pandas-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64
991    /// │   │       ├── pandas
992    /// │   │       │   ├── ...
993    /// │   │       ├── pandas-2.1.3.dist-info
994    /// │   │       │   ├── ...
995    /// │   │       └── pandas.libs
996    /// │   ├── ...
997    /// └── url
998    ///     └── 4b8be67c801a7ecb
999    ///         └── flask
1000    ///             ├── flask-3.0.0-py3-none-any.msgpack
1001    ///             ├── flask-3.0.0-py3-none-any.msgpack
1002    ///             └── flask-3.0.0-py3-none-any
1003    ///                 ├── flask
1004    ///                 │   └── ...
1005    ///                 └── flask-3.0.0.dist-info
1006    ///                     └── ...
1007    Wheels,
1008    /// Source distributions, wheels built from source distributions, their extracted metadata, and the
1009    /// cache policy of the source distribution.
1010    ///
1011    /// The structure is similar of that of the `Wheel` bucket, except we have an additional layer
1012    /// for the source distribution filename and the metadata is at the source distribution-level,
1013    /// not at the wheel level.
1014    ///
1015    /// TODO(konstin): The cache policy should be on the source distribution level, the metadata we
1016    /// can put next to the wheels as in the `Wheels` bucket.
1017    ///
1018    /// The unzipped source distribution is stored in a directory matching the source distribution
1019    /// archive name.
1020    ///
1021    /// Source distributions are built into zipped wheel files (as PEP 517 specifies) and unzipped
1022    /// lazily before installing. So when resolving, we only build the wheel and store the archive
1023    /// file in the cache, when installing, we unpack it under the same name (exclusive of the
1024    /// `.whl` extension). You may find a mix of wheel archive zip files and unzipped wheel
1025    /// directories in the cache.
1026    ///
1027    /// Cache structure:
1028    ///  * `built-wheels-v0/pypi/foo/34a17436ed1e9669/{manifest.msgpack, metadata.msgpack, foo-1.0.0.zip, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1029    ///  * `built-wheels-v0/<digest(index-url)>/foo/foo-1.0.0.zip/{manifest.msgpack, metadata.msgpack, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1030    ///  * `built-wheels-v0/url/<digest(url)>/foo/foo-1.0.0.zip/{manifest.msgpack, metadata.msgpack, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1031    ///  * `built-wheels-v0/git/<digest(url)>/<git sha>/foo/foo-1.0.0.zip/{metadata.msgpack, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1032    ///
1033    /// But the url filename does not need to be a valid source dist filename
1034    /// (<https://github.com/search?q=path%3A**%2Frequirements.txt+master.zip&type=code>),
1035    /// so it could also be the following and we have to take any string as filename:
1036    ///  * `built-wheels-v0/url/<sha256(url)>/master.zip/metadata.msgpack`
1037    ///
1038    /// # Example
1039    ///
1040    /// The following requirements:
1041    /// ```text
1042    /// # git source dist
1043    /// pydantic-extra-types @ git+https://github.com/pydantic/pydantic-extra-types.git
1044    /// # pypi source dist
1045    /// django_allauth==0.51.0
1046    /// # url source dist
1047    /// werkzeug @ https://files.pythonhosted.org/packages/0d/cc/ff1904eb5eb4b455e442834dabf9427331ac0fa02853bf83db817a7dd53d/werkzeug-3.0.1.tar.gz
1048    /// ```
1049    ///
1050    /// ...may be cached as:
1051    /// ```text
1052    /// built-wheels-v4/
1053    /// ├── git
1054    /// │   └── 2122faf3e081fb7a
1055    /// │       └── 7a2d650a4a7b4d04
1056    /// │           ├── metadata.msgpack
1057    /// │           └── pydantic_extra_types-2.9.0-py3-none-any.whl
1058    /// ├── pypi
1059    /// │   └── django-allauth
1060    /// │       └── 0.51.0
1061    /// │           ├── 0gH-_fwv8tdJ7JwwjJsUc
1062    /// │           │   ├── django-allauth-0.51.0.tar.gz
1063    /// │           │   │   └── [UNZIPPED CONTENTS]
1064    /// │           │   ├── django_allauth-0.51.0-py3-none-any.whl
1065    /// │           │   └── metadata.msgpack
1066    /// │           └── revision.http
1067    /// └── url
1068    ///     └── 6781bd6440ae72c2
1069    ///         ├── APYY01rbIfpAo_ij9sCY6
1070    ///         │   ├── metadata.msgpack
1071    ///         │   ├── werkzeug-3.0.1-py3-none-any.whl
1072    ///         │   └── werkzeug-3.0.1.tar.gz
1073    ///         │       └── [UNZIPPED CONTENTS]
1074    ///         └── revision.http
1075    /// ```
1076    ///
1077    /// Structurally, the `manifest.msgpack` is empty, and only contains the caching information
1078    /// needed to invalidate the cache. The `metadata.msgpack` contains the metadata of the source
1079    /// distribution.
1080    SourceDistributions,
1081    /// Flat index responses, a format very similar to the simple metadata API.
1082    ///
1083    /// Cache structure:
1084    ///  * `flat-index-v0/index/<digest(flat_index_url)>.msgpack`
1085    ///
1086    /// The response is stored as `Vec<File>`.
1087    FlatIndex,
1088    /// Git repositories.
1089    Git,
1090    /// Information about an interpreter at a path.
1091    ///
1092    /// To avoid caching pyenv shims, bash scripts which may redirect to a new python version
1093    /// without the shim itself changing, we only cache when the path equals `sys.executable`, i.e.
1094    /// the path we're running is the python executable itself and not a shim.
1095    ///
1096    /// Cache structure: `interpreter-v0/<digest(path)>.msgpack`
1097    ///
1098    /// # Example
1099    ///
1100    /// The contents of each of the `MsgPack` files has a timestamp field in unix time, the [PEP 508]
1101    /// markers and some information from the `sys`/`sysconfig` modules.
1102    ///
1103    /// ```json
1104    /// {
1105    ///   "timestamp": 1698047994491,
1106    ///   "data": {
1107    ///     "markers": {
1108    ///       "implementation_name": "cpython",
1109    ///       "implementation_version": "3.12.0",
1110    ///       "os_name": "posix",
1111    ///       "platform_machine": "x86_64",
1112    ///       "platform_python_implementation": "CPython",
1113    ///       "platform_release": "6.5.0-13-generic",
1114    ///       "platform_system": "Linux",
1115    ///       "platform_version": "#13-Ubuntu SMP PREEMPT_DYNAMIC Fri Nov  3 12:16:05 UTC 2023",
1116    ///       "python_full_version": "3.12.0",
1117    ///       "python_version": "3.12",
1118    ///       "sys_platform": "linux"
1119    ///     },
1120    ///     "base_exec_prefix": "/home/ferris/.pyenv/versions/3.12.0",
1121    ///     "base_prefix": "/home/ferris/.pyenv/versions/3.12.0",
1122    ///     "sys_executable": "/home/ferris/projects/uv/.venv/bin/python"
1123    ///   }
1124    /// }
1125    /// ```
1126    ///
1127    /// [PEP 508]: https://peps.python.org/pep-0508/#environment-markers
1128    Interpreter,
1129    /// Index responses through the simple metadata API.
1130    ///
1131    /// Cache structure:
1132    ///  * `simple-v0/pypi/<package_name>.rkyv`
1133    ///  * `simple-v0/<digest(index_url)>/<package_name>.rkyv`
1134    ///
1135    /// The response is parsed into `uv_client::SimpleMetadata` before storage.
1136    Simple,
1137    /// A cache of unzipped wheels, stored as directories. This is used internally within the cache.
1138    /// When other buckets need to store directories, they should persist them to
1139    /// [`CacheBucket::Archive`], and then symlink them into the appropriate bucket. This ensures
1140    /// that cache entries can be atomically replaced and removed, as storing directories in the
1141    /// other buckets directly would make atomic operations impossible.
1142    Archive,
1143    /// Ephemeral virtual environments used to execute PEP 517 builds and other operations.
1144    Builds,
1145    /// Reusable virtual environments used to invoke Python tools.
1146    Environments,
1147    /// Cached Python downloads
1148    Python,
1149    /// Downloaded tool binaries (e.g., Ruff).
1150    Binaries,
1151}
1152
1153impl CacheBucket {
1154    fn to_str(self) -> &'static str {
1155        match self {
1156            // Note that when bumping this, you'll also need to bump it
1157            // in `crates/uv/tests/it/cache_prune.rs`.
1158            Self::SourceDistributions => "sdists-v9",
1159            Self::FlatIndex => "flat-index-v2",
1160            Self::Git => "git-v0",
1161            Self::Interpreter => "interpreter-v4",
1162            // Note that when bumping this, you'll also need to bump it
1163            // in `crates/uv/tests/it/cache_clean.rs`.
1164            Self::Simple => "simple-v18",
1165            // Note that when bumping this, you'll also need to bump it
1166            // in `crates/uv/tests/it/cache_prune.rs`.
1167            Self::Wheels => "wheels-v5",
1168            // Note that when bumping this, you'll also need to bump
1169            // `ARCHIVE_VERSION` in `crates/uv-cache/src/lib.rs`.
1170            Self::Archive => "archive-v0",
1171            Self::Builds => "builds-v0",
1172            Self::Environments => "environments-v2",
1173            Self::Python => "python-v0",
1174            Self::Binaries => "binaries-v0",
1175        }
1176    }
1177
1178    /// Remove a package from the cache bucket.
1179    ///
1180    /// Returns the number of entries removed from the cache.
1181    fn remove(self, cache: &Cache, name: &PackageName) -> Result<Removal, io::Error> {
1182        /// Returns `true` if the [`Path`] represents a built wheel for the given package.
1183        fn is_match(path: &Path, name: &PackageName) -> bool {
1184            let Ok(metadata) = fs_err::read(path.join("metadata.msgpack")) else {
1185                return false;
1186            };
1187            let Ok(metadata) = rmp_serde::from_slice::<ResolutionMetadata>(&metadata) else {
1188                return false;
1189            };
1190            metadata.name == *name
1191        }
1192
1193        let mut summary = Removal::default();
1194        match self {
1195            Self::Wheels => {
1196                // For `pypi` wheels, we expect a directory per package (indexed by name).
1197                let root = cache.bucket(self).join(WheelCacheKind::Pypi);
1198                summary += rm_rf(root.join(name.to_string()))?;
1199
1200                // For alternate indices, we expect a directory for every index (under an `index`
1201                // subdirectory), followed by a directory per package (indexed by name).
1202                let root = cache.bucket(self).join(WheelCacheKind::Index);
1203                for directory in directories(root)? {
1204                    summary += rm_rf(directory.join(name.to_string()))?;
1205                }
1206
1207                // For direct URLs, we expect a directory for every URL, followed by a
1208                // directory per package (indexed by name).
1209                let root = cache.bucket(self).join(WheelCacheKind::Url);
1210                for directory in directories(root)? {
1211                    summary += rm_rf(directory.join(name.to_string()))?;
1212                }
1213            }
1214            Self::SourceDistributions => {
1215                // For `pypi` wheels, we expect a directory per package (indexed by name).
1216                let root = cache.bucket(self).join(WheelCacheKind::Pypi);
1217                summary += rm_rf(root.join(name.to_string()))?;
1218
1219                // For alternate indices, we expect a directory for every index (under an `index`
1220                // subdirectory), followed by a directory per package (indexed by name).
1221                let root = cache.bucket(self).join(WheelCacheKind::Index);
1222                for directory in directories(root)? {
1223                    summary += rm_rf(directory.join(name.to_string()))?;
1224                }
1225
1226                // For direct URLs, we expect a directory for every URL, followed by a
1227                // directory per version. To determine whether the URL is relevant, we need to
1228                // search for a wheel matching the package name.
1229                let root = cache.bucket(self).join(WheelCacheKind::Url);
1230                for url in directories(root)? {
1231                    if directories(&url)?.any(|version| is_match(&version, name)) {
1232                        summary += rm_rf(url)?;
1233                    }
1234                }
1235
1236                // For local dependencies, we expect a directory for every path, followed by a
1237                // directory per version. To determine whether the path is relevant, we need to
1238                // search for a wheel matching the package name.
1239                let root = cache.bucket(self).join(WheelCacheKind::Path);
1240                for path in directories(root)? {
1241                    if directories(&path)?.any(|version| is_match(&version, name)) {
1242                        summary += rm_rf(path)?;
1243                    }
1244                }
1245
1246                // For Git dependencies, we expect a directory for every repository, followed by a
1247                // directory for every SHA. To determine whether the SHA is relevant, we need to
1248                // search for a wheel matching the package name.
1249                let root = cache.bucket(self).join(WheelCacheKind::Git);
1250                for repository in directories(root)? {
1251                    for sha in directories(repository)? {
1252                        if is_match(&sha, name) {
1253                            summary += rm_rf(sha)?;
1254                        }
1255                    }
1256                }
1257            }
1258            Self::Simple => {
1259                // For `pypi` wheels, we expect a rkyv file per package, indexed by name.
1260                let root = cache.bucket(self).join(WheelCacheKind::Pypi);
1261                summary += rm_rf(root.join(format!("{name}.rkyv")))?;
1262
1263                // For alternate indices, we expect a directory for every index (under an `index`
1264                // subdirectory), followed by a directory per package (indexed by name).
1265                let root = cache.bucket(self).join(WheelCacheKind::Index);
1266                for directory in directories(root)? {
1267                    summary += rm_rf(directory.join(format!("{name}.rkyv")))?;
1268                }
1269            }
1270            Self::FlatIndex => {
1271                // We can't know if the flat index includes a package, so we just remove the entire
1272                // cache entry.
1273                let root = cache.bucket(self);
1274                summary += rm_rf(root)?;
1275            }
1276            Self::Git
1277            | Self::Interpreter
1278            | Self::Archive
1279            | Self::Builds
1280            | Self::Environments
1281            | Self::Python
1282            | Self::Binaries => {
1283                // Nothing to do.
1284            }
1285        }
1286        Ok(summary)
1287    }
1288
1289    /// Return an iterator over all cache buckets.
1290    pub fn iter() -> impl Iterator<Item = Self> {
1291        [
1292            Self::Wheels,
1293            Self::SourceDistributions,
1294            Self::FlatIndex,
1295            Self::Git,
1296            Self::Interpreter,
1297            Self::Simple,
1298            Self::Archive,
1299            Self::Builds,
1300            Self::Environments,
1301            Self::Binaries,
1302        ]
1303        .iter()
1304        .copied()
1305    }
1306}
1307
1308impl Display for CacheBucket {
1309    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1310        f.write_str(self.to_str())
1311    }
1312}
1313
1314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315pub enum Freshness {
1316    /// The cache entry is fresh according to the [`Refresh`] policy.
1317    Fresh,
1318    /// The cache entry is stale according to the [`Refresh`] policy.
1319    Stale,
1320    /// The cache entry does not exist.
1321    Missing,
1322}
1323
1324impl Freshness {
1325    pub const fn is_fresh(self) -> bool {
1326        matches!(self, Self::Fresh)
1327    }
1328
1329    pub const fn is_stale(self) -> bool {
1330        matches!(self, Self::Stale)
1331    }
1332}
1333
1334/// A refresh policy for cache entries.
1335#[derive(Debug, Clone)]
1336pub enum Refresh {
1337    /// Don't refresh any entries.
1338    None(Timestamp),
1339    /// Refresh entries linked to the given packages, if created before the given timestamp.
1340    Packages(Vec<PackageName>, Vec<Box<Path>>, Timestamp),
1341    /// Refresh all entries created before the given timestamp.
1342    All(Timestamp),
1343}
1344
1345impl Refresh {
1346    /// Determine the refresh strategy to use based on the command-line arguments.
1347    pub fn from_args(refresh: Option<bool>, refresh_package: Vec<PackageName>) -> Self {
1348        let timestamp = Timestamp::now();
1349        match refresh {
1350            Some(true) => Self::All(timestamp),
1351            Some(false) => Self::None(timestamp),
1352            None => {
1353                if refresh_package.is_empty() {
1354                    Self::None(timestamp)
1355                } else {
1356                    Self::Packages(refresh_package, vec![], timestamp)
1357                }
1358            }
1359        }
1360    }
1361
1362    /// Return the [`Timestamp`] associated with the refresh policy.
1363    pub fn timestamp(&self) -> Timestamp {
1364        match self {
1365            Self::None(timestamp) => *timestamp,
1366            Self::Packages(.., timestamp) => *timestamp,
1367            Self::All(timestamp) => *timestamp,
1368        }
1369    }
1370
1371    /// Returns `true` if no packages should be reinstalled.
1372    pub fn is_none(&self) -> bool {
1373        matches!(self, Self::None(_))
1374    }
1375
1376    /// Combine two [`Refresh`] policies, taking the "max" of the two policies.
1377    #[must_use]
1378    pub fn combine(self, other: Self) -> Self {
1379        match (self, other) {
1380            // If the policy is `None`, return the existing refresh policy.
1381            // Take the `max` of the two timestamps.
1382            (Self::None(t1), Self::None(t2)) => Self::None(t1.max(t2)),
1383            (Self::None(t1), Self::All(t2)) => Self::All(t1.max(t2)),
1384            (Self::None(t1), Self::Packages(packages, paths, t2)) => {
1385                Self::Packages(packages, paths, t1.max(t2))
1386            }
1387
1388            // If the policy is `All`, refresh all packages.
1389            (Self::All(t1), Self::None(t2) | Self::All(t2) | Self::Packages(.., t2)) => {
1390                Self::All(t1.max(t2))
1391            }
1392
1393            // If the policy is `Packages`, take the "max" of the two policies.
1394            (Self::Packages(packages, paths, t1), Self::None(t2)) => {
1395                Self::Packages(packages, paths, t1.max(t2))
1396            }
1397            (Self::Packages(.., t1), Self::All(t2)) => Self::All(t1.max(t2)),
1398            (Self::Packages(packages1, paths1, t1), Self::Packages(packages2, paths2, t2)) => {
1399                Self::Packages(
1400                    packages1.into_iter().chain(packages2).collect(),
1401                    paths1.into_iter().chain(paths2).collect(),
1402                    t1.max(t2),
1403                )
1404            }
1405        }
1406    }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411    use std::str::FromStr;
1412
1413    use crate::ArchiveId;
1414
1415    use super::Link;
1416
1417    #[test]
1418    fn test_link_round_trip() {
1419        let id = ArchiveId::new();
1420        let link = Link::new(id);
1421        let s = link.to_string();
1422        let parsed = Link::from_str(&s).unwrap();
1423        assert_eq!(link.id, parsed.id);
1424        assert_eq!(link.version, parsed.version);
1425    }
1426
1427    #[test]
1428    fn test_link_deserialize() {
1429        assert!(Link::from_str("archive-v0/foo").is_ok());
1430        assert!(Link::from_str("archive/foo").is_err());
1431        assert!(Link::from_str("v1/foo").is_err());
1432        assert!(Link::from_str("archive-v0/").is_err());
1433    }
1434}