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