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