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