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