Skip to main content

uv_resolver/lock/
installable.rs

1use std::collections::BTreeSet;
2use std::collections::VecDeque;
3use std::collections::hash_map::Entry;
4use std::path::Path;
5use std::sync::Arc;
6
7use either::Either;
8use itertools::Itertools;
9use petgraph::Graph;
10use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
11
12use uv_configuration::{
13    BuildOptions, DependencyGroupsWithDefaults, ExtrasSpecification,
14    ExtrasSpecificationWithDefaults, InstallOptions,
15};
16use uv_distribution_types::{Edge, Node, Resolution, ResolvedDist};
17use uv_normalize::{DefaultExtras, ExtraName, GroupName, PackageName};
18use uv_platform_tags::Tags;
19use uv_pypi_types::{ConflictKind, ConflictSet, ResolverMarkerEnvironment};
20
21use crate::lock::{
22    Dependency, DependencySelectionContext, HashedDist, LockErrorKind, Package, PackageId,
23    SelectedDependency, TagPolicy,
24};
25use crate::{Lock, LockError, UniversalMarker};
26
27fn newly_activated_extras<'lock>(
28    dep: &'lock Dependency,
29    activated_extras: &[(&'lock PackageName, &'lock ExtraName)],
30) -> Vec<(&'lock PackageName, &'lock ExtraName)> {
31    dep.extra
32        .iter()
33        .filter_map(|extra| {
34            let key = (&dep.package_id.name, extra);
35            (!activated_extras.contains(&key)).then_some(key)
36        })
37        .collect()
38}
39
40/// Record another condition under which a locked package and optional extra are reachable.
41///
42/// Returns `true` when the combined reachability changed.
43fn add_reachability<'lock>(
44    reachability: &mut FxHashMap<(&'lock PackageId, Option<&'lock ExtraName>), UniversalMarker>,
45    key: (&'lock PackageId, Option<&'lock ExtraName>),
46    marker: UniversalMarker,
47) -> bool {
48    match reachability.entry(key) {
49        Entry::Occupied(mut entry) => {
50            let mut combined = *entry.get();
51            combined.or(marker);
52            if combined == *entry.get() {
53                false
54            } else {
55                entry.insert(combined);
56                true
57            }
58        }
59        Entry::Vacant(entry) => {
60            entry.insert(marker);
61            true
62        }
63    }
64}
65
66/// Determines which dependencies are included from an install target root.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum InstallableRootKind {
69    /// Include the root's production dependencies and selected dependency groups.
70    Production,
71    /// Include only the root's selected dependency groups.
72    DependencyGroups,
73}
74
75pub trait Installable<'lock> {
76    /// Return the root install path.
77    fn install_path(&self) -> &'lock Path;
78
79    /// Return the [`Lock`] to install.
80    fn lock(&self) -> &'lock Lock;
81
82    /// Return the [`PackageName`] of the root packages in the target.
83    fn roots(&self) -> impl Iterator<Item = &PackageName>;
84
85    /// Return the package whose dependency groups, but not production dependencies, are included.
86    fn group_root(&self, _groups: &DependencyGroupsWithDefaults) -> Option<&PackageName> {
87        None
88    }
89
90    /// Return whether a dependency group should be included for its owning package.
91    ///
92    /// A `None` package represents groups defined directly on a non-project workspace root.
93    fn includes_group(
94        &self,
95        _package: Option<&PackageName>,
96        group: &GroupName,
97        groups: &DependencyGroupsWithDefaults,
98    ) -> bool {
99        groups.contains(group)
100    }
101
102    /// Return the [`PackageName`] of the target, if available.
103    fn project_name(&self) -> Option<&PackageName>;
104
105    /// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root.
106    fn to_resolution(
107        &self,
108        marker_env: &ResolverMarkerEnvironment,
109        tags: &Tags,
110        extras: &ExtrasSpecificationWithDefaults,
111        groups: &DependencyGroupsWithDefaults,
112        build_options: &BuildOptions,
113        install_options: &InstallOptions,
114    ) -> Result<Resolution, LockError> {
115        let resolve_root = |root_name: &PackageName| {
116            self.lock()
117                .find_by_name(root_name)
118                .map_err(|_| LockErrorKind::MultipleRootPackages {
119                    name: root_name.clone(),
120                })?
121                .ok_or_else(|| {
122                    LockError::from(LockErrorKind::MissingRootPackage {
123                        name: root_name.clone(),
124                    })
125                })
126        };
127        let roots = self
128            .roots()
129            .map(&resolve_root)
130            .collect::<Result<Vec<_>, LockError>>()?;
131        let group_root = self.group_root(groups).map(resolve_root).transpose()?;
132
133        InstallableExt::to_resolution_from_packages(
134            self,
135            &roots,
136            group_root,
137            true,
138            DependencySelectionContext::None,
139            marker_env,
140            tags,
141            extras,
142            groups,
143            build_options,
144            install_options,
145        )
146    }
147
148    /// Create an installable [`Node`] from a [`Package`].
149    fn installable_node(
150        &self,
151        package: &Package,
152        tags: &Tags,
153        marker_env: &ResolverMarkerEnvironment,
154        build_options: &BuildOptions,
155    ) -> Result<Node, LockError> {
156        let tag_policy = TagPolicy::Required(tags);
157        let HashedDist { dist, hashes } =
158            package.to_dist(self.install_path(), tag_policy, build_options, marker_env)?;
159        let version = package.version().cloned();
160        let dist = ResolvedDist::Installable {
161            dist: Arc::new(dist),
162            version,
163        };
164        Ok(Node::Dist {
165            dist,
166            hashes,
167            install: true,
168        })
169    }
170
171    /// Create a non-installable [`Node`] from a [`Package`].
172    fn non_installable_node(
173        &self,
174        package: &Package,
175        tags: &Tags,
176        marker_env: &ResolverMarkerEnvironment,
177    ) -> Result<Node, LockError> {
178        let HashedDist { dist, .. } = package.to_dist(
179            self.install_path(),
180            TagPolicy::Preferred(tags),
181            &BuildOptions::default(),
182            marker_env,
183        )?;
184        let version = package.version().cloned();
185        let dist = ResolvedDist::Installable {
186            dist: Arc::new(dist),
187            version,
188        };
189        let hashes = package.hashes();
190        Ok(Node::Dist {
191            dist,
192            hashes,
193            install: false,
194        })
195    }
196
197    /// Convert a lockfile entry to a graph [`Node`].
198    fn package_to_node(
199        &self,
200        package: &Package,
201        tags: &Tags,
202        build_options: &BuildOptions,
203        install_options: &InstallOptions,
204        marker_env: &ResolverMarkerEnvironment,
205    ) -> Result<Node, LockError> {
206        if install_options.include_package(
207            package.as_install_target(),
208            self.project_name(),
209            self.lock().members(),
210        ) {
211            self.installable_node(package, tags, marker_env, build_options)
212        } else {
213            self.non_installable_node(package, tags, marker_env)
214        }
215    }
216}
217
218/// Internal lock-to-resolution implementation shared by [`Installable`] and [`Lock`].
219trait InstallableExt<'lock>: Installable<'lock> {
220    /// Convert concrete locked packages to a [`Resolution`].
221    ///
222    /// `include_manifest` controls whether requirements attached directly to the lock target are
223    /// included in addition to `roots`.
224    fn to_resolution_from_packages(
225        &self,
226        roots: &[&Package],
227        group_root: Option<&Package>,
228        include_manifest: bool,
229        selection_context: DependencySelectionContext<'lock>,
230        marker_env: &ResolverMarkerEnvironment,
231        tags: &Tags,
232        extras: &ExtrasSpecificationWithDefaults,
233        groups: &DependencyGroupsWithDefaults,
234        build_options: &BuildOptions,
235        install_options: &InstallOptions,
236    ) -> Result<Resolution, LockError> {
237        let size_guess = self.lock().packages.len();
238        let mut petgraph = Graph::with_capacity(size_guess, size_guess);
239        let mut inverse = FxHashMap::with_capacity_and_hasher(size_guess, FxBuildHasher);
240
241        let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
242        let mut seen = FxHashSet::default();
243        let mut conflict_reachability = FxHashMap::default();
244        let mut activated_projects: Vec<&PackageName> = vec![];
245        let mut activated_extras: Vec<(&PackageName, &ExtraName)> = vec![];
246        let mut activated_groups: Vec<(&PackageName, &GroupName)> = vec![];
247        let has_conflicts = !self.lock().conflicts().is_empty();
248        let validate_conflicts = !include_manifest && has_conflicts;
249        let mut dependencies_for_conflict_validation = vec![];
250
251        let root = petgraph.add_node(Node::Root);
252
253        match selection_context {
254            DependencySelectionContext::None => {}
255            DependencySelectionContext::Production(project) => {
256                activated_projects.push(project);
257            }
258            DependencySelectionContext::Group(project, group) => {
259                activated_groups.push((project, group));
260            }
261        }
262
263        // Determine the set of activated extras and groups, from the root.
264        //
265        // Extras activated by dependency groups (via `pkg[extra]` entries in the group) are
266        // accumulated below, when we process the groups themselves. This ensures that when we
267        // later evaluate conflict markers on transitive dependencies, self-extras enabled by an
268        // active group are treated as enabled.
269        //
270        // TODO(zanieb): For completeness, the group-dep loop below still has two structural
271        // soundness gaps. Neither is reachable through lockfiles the resolver currently
272        // produces — they'd require a group-dep entry with a strict positive conflict marker
273        // referencing an extra *other* than the entry's own self-extra, and the resolver
274        // either emits self-extra markers (handled by `newly_activated_extras` below) or
275        // markers with vacuously-true disjuncts. But the code should still handle them:
276        //
277        // 1. Ordering: an earlier group-dep entry whose marker references an extra activated
278        //    by a later entry is evaluated with an incomplete `activated_extras` set.
279        // 2. Transitive self-extras: a group dep `pkg[a]` where `pkg.optional_dependencies.a`
280        //    includes `pkg[b]` only activates `(pkg, b)` during the first-pass traversal
281        //    below, so any group dep whose marker needs `(pkg, b)` is evaluated too early.
282        //
283        // Fixing these correctly likely means iterating group-dep activation to a fixed point
284        // or interleaving it with the first-pass traversal.
285        if has_conflicts {
286            for dist in roots.iter().copied() {
287                // Track the activated extras.
288                if groups.prod() {
289                    activated_projects.push(&dist.id.name);
290                    for extra in extras.extra_names(dist.optional_dependencies.keys()) {
291                        activated_extras.push((&dist.id.name, extra));
292                    }
293                }
294            }
295
296            for dist in roots.iter().copied().chain(group_root) {
297                for group in dist
298                    .dependency_groups
299                    .keys()
300                    .filter(|group| self.includes_group(Some(&dist.id.name), group, groups))
301                {
302                    activated_groups.push((&dist.id.name, group));
303                }
304            }
305        }
306
307        // Initialize the workspace roots.
308        let mut initialized_roots = vec![];
309        for (dist, root_kind) in roots
310            .iter()
311            .copied()
312            .map(|dist| (dist, InstallableRootKind::Production))
313            .chain(group_root.map(|dist| (dist, InstallableRootKind::DependencyGroups)))
314        {
315            // Add the workspace package to the graph.
316            let index = petgraph.add_node(
317                if root_kind == InstallableRootKind::Production && groups.prod() {
318                    self.package_to_node(dist, tags, build_options, install_options, marker_env)?
319                } else {
320                    self.non_installable_node(dist, tags, marker_env)?
321                },
322            );
323            inverse.insert(&dist.id, index);
324
325            // Add an edge from the root.
326            petgraph.add_edge(root, index, Edge::Prod);
327
328            // Push the package onto the queue.
329            initialized_roots.push((dist, index, root_kind));
330        }
331
332        // Add the workspace dependencies to the queue.
333        for (dist, index, root_kind) in initialized_roots {
334            if root_kind == InstallableRootKind::Production && groups.prod() {
335                // Push its dependencies onto the queue.
336                queue.push_back((dist, None));
337                add_reachability(
338                    &mut conflict_reachability,
339                    (&dist.id, None),
340                    UniversalMarker::TRUE,
341                );
342                for extra in extras.extra_names(dist.optional_dependencies.keys()) {
343                    queue.push_back((dist, Some(extra)));
344                    add_reachability(
345                        &mut conflict_reachability,
346                        (&dist.id, Some(extra)),
347                        UniversalMarker::TRUE,
348                    );
349                }
350            }
351
352            // Add any dev dependencies.
353            for (group, dep) in dist
354                .dependency_groups
355                .iter()
356                .filter_map(|(group, deps)| {
357                    if self.includes_group(Some(&dist.id.name), group, groups) {
358                        Some(deps.iter().map(move |dep| (group, dep)))
359                    } else {
360                        None
361                    }
362                })
363                .flatten()
364            {
365                if validate_conflicts && dep.complexified_marker.has_conflict_marker() {
366                    dependencies_for_conflict_validation.push((dist, dep));
367                }
368                let additional_activated_extras = newly_activated_extras(dep, &activated_extras);
369                if !dep.complexified_marker.evaluate(
370                    marker_env,
371                    activated_projects.iter().copied(),
372                    activated_extras
373                        .iter()
374                        .chain(additional_activated_extras.iter())
375                        .copied(),
376                    activated_groups.iter().copied(),
377                ) {
378                    continue;
379                }
380
381                let dep_dist = self.lock().find_by_id(&dep.package_id);
382
383                // Add the package to the graph.
384                let dep_index = match inverse.entry(&dep.package_id) {
385                    Entry::Vacant(entry) => {
386                        let index = petgraph.add_node(self.package_to_node(
387                            dep_dist,
388                            tags,
389                            build_options,
390                            install_options,
391                            marker_env,
392                        )?);
393                        entry.insert(index);
394                        index
395                    }
396                    Entry::Occupied(entry) => {
397                        // Critically, if the package is already in the graph, then it's a workspace
398                        // member. If it was omitted due to, e.g., `--only-dev`, but is itself
399                        // referenced as a development dependency, then we need to re-enable it.
400                        let index = *entry.get();
401                        let node = &mut petgraph[index];
402                        if !groups.prod() || matches!(node, Node::Dist { install: false, .. }) {
403                            *node = self.package_to_node(
404                                dep_dist,
405                                tags,
406                                build_options,
407                                install_options,
408                                marker_env,
409                            )?;
410                        }
411                        index
412                    }
413                };
414
415                petgraph.add_edge(
416                    index,
417                    dep_index,
418                    // This is OK because we are resolving to a resolution for
419                    // a specific marker environment and set of extras/groups.
420                    // So at this point, we know the extras/groups have been
421                    // satisfied, so we can safely drop the conflict marker.
422                    Edge::Dev(group.clone()),
423                );
424
425                // Persist any self-extras activated by this group dependency (e.g., a group
426                // that references `pkg[extra]`). Without this, conflict markers on transitive
427                // dependencies gated by the activated extra would not evaluate to `true`
428                // during the graph traversals below.
429                for key in additional_activated_extras {
430                    activated_extras.push(key);
431                }
432
433                // Push its dependencies on the queue.
434                add_reachability(
435                    &mut conflict_reachability,
436                    (&dep.package_id, None),
437                    dep.complexified_marker,
438                );
439                if seen.insert((&dep.package_id, None)) {
440                    queue.push_back((dep_dist, None));
441                }
442                for extra in &dep.extra {
443                    add_reachability(
444                        &mut conflict_reachability,
445                        (&dep.package_id, Some(extra)),
446                        dep.complexified_marker,
447                    );
448                    if seen.insert((&dep.package_id, Some(extra))) {
449                        queue.push_back((dep_dist, Some(extra)));
450                    }
451                }
452            }
453        }
454
455        if include_manifest {
456            // Add any requirements that are exclusive to the workspace root (e.g., dependencies in
457            // PEP 723 scripts).
458            for dependency in self.lock().requirements() {
459                if !dependency.marker.evaluate(marker_env, &[]) {
460                    continue;
461                }
462
463                let root_name = &dependency.name;
464                let dist = self
465                    .lock()
466                    .find_by_markers(root_name, marker_env)
467                    .map_err(|_| LockErrorKind::MultipleRootPackages {
468                        name: root_name.clone(),
469                    })?
470                    .ok_or_else(|| LockErrorKind::MissingRootPackage {
471                        name: root_name.clone(),
472                    })?;
473
474                // Add the package to the graph.
475                let index = petgraph.add_node(if groups.prod() {
476                    self.package_to_node(dist, tags, build_options, install_options, marker_env)?
477                } else {
478                    self.non_installable_node(dist, tags, marker_env)?
479                });
480                inverse.insert(&dist.id, index);
481
482                // Add the edge.
483                petgraph.add_edge(root, index, Edge::Prod);
484
485                // Push its dependencies on the queue.
486                add_reachability(
487                    &mut conflict_reachability,
488                    (&dist.id, None),
489                    UniversalMarker::TRUE,
490                );
491                if seen.insert((&dist.id, None)) {
492                    queue.push_back((dist, None));
493                }
494                for extra in &dependency.extras {
495                    add_reachability(
496                        &mut conflict_reachability,
497                        (&dist.id, Some(extra)),
498                        UniversalMarker::TRUE,
499                    );
500                    if seen.insert((&dist.id, Some(extra))) {
501                        queue.push_back((dist, Some(extra)));
502                    }
503                }
504            }
505
506            // Add any dependency groups that are exclusive to the workspace root (e.g., dev
507            // dependencies in non-project workspace roots).
508            for (group, dependency) in self
509                .lock()
510                .dependency_groups()
511                .iter()
512                .filter_map(|(group, deps)| {
513                    if self.includes_group(None, group, groups) {
514                        Some(deps.iter().map(move |dep| (group, dep)))
515                    } else {
516                        None
517                    }
518                })
519                .flatten()
520            {
521                if !dependency.marker.evaluate(marker_env, &[]) {
522                    continue;
523                }
524
525                let root_name = &dependency.name;
526                let dist = self
527                    .lock()
528                    .find_by_markers(root_name, marker_env)
529                    .map_err(|_| LockErrorKind::MultipleRootPackages {
530                        name: root_name.clone(),
531                    })?
532                    .ok_or_else(|| LockErrorKind::MissingRootPackage {
533                        name: root_name.clone(),
534                    })?;
535
536                // Add the package to the graph.
537                let index = match inverse.entry(&dist.id) {
538                    Entry::Vacant(entry) => {
539                        let index = petgraph.add_node(self.package_to_node(
540                            dist,
541                            tags,
542                            build_options,
543                            install_options,
544                            marker_env,
545                        )?);
546                        entry.insert(index);
547                        index
548                    }
549                    Entry::Occupied(entry) => {
550                        // Critically, if the package is already in the graph, then it's a workspace
551                        // member. If it was omitted due to, e.g., `--only-dev`, but is itself
552                        // referenced as a development dependency, then we need to re-enable it.
553                        let index = *entry.get();
554                        let node = &mut petgraph[index];
555                        if !groups.prod() {
556                            *node = self.package_to_node(
557                                dist,
558                                tags,
559                                build_options,
560                                install_options,
561                                marker_env,
562                            )?;
563                        }
564                        index
565                    }
566                };
567
568                // Add the edge.
569                petgraph.add_edge(root, index, Edge::Dev(group.clone()));
570
571                // Persist any self-extras activated by this group dependency. Mirrors the
572                // handling in the package-level `dependency_groups` loop above; without this,
573                // conflict markers on transitive dependencies gated by the activated extra
574                // would not evaluate to `true` during the graph traversals below.
575                for extra in &dependency.extras {
576                    let key = (&dist.id.name, extra);
577                    if !activated_extras.contains(&key) {
578                        activated_extras.push(key);
579                    }
580                }
581
582                // Push its dependencies on the queue.
583                add_reachability(
584                    &mut conflict_reachability,
585                    (&dist.id, None),
586                    UniversalMarker::TRUE,
587                );
588                if seen.insert((&dist.id, None)) {
589                    queue.push_back((dist, None));
590                }
591                for extra in &dependency.extras {
592                    add_reachability(
593                        &mut conflict_reachability,
594                        (&dist.id, Some(extra)),
595                        UniversalMarker::TRUE,
596                    );
597                    if seen.insert((&dist.id, Some(extra))) {
598                        queue.push_back((dist, Some(extra)));
599                    }
600                }
601            }
602        }
603
604        // Below, we traverse the dependency graph in a breadth first manner
605        // twice. It's only in the second traversal that we actually build
606        // up our resolution graph. In the first traversal, we accumulate all
607        // activated extras. This includes the extras explicitly enabled on
608        // the CLI (which were gathered above) and the extras enabled via
609        // dependency specifications like `foo[extra]`. We need to do this
610        // to correctly support conflicting extras.
611        //
612        // In particular, the way conflicting extras works is by forking the
613        // resolver based on the extras that are declared as conflicting. But
614        // this forking needs to be made manifest somehow in the lock file to
615        // avoid multiple versions of the same package being installed into the
616        // environment. This is why "conflict markers" were invented. For
617        // example, you might have both `torch` and `torch+cpu` in your
618        // dependency graph, where the latter is only enabled when the `cpu`
619        // extra is enabled, and the former is specifically *not* enabled
620        // when the `cpu` extra is enabled.
621        //
622        // In order to evaluate these conflict markers correctly, we need to
623        // know whether the `cpu` extra is enabled when we visit the `torch`
624        // dependency. If we think it's disabled, then we'll erroneously
625        // include it if the extra is actually enabled. But in order to tell
626        // if it's enabled, we need to traverse the entire dependency graph
627        // first to inspect which extras are enabled!
628        //
629        // Of course, we don't need to do this at all if there aren't any
630        // conflicts. In which case, we skip all of this and just do the one
631        // traversal below.
632        if has_conflicts {
633            let mut activated_extras_set: BTreeSet<(&PackageName, &ExtraName)> =
634                activated_extras.iter().copied().collect();
635            let mut queue = queue.clone();
636            let mut reachability = conflict_reachability;
637            while let Some((package, extra)) = queue.pop_front() {
638                let Some(parent_reachability) = reachability.get(&(&package.id, extra)).copied()
639                else {
640                    continue;
641                };
642                let deps = if let Some(extra) = extra {
643                    Either::Left(
644                        package
645                            .optional_dependencies
646                            .get(extra)
647                            .into_iter()
648                            .flatten(),
649                    )
650                } else {
651                    Either::Right(package.dependencies.iter())
652                };
653                for dep in deps {
654                    let mut dep_reachability = dep.complexified_marker;
655                    dep_reachability.and(parent_reachability);
656                    let additional_activated_extras =
657                        newly_activated_extras(dep, &activated_extras);
658                    if !dep_reachability.evaluate(
659                        marker_env,
660                        activated_projects.iter().copied(),
661                        activated_extras
662                            .iter()
663                            .chain(additional_activated_extras.iter())
664                            .copied(),
665                        activated_groups.iter().copied(),
666                    ) {
667                        continue;
668                    }
669                    // The dependency can still be visited provisionally before all activated
670                    // extras are known. The second traversal below will exclude it once those
671                    // extras are available. Crucially, `dep_reachability` includes the conditions
672                    // required to reach the parent package: dependency markers may have been
673                    // simplified under those conditions and cannot stand alone during this
674                    // preliminary traversal. Otherwise, an unreachable package could activate an
675                    // extra and cause the conflict check below to report a false positive.
676
677                    for key in additional_activated_extras {
678                        activated_extras_set.insert(key);
679                        activated_extras.push(key);
680                    }
681                    let dep_dist = self.lock().find_by_id(&dep.package_id);
682                    // Push its dependencies on the queue.
683                    if add_reachability(
684                        &mut reachability,
685                        (&dep.package_id, None),
686                        dep_reachability,
687                    ) {
688                        queue.push_back((dep_dist, None));
689                    }
690                    for extra in &dep.extra {
691                        if add_reachability(
692                            &mut reachability,
693                            (&dep.package_id, Some(extra)),
694                            dep_reachability,
695                        ) {
696                            queue.push_back((dep_dist, Some(extra)));
697                        }
698                    }
699                }
700            }
701            // At time of writing, it's somewhat expected that the set of
702            // conflicting extras is pretty small. With that said, the
703            // time complexity of the following routine is pretty gross.
704            // Namely, `set.contains` is linear in the size of the set,
705            // iteration over all conflicts is also obviously linear in
706            // the number of conflicting sets and then for each of those,
707            // we visit every possible pair of activated extra from above,
708            // which is quadratic in the total number of extras enabled. I
709            // believe the simplest improvement here, if it's necessary, is
710            // to adjust the `Conflicts` internals to own these sorts of
711            // checks. ---AG
712            for set in self.lock().conflicts().iter() {
713                for ((pkg1, extra1), (pkg2, extra2)) in
714                    activated_extras_set.iter().tuple_combinations()
715                {
716                    if set.contains(pkg1, *extra1) && set.contains(pkg2, *extra2) {
717                        return Err(LockErrorKind::ConflictingExtra {
718                            package1: (*pkg1).clone(),
719                            extra1: (*extra1).clone(),
720                            package2: (*pkg2).clone(),
721                            extra2: (*extra2).clone(),
722                        }
723                        .into());
724                    }
725                }
726            }
727        }
728
729        while let Some((package, extra)) = queue.pop_front() {
730            let deps = if let Some(extra) = extra {
731                Either::Left(
732                    package
733                        .optional_dependencies
734                        .get(extra)
735                        .into_iter()
736                        .flatten(),
737                )
738            } else {
739                Either::Right(package.dependencies.iter())
740            };
741            for dep in deps {
742                if validate_conflicts && dep.complexified_marker.has_conflict_marker() {
743                    dependencies_for_conflict_validation.push((package, dep));
744                }
745                if !dep.complexified_marker.evaluate(
746                    marker_env,
747                    activated_projects.iter().copied(),
748                    activated_extras.iter().copied(),
749                    activated_groups.iter().copied(),
750                ) {
751                    continue;
752                }
753
754                let dep_dist = self.lock().find_by_id(&dep.package_id);
755
756                // Add the dependency to the graph.
757                let dep_index = match inverse.entry(&dep.package_id) {
758                    Entry::Vacant(entry) => {
759                        let index = petgraph.add_node(self.package_to_node(
760                            dep_dist,
761                            tags,
762                            build_options,
763                            install_options,
764                            marker_env,
765                        )?);
766                        entry.insert(index);
767                        index
768                    }
769                    Entry::Occupied(entry) => {
770                        let index = *entry.get();
771                        if matches!(&petgraph[index], Node::Dist { install: false, .. }) {
772                            petgraph[index] = self.package_to_node(
773                                dep_dist,
774                                tags,
775                                build_options,
776                                install_options,
777                                marker_env,
778                            )?;
779                        }
780                        index
781                    }
782                };
783
784                // Add the edge.
785                let index = inverse[&package.id];
786                petgraph.add_edge(
787                    index,
788                    dep_index,
789                    if let Some(extra) = extra {
790                        Edge::Optional(extra.clone())
791                    } else {
792                        Edge::Prod
793                    },
794                );
795
796                // Push its dependencies on the queue.
797                if seen.insert((&dep.package_id, None)) {
798                    queue.push_back((dep_dist, None));
799                }
800                for extra in &dep.extra {
801                    if seen.insert((&dep.package_id, Some(extra))) {
802                        queue.push_back((dep_dist, Some(extra)));
803                    }
804                }
805            }
806        }
807
808        // Evaluate conflict markers from concrete roots, not from workspace members that depend on
809        // them. Reject markers that still depend on conflict items outside the resulting subgraph.
810        if !dependencies_for_conflict_validation.is_empty() {
811            let subgraph_packages = inverse
812                .keys()
813                .map(|package_id| &package_id.name)
814                .collect::<FxHashSet<_>>();
815            let selection_context_package = selection_context.package();
816
817            // The environment and conflict state are shared by every dependency, so repeated
818            // markers have the same result.
819            let mut validated_markers = FxHashSet::default();
820            for (package, dependency) in dependencies_for_conflict_validation {
821                if !validated_markers.insert(dependency.complexified_marker) {
822                    continue;
823                }
824                let mut marker = dependency.complexified_marker;
825                for item in self.lock().conflicts().iter().flat_map(ConflictSet::iter) {
826                    if selection_context_package != Some(item.package())
827                        && !subgraph_packages.contains(item.package())
828                    {
829                        continue;
830                    }
831
832                    let active = match item.kind() {
833                        ConflictKind::Project => activated_projects.contains(&item.package()),
834                        ConflictKind::Extra(extra) => {
835                            activated_extras.contains(&(item.package(), extra))
836                        }
837                        ConflictKind::Group(group) => {
838                            activated_groups.contains(&(item.package(), group))
839                        }
840                    };
841                    if active {
842                        marker.assume_conflict_item(item);
843                    } else {
844                        marker.assume_not_conflict_item(item);
845                    }
846                }
847
848                let conflict = marker.conflict_for_environment(marker_env);
849                // All in-subgraph conflict items were resolved above, so a non-constant marker
850                // still depends on a package outside the subgraph.
851                if !conflict.is_constant() {
852                    return Err(LockErrorKind::DependencyConflictOutsideSubgraph {
853                        package: package.id.clone(),
854                        dependency: dependency.package_id.clone(),
855                    }
856                    .into());
857                }
858            }
859        }
860
861        Ok(Resolution::new(petgraph))
862    }
863}
864
865impl<'lock, T> InstallableExt<'lock> for T where T: Installable<'lock> + ?Sized {}
866
867/// An [`Installable`] adapter for materializing concrete packages directly from a [`Lock`].
868struct LockedPackages<'lock> {
869    lock: &'lock Lock,
870    install_path: &'lock Path,
871    project_name: Option<&'lock PackageName>,
872}
873
874impl<'lock> Installable<'lock> for LockedPackages<'lock> {
875    fn install_path(&self) -> &'lock Path {
876        self.install_path
877    }
878
879    fn lock(&self) -> &'lock Lock {
880        self.lock
881    }
882
883    fn roots(&self) -> impl Iterator<Item = &PackageName> {
884        std::iter::empty()
885    }
886
887    fn project_name(&self) -> Option<&PackageName> {
888        self.project_name
889    }
890}
891
892impl Lock {
893    /// Materialize a direct dependency selection from this lock.
894    ///
895    /// Like [`Self::to_resolution`], this materializes the selected dependency's subgraph. It also
896    /// preserves the extras activated by the direct edge and the project production or group
897    /// context used to select a conflict fork.
898    pub fn to_resolution_from_dependency<'lock>(
899        &'lock self,
900        install_path: &'lock Path,
901        dependency: &SelectedDependency<'lock>,
902        project_name: Option<&'lock PackageName>,
903        marker_env: &ResolverMarkerEnvironment,
904        tags: &Tags,
905        build_options: &BuildOptions,
906        install_options: &InstallOptions,
907    ) -> Result<Resolution, LockError> {
908        let selected_package = dependency.package();
909        let Some(index) = self.by_id.get(&selected_package.id) else {
910            return Err(LockErrorKind::RootPackageMissingFromLock {
911                id: selected_package.id.clone(),
912            }
913            .into());
914        };
915        let Some(package) = self.packages.get(*index) else {
916            return Err(LockErrorKind::RootPackageMissingFromLock {
917                id: selected_package.id.clone(),
918            }
919            .into());
920        };
921        let extras = ExtrasSpecification::from_extra(dependency.extras().cloned().collect())
922            .with_defaults(DefaultExtras::default());
923        let groups = DependencyGroupsWithDefaults::none();
924
925        LockedPackages {
926            lock: self,
927            install_path,
928            project_name,
929        }
930        .to_resolution_from_packages(
931            &[package],
932            None,
933            false,
934            dependency.context(),
935            marker_env,
936            tags,
937            &extras,
938            &groups,
939            build_options,
940            install_options,
941        )
942    }
943
944    /// Materialize the exact dependency subgraph reachable from concrete locked `roots`.
945    ///
946    /// Each root must be a [`Package`] from this lock. Unlike [`Installable::to_resolution`], this
947    /// method does not include requirements or dependency groups attached directly to the lock
948    /// manifest. Extras and dependency groups on the concrete roots are still included according
949    /// to `extras` and `groups`.
950    ///
951    /// Conflict-marker evaluation starts from `roots` and their requested `extras` and `groups`,
952    /// not from workspace members that depend on those roots. The method returns an error if a
953    /// dependency marker still depends on a conflict item outside the resulting subgraph. Use
954    /// [`Installable::to_resolution`] when materializing an existing lock target.
955    ///
956    /// `project_name` identifies the project for project-specific [`InstallOptions`] filters, if
957    /// applicable. Callers are responsible for selecting roots that apply to `marker_env`.
958    pub fn to_resolution<'lock>(
959        &'lock self,
960        install_path: &'lock Path,
961        roots: impl IntoIterator<Item = &'lock Package>,
962        project_name: Option<&'lock PackageName>,
963        marker_env: &ResolverMarkerEnvironment,
964        tags: &Tags,
965        extras: &ExtrasSpecificationWithDefaults,
966        groups: &DependencyGroupsWithDefaults,
967        build_options: &BuildOptions,
968        install_options: &InstallOptions,
969    ) -> Result<Resolution, LockError> {
970        let mut seen = FxHashSet::default();
971        let mut concrete_roots = Vec::new();
972        for root in roots {
973            let Some(index) = self.by_id.get(&root.id) else {
974                return Err(LockErrorKind::RootPackageMissingFromLock {
975                    id: root.id.clone(),
976                }
977                .into());
978            };
979            if seen.insert(&root.id) {
980                let Some(root) = self.packages.get(*index) else {
981                    return Err(LockErrorKind::RootPackageMissingFromLock {
982                        id: root.id.clone(),
983                    }
984                    .into());
985                };
986                concrete_roots.push(root);
987            }
988        }
989
990        LockedPackages {
991            lock: self,
992            install_path,
993            project_name,
994        }
995        .to_resolution_from_packages(
996            &concrete_roots,
997            None,
998            false,
999            DependencySelectionContext::None,
1000            marker_env,
1001            tags,
1002            extras,
1003            groups,
1004            build_options,
1005            install_options,
1006        )
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use std::cell::Cell;
1013    use std::str::FromStr;
1014    use std::sync::LazyLock;
1015
1016    use petgraph::visit::EdgeRef;
1017    use uv_configuration::{DependencyGroups, ExtrasSpecification};
1018    use uv_distribution_types::Name;
1019    use uv_normalize::{DefaultExtras, DefaultGroups};
1020    use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder};
1021    use uv_platform_tags::{Arch, Os, Platform, TagsOptions};
1022    use uv_warnings::anstream;
1023
1024    use super::*;
1025
1026    static TAGS: LazyLock<Tags> = LazyLock::new(|| {
1027        Tags::from_env(
1028            Platform::new(
1029                Os::Macos {
1030                    major: 14,
1031                    minor: 0,
1032                },
1033                Arch::Aarch64,
1034            ),
1035            (3, 11),
1036            "cpython",
1037            (3, 11),
1038            TagsOptions::default(),
1039        )
1040        .expect("valid tags")
1041    });
1042
1043    static DARWIN_MARKERS: LazyLock<ResolverMarkerEnvironment> =
1044        LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("darwin", "Darwin")));
1045
1046    static LINUX_MARKERS: LazyLock<ResolverMarkerEnvironment> =
1047        LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("linux", "Linux")));
1048
1049    fn marker_environment(
1050        sys_platform: &'static str,
1051        platform_system: &'static str,
1052    ) -> MarkerEnvironment {
1053        MarkerEnvironment::try_from(MarkerEnvironmentBuilder {
1054            implementation_name: "cpython",
1055            implementation_version: "3.11.5",
1056            os_name: "posix",
1057            platform_machine: "arm64",
1058            platform_python_implementation: "CPython",
1059            platform_release: "23.0.0",
1060            platform_system,
1061            platform_version: "test",
1062            python_full_version: "3.11.5",
1063            python_version: "3.11",
1064            sys_platform,
1065        })
1066        .expect("valid marker environment")
1067    }
1068
1069    fn lock() -> Lock {
1070        toml::from_str(
1071            r#"
1072version = 1
1073revision = 3
1074requires-python = ">=3.11"
1075resolution-markers = [
1076    "sys_platform == 'darwin'",
1077    "sys_platform != 'darwin'",
1078]
1079
1080[manifest]
1081requirements = [{ name = "unrelated" }]
1082
1083[[package]]
1084name = "dev-dependency"
1085version = "1.0.0"
1086source = { registry = "https://example.com/simple" }
1087sdist = { url = "https://example.com/dev_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1088
1089[[package]]
1090name = "forked"
1091version = "1.0.0"
1092source = { registry = "https://example.com/simple" }
1093resolution-markers = ["sys_platform == 'darwin'"]
1094sdist = { url = "https://example.com/forked-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1095
1096[[package]]
1097name = "forked"
1098version = "2.0.0"
1099source = { registry = "https://example.com/simple" }
1100resolution-markers = ["sys_platform != 'darwin'"]
1101sdist = { url = "https://example.com/forked-2.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
1102
1103[[package]]
1104name = "optional-dependency"
1105version = "1.0.0"
1106source = { registry = "https://example.com/simple" }
1107sdist = { url = "https://example.com/optional_dependency-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
1108
1109[[package]]
1110name = "root-a"
1111version = "1.0.0"
1112source = { registry = "https://example.com/simple" }
1113dependencies = [
1114    { name = "forked", version = "1.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform == 'darwin'" },
1115    { name = "forked", version = "2.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform != 'darwin'" },
1116    { name = "shared" },
1117]
1118sdist = { url = "https://example.com/root_a-1.0.0.tar.gz", hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" }
1119
1120[package.optional-dependencies]
1121feature = [{ name = "optional-dependency" }]
1122
1123[package.dependency-groups]
1124dev = [{ name = "dev-dependency" }]
1125
1126[package.metadata]
1127provides-extras = ["feature"]
1128
1129[[package]]
1130name = "root-b"
1131version = "1.0.0"
1132source = { registry = "https://example.com/simple" }
1133dependencies = [{ name = "shared" }]
1134sdist = { url = "https://example.com/root_b-1.0.0.tar.gz", hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" }
1135
1136[[package]]
1137name = "shared"
1138version = "1.0.0"
1139source = { registry = "https://example.com/simple" }
1140sdist = { url = "https://example.com/shared-1.0.0.tar.gz", hash = "sha256:7777777777777777777777777777777777777777777777777777777777777777" }
1141
1142[[package]]
1143name = "unrelated"
1144version = "1.0.0"
1145source = { registry = "https://example.com/simple" }
1146sdist = { url = "https://example.com/unrelated-1.0.0.tar.gz", hash = "sha256:8888888888888888888888888888888888888888888888888888888888888888" }
1147"#,
1148        )
1149        .expect("valid lock")
1150    }
1151
1152    fn conflict_lock() -> Lock {
1153        toml::from_str(
1154            r#"
1155version = 1
1156revision = 3
1157requires-python = ">=3.11"
1158conflicts = [
1159    [
1160        { package = "tool", extra = "cpu" },
1161        { package = "tool", extra = "gpu" },
1162    ],
1163    [
1164        { package = "project", extra = "foo" },
1165        { package = "project", extra = "bar" },
1166    ],
1167]
1168
1169[[package]]
1170name = "contextual-dependency"
1171version = "1.0.0"
1172source = { registry = "https://example.com/simple" }
1173sdist = { url = "https://example.com/contextual_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1174
1175[[package]]
1176name = "contextual-tool"
1177version = "1.0.0"
1178source = { registry = "https://example.com/simple" }
1179dependencies = [
1180    { name = "contextual-dependency", marker = "sys_platform == 'linux' or (sys_platform == 'darwin' and extra == 'extra-7-project-foo')" },
1181]
1182sdist = { url = "https://example.com/contextual_tool-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1183
1184[[package]]
1185name = "cpu-backend"
1186version = "1.0.0"
1187source = { registry = "https://example.com/simple" }
1188sdist = { url = "https://example.com/cpu_backend-1.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
1189
1190[[package]]
1191name = "gpu-backend"
1192version = "1.0.0"
1193source = { registry = "https://example.com/simple" }
1194sdist = { url = "https://example.com/gpu_backend-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
1195
1196[[package]]
1197name = "project"
1198version = "1.0.0"
1199source = { registry = "https://example.com/simple" }
1200sdist = { url = "https://example.com/project-1.0.0.tar.gz", hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" }
1201
1202[package.optional-dependencies]
1203foo = []
1204bar = []
1205
1206[package.metadata]
1207provides-extras = ["foo", "bar"]
1208
1209[[package]]
1210name = "runtime"
1211version = "1.0.0"
1212source = { registry = "https://example.com/simple" }
1213dependencies = [
1214    { name = "cpu-backend", marker = "extra == 'extra-4-tool-cpu'" },
1215    { name = "gpu-backend", marker = "extra == 'extra-4-tool-gpu'" },
1216]
1217sdist = { url = "https://example.com/runtime-1.0.0.tar.gz", hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" }
1218
1219[[package]]
1220name = "tool"
1221version = "1.0.0"
1222source = { registry = "https://example.com/simple" }
1223dependencies = [{ name = "runtime" }]
1224sdist = { url = "https://example.com/tool-1.0.0.tar.gz", hash = "sha256:7777777777777777777777777777777777777777777777777777777777777777" }
1225
1226[package.optional-dependencies]
1227cpu = []
1228gpu = []
1229
1230[package.metadata]
1231provides-extras = ["cpu", "gpu"]
1232"#,
1233        )
1234        .expect("valid lock")
1235    }
1236
1237    fn dependency_selection_lock() -> Lock {
1238        toml::from_str(
1239            r#"
1240version = 1
1241revision = 3
1242requires-python = ">=3.11"
1243conflicts = [[
1244    { package = "project", group = "dev" },
1245    { package = "project", group = "other" },
1246]]
1247
1248[[package]]
1249name = "contextual-dev-dependency"
1250version = "1.0.0"
1251source = { registry = "https://example.com/simple" }
1252sdist = { url = "https://example.com/contextual_dev_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1253
1254[[package]]
1255name = "contextual-other-dependency"
1256version = "1.0.0"
1257source = { registry = "https://example.com/simple" }
1258sdist = { url = "https://example.com/contextual_other_dependency-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1259
1260[[package]]
1261name = "optional-dependency"
1262version = "1.0.0"
1263source = { registry = "https://example.com/simple" }
1264sdist = { url = "https://example.com/optional_dependency-1.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
1265
1266[[package]]
1267name = "project"
1268version = "1.0.0"
1269source = { virtual = "." }
1270
1271[package.dependency-groups]
1272dev = [{ name = "tool", extra = ["cli"] }]
1273other = [{ name = "tool" }]
1274
1275[[package]]
1276name = "tool"
1277version = "1.0.0"
1278source = { registry = "https://example.com/simple" }
1279dependencies = [
1280    { name = "contextual-dev-dependency", marker = "extra == 'group-7-project-dev'" },
1281    { name = "contextual-other-dependency", marker = "extra == 'group-7-project-other'" },
1282]
1283sdist = { url = "https://example.com/tool-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
1284
1285[package.optional-dependencies]
1286cli = [{ name = "optional-dependency" }]
1287
1288[package.metadata]
1289provides-extras = ["cli"]
1290"#,
1291        )
1292        .expect("valid lock")
1293    }
1294
1295    fn package<'lock>(lock: &'lock Lock, name: &str, version: &str) -> &'lock Package {
1296        lock.packages()
1297            .iter()
1298            .find(|package| {
1299                package.name().as_ref() == name
1300                    && package
1301                        .version()
1302                        .is_some_and(|package_version| package_version.to_string() == version)
1303            })
1304            .expect("locked package")
1305    }
1306
1307    fn materialize(
1308        lock: &Lock,
1309        roots: &[&Package],
1310        marker_env: &ResolverMarkerEnvironment,
1311    ) -> Resolution {
1312        let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
1313        let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
1314        lock.to_resolution(
1315            Path::new("."),
1316            roots.iter().copied(),
1317            None,
1318            marker_env,
1319            &TAGS,
1320            &extras,
1321            &groups,
1322            &BuildOptions::default(),
1323            &InstallOptions::default(),
1324        )
1325        .expect("valid resolution")
1326    }
1327
1328    fn materialize_with_extras(
1329        lock: &Lock,
1330        roots: &[&Package],
1331        marker_env: &ResolverMarkerEnvironment,
1332        extras: &ExtrasSpecification,
1333    ) -> Result<Resolution, LockError> {
1334        let extras = extras.with_defaults(DefaultExtras::default());
1335        let groups = DependencyGroupsWithDefaults::none();
1336        lock.to_resolution(
1337            Path::new("."),
1338            roots.iter().copied(),
1339            None,
1340            marker_env,
1341            &TAGS,
1342            &extras,
1343            &groups,
1344            &BuildOptions::default(),
1345            &InstallOptions::default(),
1346        )
1347    }
1348
1349    fn materialize_selected_dependency(lock: &Lock, group: &str) -> Resolution {
1350        let project_name = PackageName::from_str("project").expect("valid package name");
1351        let dependency_name = PackageName::from_str("tool").expect("valid package name");
1352        let group = GroupName::from_str(group).expect("valid group name");
1353        let selection = lock
1354            .dependency_selection(
1355                Some(&project_name),
1356                &dependency_name,
1357                DARWIN_MARKERS.markers(),
1358            )
1359            .expect("unique dependency selection");
1360        let dependency = selection.group(&group).expect("group dependency");
1361
1362        lock.to_resolution_from_dependency(
1363            Path::new("."),
1364            dependency,
1365            Some(&project_name),
1366            &DARWIN_MARKERS,
1367            &TAGS,
1368            &BuildOptions::default(),
1369            &InstallOptions::default(),
1370        )
1371        .expect("valid resolution")
1372    }
1373
1374    struct OverridingInstallable<'lock> {
1375        lock: &'lock Lock,
1376        root_name: &'lock PackageName,
1377        package_to_node_calls: Cell<usize>,
1378    }
1379
1380    impl<'lock> Installable<'lock> for OverridingInstallable<'lock> {
1381        fn install_path(&self) -> &'lock Path {
1382            Path::new(".")
1383        }
1384
1385        fn lock(&self) -> &'lock Lock {
1386            self.lock
1387        }
1388
1389        fn roots(&self) -> impl Iterator<Item = &PackageName> {
1390            std::iter::once(self.root_name)
1391        }
1392
1393        fn project_name(&self) -> Option<&PackageName> {
1394            None
1395        }
1396
1397        fn package_to_node(
1398            &self,
1399            _package: &Package,
1400            _tags: &Tags,
1401            _build_options: &BuildOptions,
1402            _install_options: &InstallOptions,
1403            _marker_env: &ResolverMarkerEnvironment,
1404        ) -> Result<Node, LockError> {
1405            self.package_to_node_calls
1406                .set(self.package_to_node_calls.get() + 1);
1407            Ok(Node::Root)
1408        }
1409    }
1410
1411    fn graph_snapshot(resolution: &Resolution) -> (Vec<String>, Vec<String>) {
1412        let graph = resolution.graph();
1413        let labels = graph
1414            .node_weights()
1415            .map(|node| match node {
1416                Node::Root => "root".to_string(),
1417                Node::Dist {
1418                    dist,
1419                    hashes,
1420                    install,
1421                } => format!(
1422                    "{}=={} (install: {install}, hashes: {})",
1423                    dist.name(),
1424                    dist.version()
1425                        .map(ToString::to_string)
1426                        .unwrap_or_else(|| "<dynamic>".to_string()),
1427                    hashes.iter().map(ToString::to_string).join(", ")
1428                ),
1429            })
1430            .collect::<Vec<_>>();
1431        let mut nodes = labels.clone();
1432        nodes.sort_unstable();
1433        let mut edges = graph
1434            .edge_references()
1435            .map(|edge| {
1436                format!(
1437                    "{} --{:?}--> {}",
1438                    labels[edge.source().index()],
1439                    edge.weight(),
1440                    labels[edge.target().index()]
1441                )
1442            })
1443            .collect::<Vec<_>>();
1444        edges.sort_unstable();
1445        (nodes, edges)
1446    }
1447
1448    #[test]
1449    fn materializes_multiple_concrete_roots_with_shared_dependencies() {
1450        let lock = lock();
1451        let resolution = materialize(
1452            &lock,
1453            &[
1454                package(&lock, "root-a", "1.0.0"),
1455                package(&lock, "root-b", "1.0.0"),
1456            ],
1457            &DARWIN_MARKERS,
1458        );
1459
1460        insta::with_settings!({
1461            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1462        }, {
1463            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1464        (
1465            [
1466                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1467                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1468                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1469                "root",
1470                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1471                "root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
1472                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1473            ],
1474            [
1475                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1476                "root --Prod--> root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
1477                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1478                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1479                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1480                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1481                "root-b==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1482            ],
1483        )
1484            "#);
1485        });
1486    }
1487
1488    #[test]
1489    fn materializes_group_root_referenced_by_production_dependency() {
1490        let lock = lock();
1491        let project = package(&lock, "root-a", "1.0.0");
1492        let group_root = package(&lock, "shared", "1.0.0");
1493        let extras = ExtrasSpecification::default().with_defaults(DefaultExtras::default());
1494        let groups = DependencyGroupsWithDefaults::none();
1495
1496        let resolution = LockedPackages {
1497            lock: &lock,
1498            install_path: Path::new("."),
1499            project_name: Some(project.name()),
1500        }
1501        .to_resolution_from_packages(
1502            &[project],
1503            Some(group_root),
1504            false,
1505            DependencySelectionContext::None,
1506            &DARWIN_MARKERS,
1507            &TAGS,
1508            &extras,
1509            &groups,
1510            &BuildOptions::default(),
1511            &InstallOptions::default(),
1512        )
1513        .expect("valid resolution");
1514
1515        assert!(
1516            resolution
1517                .distributions()
1518                .any(|distribution| distribution.name() == group_root.name())
1519        );
1520    }
1521
1522    #[test]
1523    fn materializes_the_selected_universal_lock_fork() {
1524        let lock = lock();
1525        let root = package(&lock, "root-a", "1.0.0");
1526        let darwin = materialize(&lock, &[root], &DARWIN_MARKERS);
1527        let linux = materialize(&lock, &[root], &LINUX_MARKERS);
1528        let concrete_fork =
1529            materialize(&lock, &[package(&lock, "forked", "1.0.0")], &DARWIN_MARKERS);
1530
1531        insta::with_settings!({
1532            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1533        }, {
1534            insta::assert_debug_snapshot!(graph_snapshot(&darwin), @r#"
1535        (
1536            [
1537                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1538                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1539                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1540                "root",
1541                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1542                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1543            ],
1544            [
1545                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1546                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1547                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1548                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1549                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1550            ],
1551        )
1552        "#);
1553            insta::assert_debug_snapshot!(graph_snapshot(&linux), @r#"
1554        (
1555            [
1556                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1557                "forked==2.0.0 (install: true, hashes: sha256:[HASH])",
1558                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1559                "root",
1560                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1561                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1562            ],
1563            [
1564                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1565                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1566                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1567                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==2.0.0 (install: true, hashes: sha256:[HASH])",
1568                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1569            ],
1570        )
1571        "#);
1572            insta::assert_debug_snapshot!(graph_snapshot(&concrete_fork), @r#"
1573        (
1574            [
1575                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1576                "root",
1577            ],
1578            [
1579                "root --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1580            ],
1581        )
1582            "#);
1583        });
1584    }
1585
1586    #[test]
1587    fn materializes_conflicting_extras_within_the_synthetic_root() {
1588        let lock = conflict_lock();
1589        let extras =
1590            ExtrasSpecification::from_extra(vec!["cpu".parse().expect("valid extra name")]);
1591        let resolution = materialize_with_extras(
1592            &lock,
1593            &[package(&lock, "tool", "1.0.0")],
1594            &DARWIN_MARKERS,
1595            &extras,
1596        )
1597        .expect("conflict markers are resolved within the subgraph");
1598
1599        insta::with_settings!({
1600            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1601        }, {
1602            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1603        (
1604            [
1605                "cpu-backend==1.0.0 (install: true, hashes: sha256:[HASH])",
1606                "root",
1607                "runtime==1.0.0 (install: true, hashes: sha256:[HASH])",
1608                "tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1609            ],
1610            [
1611                "root --Prod--> tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1612                "runtime==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> cpu-backend==1.0.0 (install: true, hashes: sha256:[HASH])",
1613                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> runtime==1.0.0 (install: true, hashes: sha256:[HASH])",
1614            ],
1615        )
1616        "#);
1617        });
1618    }
1619
1620    #[test]
1621    fn materializes_selected_dependency_extras() {
1622        let resolution = materialize_selected_dependency(&dependency_selection_lock(), "dev");
1623
1624        insta::with_settings!({
1625            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1626        }, {
1627            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1628        (
1629            [
1630                "contextual-dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1631                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1632                "root",
1633                "tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1634            ],
1635            [
1636                "root --Prod--> tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1637                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"cli\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1638                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> contextual-dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1639            ],
1640        )
1641        "#);
1642        });
1643    }
1644
1645    #[test]
1646    fn materializes_selected_dependency_project_conflict_context() {
1647        let resolution = materialize_selected_dependency(&dependency_selection_lock(), "other");
1648
1649        insta::with_settings!({
1650            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1651        }, {
1652            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1653        (
1654            [
1655                "contextual-other-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1656                "root",
1657                "tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1658            ],
1659            [
1660                "root --Prod--> tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1661                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> contextual-other-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1662            ],
1663        )
1664        "#);
1665        });
1666    }
1667
1668    #[test]
1669    fn rejects_conflicts_outside_the_synthetic_root() {
1670        let lock = conflict_lock();
1671        let root = package(&lock, "contextual-tool", "1.0.0");
1672        let extras = ExtrasSpecification::default();
1673
1674        let error = materialize_with_extras(&lock, &[root], &DARWIN_MARKERS, &extras)
1675            .expect_err("Darwin dependency depends on the project extra");
1676        let error = error.to_string();
1677        let error = anstream::adapter::strip_str(&error);
1678        insta::assert_snapshot!(error, @"Cannot materialize dependency `contextual-dependency==1.0.0 @ registry+https://example.com/simple` of `contextual-tool==1.0.0 @ registry+https://example.com/simple` because its conflict marker depends on a package outside the selected subgraph");
1679
1680        let linux = materialize_with_extras(&lock, &[root], &LINUX_MARKERS, &extras)
1681            .expect("the dependency is unconditional on Linux");
1682        insta::with_settings!({
1683            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1684        }, {
1685            insta::assert_debug_snapshot!(graph_snapshot(&linux), @r#"
1686        (
1687            [
1688                "contextual-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1689                "contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1690                "root",
1691            ],
1692            [
1693                "contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> contextual-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1694                "root --Prod--> contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1695            ],
1696        )
1697        "#);
1698        });
1699    }
1700
1701    #[test]
1702    fn installable_to_resolution_preserves_node_overrides() {
1703        let mut lock = lock();
1704        lock.manifest.requirements.clear();
1705        let target = OverridingInstallable {
1706            root_name: package(&lock, "root-a", "1.0.0").name(),
1707            lock: &lock,
1708            package_to_node_calls: Cell::new(0),
1709        };
1710        let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
1711        let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
1712
1713        target
1714            .to_resolution(
1715                &DARWIN_MARKERS,
1716                &TAGS,
1717                &extras,
1718                &groups,
1719                &BuildOptions::default(),
1720                &InstallOptions::default(),
1721            )
1722            .expect("valid resolution");
1723
1724        assert!(target.package_to_node_calls.get() > 0);
1725    }
1726}