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