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}