uv_installer/plan.rs
1use std::sync::Arc;
2
3use anyhow::{Result, bail};
4use owo_colors::OwoColorize;
5use tracing::{debug, warn};
6
7use uv_cache::{Cache, CacheBucket, WheelCache};
8use uv_cache_info::Timestamp;
9use uv_configuration::{BuildOptions, Reinstall};
10use uv_distribution::{
11 BuiltWheelIndex, HttpArchivePointer, LocalArchivePointer, RegistryWheelIndex,
12};
13use uv_distribution_filename::WheelFilename;
14use uv_distribution_types::{
15 BuiltDist, CachedDirectUrlDist, CachedDist, ConfigSettings, Dist, Error, ExtraBuildRequires,
16 ExtraBuildVariables, Hashed, IndexLocations, InstalledDist, Name, PackageConfigSettings,
17 RequirementSource, Resolution, ResolvedDist, SourceDist,
18};
19use uv_fs::Simplified;
20use uv_normalize::PackageName;
21use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags};
22use uv_pypi_types::VerbatimParsedUrl;
23use uv_python::PythonEnvironment;
24use uv_types::HashStrategy;
25
26use crate::satisfies::RequirementSatisfaction;
27use crate::{InstallationStrategy, SitePackages};
28
29/// A planner to generate an [`Plan`] based on a set of requirements.
30#[derive(Debug)]
31pub struct Planner<'a> {
32 resolution: &'a Resolution,
33}
34
35impl<'a> Planner<'a> {
36 /// Set the requirements use in the [`Plan`].
37 pub fn new(resolution: &'a Resolution) -> Self {
38 Self { resolution }
39 }
40
41 /// Partition a set of requirements into those that should be linked from the cache, those that
42 /// need to be downloaded, and those that should be removed.
43 ///
44 /// The install plan will respect cache [`Freshness`]. Specifically, if refresh is enabled, the
45 /// plan will respect cache entries created after the current time (as per the [`Refresh`]
46 /// policy). Otherwise, entries will be ignored. The downstream distribution database may still
47 /// read those entries from the cache after revalidating them.
48 ///
49 /// The install plan will also respect the required hashes, such that it will never return a
50 /// cached distribution that does not match the required hash. Like pip, though, it _will_
51 /// return an _installed_ distribution that does not match the required hash.
52 pub fn build(
53 self,
54 mut site_packages: SitePackages,
55 installation: InstallationStrategy,
56 reinstall: &Reinstall,
57 build_options: &BuildOptions,
58 hasher: &HashStrategy,
59 index_locations: &IndexLocations,
60 config_settings: &ConfigSettings,
61 config_settings_package: &PackageConfigSettings,
62 extra_build_requires: &ExtraBuildRequires,
63 extra_build_variables: &ExtraBuildVariables,
64 cache: &Cache,
65 venv: &PythonEnvironment,
66 tags: &Tags,
67 ) -> Result<Plan> {
68 // Index all the already-downloaded wheels in the cache.
69 let mut registry_index = RegistryWheelIndex::new(
70 cache,
71 tags,
72 index_locations,
73 hasher,
74 config_settings,
75 config_settings_package,
76 extra_build_requires,
77 extra_build_variables,
78 );
79 let built_index = BuiltWheelIndex::new(
80 cache,
81 tags,
82 hasher,
83 config_settings,
84 config_settings_package,
85 extra_build_requires,
86 extra_build_variables,
87 );
88
89 let mut cached = vec![];
90 let mut remote = vec![];
91 let mut reinstalls = vec![];
92 let mut extraneous = vec![];
93
94 // TODO(charlie): There are a few assumptions here that are hard to spot:
95 //
96 // 1. Apparently, we never return direct URL distributions as [`ResolvedDist::Installed`].
97 // If you trace the resolver, we only ever return [`ResolvedDist::Installed`] if you go
98 // through the [`CandidateSelector`], and we only go through the [`CandidateSelector`]
99 // for registry distributions.
100 //
101 // 2. We expect any distribution returned as [`ResolvedDist::Installed`] to hit the
102 // "Requirement already installed" path (hence the `unreachable!`) a few lines below it.
103 // So, e.g., if a package is marked as `--reinstall`, we _expect_ that it's not passed in
104 // as [`ResolvedDist::Installed`] here.
105 for dist in self.resolution.distributions() {
106 // Check if the package should be reinstalled.
107 let reinstall = reinstall.contains_package(dist.name())
108 || dist
109 .source_tree()
110 .is_some_and(|source_tree| reinstall.contains_path(source_tree));
111
112 // Check if installation of a binary version of the package should be allowed.
113 let no_binary = build_options.no_binary_package(dist.name());
114 let no_build = build_options.no_build_package(dist.name());
115
116 // Determine whether the distribution is already installed.
117 let installed_dists = site_packages.remove_packages(dist.name());
118 if reinstall {
119 reinstalls.extend(installed_dists);
120 } else {
121 match installed_dists.as_slice() {
122 [] => {}
123 [installed] => {
124 let source = RequirementSource::from(dist);
125 match RequirementSatisfaction::check(
126 dist.name(),
127 installed,
128 &source,
129 installation,
130 tags,
131 config_settings,
132 config_settings_package,
133 extra_build_requires,
134 extra_build_variables,
135 ) {
136 RequirementSatisfaction::Mismatch => {
137 debug!(
138 "Requirement installed, but mismatched:\n Installed: {installed:?}\n Requested: {source:?}"
139 );
140 }
141 RequirementSatisfaction::Satisfied => {
142 debug!("Requirement already installed: {installed}");
143 continue;
144 }
145 RequirementSatisfaction::OutOfDate => {
146 debug!("Requirement installed, but not fresh: {installed}");
147
148 // If we made it here, something went wrong in the resolver, because it returned an
149 // already-installed distribution that we "shouldn't" use. Typically, this means the
150 // distribution was considered out-of-date, but in a way that the resolver didn't
151 // detect, and is indicative of drift between the resolver's candidate selector and
152 // the install plan. For example, at present, the resolver doesn't check that an
153 // installed distribution was built with the expected build settings. Treat it as
154 // up-to-date for now; it's just means we may not rebuild a package when we otherwise
155 // should. This is a known issue, but should only affect the `uv pip` CLI, as the
156 // project APIs never return installed distributions during resolution (i.e., the
157 // resolver is stateless).
158 // TODO(charlie): Incorporate these checks into the resolver.
159 if matches!(dist, ResolvedDist::Installed { .. }) {
160 warn!(
161 "Installed distribution was considered out-of-date, but returned by the resolver: {dist}"
162 );
163 continue;
164 }
165 }
166 RequirementSatisfaction::CacheInvalid => {
167 // Already logged
168 }
169 }
170 reinstalls.push(installed.clone());
171 }
172 // We reinstall installed distributions with multiple versions because
173 // we do not want to keep multiple incompatible versions but removing
174 // one version is likely to break another.
175 _ => reinstalls.extend(installed_dists),
176 }
177 }
178
179 let ResolvedDist::Installable { dist, .. } = dist else {
180 unreachable!("Installed distribution could not be found in site-packages: {dist}");
181 };
182
183 if cache.must_revalidate_package(dist.name())
184 || dist
185 .source_tree()
186 .is_some_and(|source_tree| cache.must_revalidate_path(source_tree))
187 {
188 debug!("Must revalidate requirement: {}", dist.name());
189 remote.push(dist.clone());
190 continue;
191 }
192
193 // Identify any cached distributions that satisfy the requirement.
194 match dist.as_ref() {
195 Dist::Built(BuiltDist::Registry(wheel)) => {
196 if let Some(distribution) = registry_index.get(wheel.name()).find_map(|entry| {
197 if *entry.index.url() != wheel.best_wheel().index {
198 return None;
199 }
200 if entry.dist.filename != wheel.best_wheel().filename {
201 return None;
202 }
203 if entry.built && no_build {
204 return None;
205 }
206 if !entry.built && no_binary {
207 return None;
208 }
209 Some(&entry.dist)
210 }) {
211 debug!("Registry requirement already cached: {distribution}");
212 cached.push(CachedDist::Registry(distribution.clone()));
213 continue;
214 }
215 }
216 Dist::Built(BuiltDist::DirectUrl(wheel)) => {
217 if !wheel.filename.is_compatible(tags) {
218 let hint = generate_wheel_compatibility_hint(&wheel.filename, tags);
219 if let Some(hint) = hint {
220 bail!(
221 "A URL dependency is incompatible with the current platform: {}\n\n{}{} {}",
222 wheel.url,
223 "hint".bold().cyan(),
224 ":".bold(),
225 hint
226 );
227 }
228 bail!(
229 "A URL dependency is incompatible with the current platform: {}",
230 wheel.url
231 );
232 }
233
234 if no_binary {
235 bail!(
236 "A URL dependency points to a wheel which conflicts with `--no-binary`: {}",
237 wheel.url
238 );
239 }
240
241 // Find the exact wheel from the cache, since we know the filename in
242 // advance.
243 let cache_entry = cache
244 .shard(
245 CacheBucket::Wheels,
246 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
247 )
248 .entry(format!("{}.http", wheel.filename.cache_key()));
249
250 // Read the HTTP pointer.
251 match HttpArchivePointer::read_from(&cache_entry) {
252 Ok(Some(pointer)) => {
253 let cache_info = pointer.to_cache_info();
254 let build_info = pointer.to_build_info();
255 let archive = pointer.into_archive();
256 if archive.satisfies(hasher.get(dist.as_ref())) {
257 let cached_dist = CachedDirectUrlDist {
258 filename: wheel.filename.clone(),
259 url: VerbatimParsedUrl {
260 parsed_url: wheel.parsed_url(),
261 verbatim: wheel.url.clone(),
262 },
263 hashes: archive.hashes,
264 cache_info,
265 build_info,
266 path: cache.archive(&archive.id).into_boxed_path(),
267 };
268
269 debug!("URL wheel requirement already cached: {cached_dist}");
270 cached.push(CachedDist::Url(cached_dist));
271 continue;
272 }
273 debug!(
274 "Cached URL wheel requirement does not match expected hash policy for: {wheel}"
275 );
276 }
277 Ok(None) => {}
278 Err(err) => {
279 debug!(
280 "Failed to deserialize cached URL wheel requirement for: {wheel} ({err})"
281 );
282 }
283 }
284 }
285 Dist::Built(BuiltDist::Path(wheel)) => {
286 // Validate that the path exists.
287 if !wheel.install_path.exists() {
288 return Err(Error::NotFound(wheel.url.to_url()).into());
289 }
290
291 if !wheel.filename.is_compatible(tags) {
292 let hint = generate_wheel_compatibility_hint(&wheel.filename, tags);
293 if let Some(hint) = hint {
294 bail!(
295 "A path dependency is incompatible with the current platform: {}\n\n{}{} {}",
296 wheel.install_path.user_display(),
297 "hint".bold().cyan(),
298 ":".bold(),
299 hint
300 );
301 }
302 bail!(
303 "A path dependency is incompatible with the current platform: {}",
304 wheel.install_path.user_display()
305 );
306 }
307
308 if no_binary {
309 bail!(
310 "A path dependency points to a wheel which conflicts with `--no-binary`: {}",
311 wheel.url
312 );
313 }
314
315 // Find the exact wheel from the cache, since we know the filename in
316 // advance.
317 let cache_entry = cache
318 .shard(
319 CacheBucket::Wheels,
320 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
321 )
322 .entry(format!("{}.rev", wheel.filename.cache_key()));
323
324 match LocalArchivePointer::read_from(&cache_entry) {
325 Ok(Some(pointer)) => match Timestamp::from_path(&wheel.install_path) {
326 Ok(timestamp) => {
327 if pointer.is_up_to_date(timestamp) {
328 let cache_info = pointer.to_cache_info();
329 let build_info = pointer.to_build_info();
330 let archive = pointer.into_archive();
331 if archive.satisfies(hasher.get(dist.as_ref())) {
332 let cached_dist = CachedDirectUrlDist {
333 filename: wheel.filename.clone(),
334 url: VerbatimParsedUrl {
335 parsed_url: wheel.parsed_url(),
336 verbatim: wheel.url.clone(),
337 },
338 hashes: archive.hashes,
339 cache_info,
340 build_info,
341 path: cache.archive(&archive.id).into_boxed_path(),
342 };
343
344 debug!(
345 "Path wheel requirement already cached: {cached_dist}"
346 );
347 cached.push(CachedDist::Url(cached_dist));
348 continue;
349 }
350 debug!(
351 "Cached path wheel requirement does not match expected hash policy for: {wheel}"
352 );
353 }
354 }
355 Err(err) => {
356 debug!("Failed to get timestamp for wheel {wheel} ({err})");
357 }
358 },
359 Ok(None) => {}
360 Err(err) => {
361 debug!(
362 "Failed to deserialize cached path wheel requirement for: {wheel} ({err})"
363 );
364 }
365 }
366 }
367 Dist::Source(SourceDist::Registry(sdist)) => {
368 if let Some(distribution) = registry_index.get(sdist.name()).find_map(|entry| {
369 if *entry.index.url() != sdist.index {
370 return None;
371 }
372 if entry.dist.filename.name != sdist.name {
373 return None;
374 }
375 if entry.dist.filename.version != sdist.version {
376 return None;
377 }
378 if entry.built && no_build {
379 return None;
380 }
381 if !entry.built && no_binary {
382 return None;
383 }
384 Some(&entry.dist)
385 }) {
386 debug!("Registry requirement already cached: {distribution}");
387 cached.push(CachedDist::Registry(distribution.clone()));
388 continue;
389 }
390 }
391 Dist::Source(SourceDist::DirectUrl(sdist)) => {
392 // Find the most-compatible wheel from the cache, since we don't know
393 // the filename in advance.
394 match built_index.url(sdist) {
395 Ok(Some(wheel)) => {
396 if wheel.filename.name == sdist.name {
397 let cached_dist = wheel.into_url_dist(sdist);
398 debug!("URL source requirement already cached: {cached_dist}");
399 cached.push(CachedDist::Url(cached_dist));
400 continue;
401 }
402
403 warn!(
404 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
405 sdist, wheel.filename
406 );
407 }
408 Ok(None) => {}
409 Err(err) => {
410 debug!(
411 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
412 );
413 }
414 }
415 }
416 Dist::Source(SourceDist::Git(sdist)) => {
417 // Find the most-compatible wheel from the cache, since we don't know
418 // the filename in advance.
419 if let Some(wheel) = built_index.git(sdist) {
420 if wheel.filename.name == sdist.name {
421 let cached_dist = wheel.into_git_dist(sdist);
422 debug!("Git source requirement already cached: {cached_dist}");
423 cached.push(CachedDist::Url(cached_dist));
424 continue;
425 }
426
427 warn!(
428 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
429 sdist, wheel.filename
430 );
431 }
432 }
433 Dist::Source(SourceDist::Path(sdist)) => {
434 // Validate that the path exists.
435 if !sdist.install_path.exists() {
436 return Err(Error::NotFound(sdist.url.to_url()).into());
437 }
438
439 // Find the most-compatible wheel from the cache, since we don't know
440 // the filename in advance.
441 match built_index.path(sdist) {
442 Ok(Some(wheel)) => {
443 if wheel.filename.name == sdist.name {
444 let cached_dist = wheel.into_path_dist(sdist);
445 debug!("Path source requirement already cached: {cached_dist}");
446 cached.push(CachedDist::Url(cached_dist));
447 continue;
448 }
449
450 warn!(
451 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
452 sdist, wheel.filename
453 );
454 }
455 Ok(None) => {}
456 Err(err) => {
457 debug!(
458 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
459 );
460 }
461 }
462 }
463 Dist::Source(SourceDist::Directory(sdist)) => {
464 // Validate that the path exists.
465 if !sdist.install_path.exists() {
466 return Err(Error::NotFound(sdist.url.to_url()).into());
467 }
468
469 // Find the most-compatible wheel from the cache, since we don't know
470 // the filename in advance.
471 match built_index.directory(sdist) {
472 Ok(Some(wheel)) => {
473 if wheel.filename.name == sdist.name {
474 let cached_dist = wheel.into_directory_dist(sdist);
475 debug!(
476 "Directory source requirement already cached: {cached_dist}"
477 );
478 cached.push(CachedDist::Url(cached_dist));
479 continue;
480 }
481
482 warn!(
483 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
484 sdist, wheel.filename
485 );
486 }
487 Ok(None) => {}
488 Err(err) => {
489 debug!(
490 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
491 );
492 }
493 }
494 }
495 }
496
497 debug!("Identified uncached distribution: {dist}");
498 remote.push(dist.clone());
499 }
500
501 // Remove any unnecessary packages.
502 if site_packages.any() {
503 // Retain seed packages unless: (1) the virtual environment was created by uv and
504 // (2) the `--seed` argument was not passed to `uv venv`.
505 let seed_packages = !venv.cfg().is_ok_and(|cfg| cfg.is_uv() && !cfg.is_seed());
506 for dist_info in site_packages {
507 if seed_packages && is_seed_package(&dist_info, venv) {
508 debug!("Preserving seed package: {dist_info}");
509 continue;
510 }
511
512 debug!("Unnecessary package: {dist_info}");
513 extraneous.push(dist_info);
514 }
515 }
516
517 Ok(Plan {
518 cached,
519 remote,
520 reinstalls,
521 extraneous,
522 })
523 }
524}
525
526/// Returns `true` if the given distribution is a seed package.
527fn is_seed_package(dist_info: &InstalledDist, venv: &PythonEnvironment) -> bool {
528 if venv.interpreter().python_tuple() >= (3, 12) {
529 matches!(dist_info.name().as_ref(), "uv" | "pip")
530 } else {
531 // Include `setuptools` and `wheel` on Python <3.12.
532 matches!(
533 dist_info.name().as_ref(),
534 "pip" | "setuptools" | "wheel" | "uv"
535 )
536 }
537}
538
539/// Generate a hint for explaining wheel compatibility issues.
540fn generate_wheel_compatibility_hint(filename: &WheelFilename, tags: &Tags) -> Option<String> {
541 let TagCompatibility::Incompatible(incompatible_tag) = filename.compatibility(tags) else {
542 return None;
543 };
544
545 match incompatible_tag {
546 IncompatibleTag::Python => {
547 let wheel_tags = filename.python_tags();
548 let current_tag = tags.python_tag();
549
550 if let Some(current) = current_tag {
551 let message = if let Some(pretty) = current.pretty() {
552 format!("{} (`{}`)", pretty.cyan(), current.cyan())
553 } else {
554 format!("`{}`", current.cyan())
555 };
556
557 Some(format!(
558 "The wheel is compatible with {}, but you're using {}",
559 wheel_tags
560 .iter()
561 .map(|tag| if let Some(pretty) = tag.pretty() {
562 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
563 } else {
564 format!("`{}`", tag.cyan())
565 })
566 .collect::<Vec<_>>()
567 .join(", "),
568 message
569 ))
570 } else {
571 Some(format!(
572 "The wheel requires {}",
573 wheel_tags
574 .iter()
575 .map(|tag| if let Some(pretty) = tag.pretty() {
576 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
577 } else {
578 format!("`{}`", tag.cyan())
579 })
580 .collect::<Vec<_>>()
581 .join(", ")
582 ))
583 }
584 }
585 IncompatibleTag::Abi => {
586 let wheel_tags = filename.abi_tags();
587 let current_tag = tags.abi_tag();
588
589 if let Some(current) = current_tag {
590 let message = if let Some(pretty) = current.pretty() {
591 format!("{} (`{}`)", pretty.cyan(), current.cyan())
592 } else {
593 format!("`{}`", current.cyan())
594 };
595 Some(format!(
596 "The wheel is compatible with {}, but you're using {}",
597 wheel_tags
598 .iter()
599 .map(|tag| if let Some(pretty) = tag.pretty() {
600 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
601 } else {
602 format!("`{}`", tag.cyan())
603 })
604 .collect::<Vec<_>>()
605 .join(", "),
606 message
607 ))
608 } else {
609 Some(format!(
610 "The wheel requires {}",
611 wheel_tags
612 .iter()
613 .map(|tag| if let Some(pretty) = tag.pretty() {
614 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
615 } else {
616 format!("`{}`", tag.cyan())
617 })
618 .collect::<Vec<_>>()
619 .join(", ")
620 ))
621 }
622 }
623 IncompatibleTag::Platform => {
624 let wheel_tags = filename.platform_tags();
625 let current_tag = tags.platform_tag();
626
627 if let Some(current) = current_tag {
628 let message = if let Some(pretty) = current.pretty() {
629 format!("{} (`{}`)", pretty.cyan(), current.cyan())
630 } else {
631 format!("`{}`", current.cyan())
632 };
633 Some(format!(
634 "The wheel is compatible with {}, but you're on {}",
635 wheel_tags
636 .iter()
637 .map(|tag| if let Some(pretty) = tag.pretty() {
638 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
639 } else {
640 format!("`{}`", tag.cyan())
641 })
642 .collect::<Vec<_>>()
643 .join(", "),
644 message
645 ))
646 } else {
647 Some(format!(
648 "The wheel requires {}",
649 wheel_tags
650 .iter()
651 .map(|tag| if let Some(pretty) = tag.pretty() {
652 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
653 } else {
654 format!("`{}`", tag.cyan())
655 })
656 .collect::<Vec<_>>()
657 .join(", ")
658 ))
659 }
660 }
661 _ => None,
662 }
663}
664
665#[derive(Debug, Default)]
666pub struct Plan {
667 /// The distributions that are not already installed in the current environment, but are
668 /// available in the local cache.
669 pub cached: Vec<CachedDist>,
670
671 /// The distributions that are not already installed in the current environment, and are
672 /// not available in the local cache.
673 pub remote: Vec<Arc<Dist>>,
674
675 /// Any distributions that are already installed in the current environment, but will be
676 /// re-installed (including upgraded) to satisfy the requirements.
677 pub reinstalls: Vec<InstalledDist>,
678
679 /// Any distributions that are already installed in the current environment, and are
680 /// _not_ necessary to satisfy the requirements.
681 pub extraneous: Vec<InstalledDist>,
682}
683
684impl Plan {
685 /// Returns `true` if the plan is empty.
686 pub fn is_empty(&self) -> bool {
687 self.cached.is_empty()
688 && self.remote.is_empty()
689 && self.reinstalls.is_empty()
690 && self.extraneous.is_empty()
691 }
692
693 /// Partition the remote distributions based on a predicate function.
694 ///
695 /// Returns a tuple of plans, where the first plan contains the remote distributions that match
696 /// the predicate, and the second plan contains those that do not.
697 ///
698 /// Any extraneous and cached distributions will be returned in the first plan, while the second
699 /// plan will contain any `false` matches from the remote distributions, along with any
700 /// reinstalls for those distributions.
701 pub fn partition<F>(self, mut f: F) -> (Self, Self)
702 where
703 F: FnMut(&PackageName) -> bool,
704 {
705 let Self {
706 cached,
707 remote,
708 reinstalls,
709 extraneous,
710 } = self;
711
712 // Partition the remote distributions based on the predicate function.
713 let (left_remote, right_remote) = remote
714 .into_iter()
715 .partition::<Vec<_>, _>(|dist| f(dist.name()));
716
717 // If any remote distributions are not matched, but are already installed, ensure that
718 // they're uninstalled as part of the right plan. (Uninstalling them as part of the left
719 // plan risks uninstalling them from the environment _prior_ to the replacement being built.)
720 let (left_reinstalls, right_reinstalls) = reinstalls
721 .into_iter()
722 .partition::<Vec<_>, _>(|dist| !right_remote.iter().any(|d| d.name() == dist.name()));
723
724 // If the right plan is non-empty, then remove extraneous distributions as part of the
725 // right plan, so they're present until the very end. Otherwise, we risk removing extraneous
726 // packages that are actually build dependencies.
727 let (left_extraneous, right_extraneous) = if right_remote.is_empty() {
728 (extraneous, vec![])
729 } else {
730 (vec![], extraneous)
731 };
732
733 // Always include the cached distributions in the left plan.
734 let (left_cached, right_cached) = (cached, vec![]);
735
736 // Include all cached and extraneous distributions in the left plan.
737 let left_plan = Self {
738 cached: left_cached,
739 remote: left_remote,
740 reinstalls: left_reinstalls,
741 extraneous: left_extraneous,
742 };
743
744 // The right plan will only contain the remote distributions that did not match the predicate,
745 // along with any reinstalls for those distributions.
746 let right_plan = Self {
747 cached: right_cached,
748 remote: right_remote,
749 reinstalls: right_reinstalls,
750 extraneous: right_extraneous,
751 };
752
753 (left_plan, right_plan)
754 }
755}