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