[go: up one dir, main page]

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