Skip to main content

hakari/
hakari.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    CargoTomlError, HakariCargoToml, TomlOutError,
6    explain::HakariExplain,
7    registry::Registry,
8    toml_name_map,
9    toml_out::{HakariOutputOptions, write_toml},
10};
11use ahash::AHashMap;
12use debug_ignore::DebugIgnore;
13use guppy::{
14    PackageId,
15    errors::TargetSpecError,
16    graph::{
17        DependencyDirection, PackageGraph, PackageMetadata,
18        cargo::{BuildPlatform, CargoOptions, CargoResolverVersion, CargoSet, InitialsPlatform},
19        feature::{FeatureId, FeatureLabel, FeatureSet, StandardFeatures, named_feature_filter},
20    },
21    platform::{Platform, PlatformSpec, TargetFeatures},
22};
23use iddqd::BiHashMap;
24use rayon::prelude::*;
25use std::{
26    borrow::Cow,
27    collections::{BTreeMap, BTreeSet, HashSet},
28    fmt,
29    sync::Arc,
30};
31
32/// Configures and constructs [`Hakari`](Hakari) instances.
33///
34/// This struct provides a number of options that determine how `Hakari` instances are generated.
35#[derive(Clone, Debug)]
36pub struct HakariBuilder<'g> {
37    graph: DebugIgnore<&'g PackageGraph>,
38    hakari_package: Option<PackageMetadata<'g>>,
39    pub(crate) platforms: Vec<Arc<Platform>>,
40    resolver: CargoResolverVersion,
41    pub(crate) verify_mode: bool,
42    pub(crate) traversal_excludes: HashSet<&'g PackageId>,
43    final_excludes: HashSet<&'g PackageId>,
44    pub(crate) registries: BiHashMap<Registry, ahash::RandomState>,
45    unify_target_host: UnifyTargetHost,
46    output_single_feature: bool,
47    pub(crate) dep_format_version: DepFormatVersion,
48    pub(crate) workspace_hack_line_style: WorkspaceHackLineStyle,
49}
50
51impl<'g> HakariBuilder<'g> {
52    /// Creates a new `HakariBuilder` instance from a `PackageGraph`.
53    ///
54    /// The Hakari package itself is usually present in the workspace. If so, specify its
55    /// package ID, otherwise pass in `None`.
56    ///
57    /// Returns an error if a Hakari package ID is specified but it isn't known to the graph, or
58    /// isn't in the workspace.
59    pub fn new(
60        graph: &'g PackageGraph,
61        hakari_id: Option<&PackageId>,
62    ) -> Result<Self, guppy::Error> {
63        let hakari_package = hakari_id
64            .map(|package_id| {
65                let package = graph.metadata(package_id)?;
66                if !package.in_workspace() {
67                    return Err(guppy::Error::UnknownWorkspaceName(
68                        package.name().to_string(),
69                    ));
70                }
71                Ok(package)
72            })
73            .transpose()?;
74
75        Ok(Self {
76            graph: DebugIgnore(graph),
77            hakari_package,
78            platforms: vec![],
79            resolver: CargoResolverVersion::V2,
80            verify_mode: false,
81            traversal_excludes: HashSet::new(),
82            final_excludes: HashSet::new(),
83            registries: BiHashMap::default(),
84            unify_target_host: UnifyTargetHost::default(),
85            output_single_feature: false,
86            dep_format_version: DepFormatVersion::default(),
87            workspace_hack_line_style: WorkspaceHackLineStyle::default(),
88        })
89    }
90
91    /// Returns the `PackageGraph` used to construct this `Hakari` instance.
92    pub fn graph(&self) -> &'g PackageGraph {
93        // This is a spurious clippy lint on Rust 1.65.0
94        #[allow(clippy::explicit_auto_deref)]
95        *self.graph
96    }
97
98    /// Returns the Hakari package, or `None` if it wasn't passed into [`new`](Self::new).
99    pub fn hakari_package(&self) -> Option<&PackageMetadata<'g>> {
100        self.hakari_package.as_ref()
101    }
102
103    /// Reads the existing TOML file for the Hakari package from disk, returning a
104    /// `HakariCargoToml`.
105    ///
106    /// This can be used with [`Hakari::to_toml_string`](Hakari::to_toml_string) to manage the
107    /// contents of the Hakari package's TOML file on disk.
108    ///
109    /// Returns an error if there was an issue reading the TOML file from disk, or `None` if
110    /// this builder was created without a Hakari package.
111    pub fn read_toml(&self) -> Option<Result<HakariCargoToml, CargoTomlError>> {
112        let hakari_package = self.hakari_package()?;
113        let workspace_path = hakari_package
114            .source()
115            .workspace_path()
116            .expect("hakari_package is in workspace");
117        Some(HakariCargoToml::new_relative(
118            self.graph.workspace().root(),
119            workspace_path,
120        ))
121    }
122
123    /// Sets a list of platforms for `hakari` to use.
124    ///
125    /// By default, `hakari` unifies features that are always enabled across all platforms. If
126    /// builds are commonly performed on a few platforms, `hakari` can output platform-specific
127    /// instructions for those builds.
128    ///
129    /// This currently supports target triples only, without further customization around
130    /// target features or flags. In the future, this may support `cfg()` expressions using
131    /// an [SMT solver](https://en.wikipedia.org/wiki/Satisfiability_modulo_theories).
132    ///
133    /// Call `set_platforms` with an empty list to reset to default behavior.
134    ///
135    /// Returns an error if a platform wasn't known to [`target_spec`], the library `hakari` uses
136    /// to resolve platforms.
137    pub fn set_platforms(
138        &mut self,
139        platforms: impl IntoIterator<Item = impl Into<Cow<'static, str>>>,
140    ) -> Result<&mut Self, TargetSpecError> {
141        self.platforms = platforms
142            .into_iter()
143            .map(|s| Ok(Arc::new(Platform::new(s.into(), TargetFeatures::Unknown)?)))
144            .collect::<Result<Vec<_>, _>>()?;
145        Ok(self)
146    }
147
148    /// Returns the platforms set through `set_platforms`, or an empty list if no platforms are
149    /// set.
150    pub fn platforms(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
151        self.platforms.iter().map(|platform| platform.triple_str())
152    }
153
154    /// Sets the Cargo resolver version.
155    ///
156    /// By default, `HakariBuilder` uses [version 2](CargoResolverVersion::V2) of the Cargo
157    /// resolver. For more about Cargo resolvers, see the documentation for
158    /// [`CargoResolverVersion`](CargoResolverVersion).
159    pub fn set_resolver(&mut self, resolver: CargoResolverVersion) -> &mut Self {
160        self.resolver = resolver;
161        self
162    }
163
164    /// Returns the current Cargo resolver version.
165    pub fn resolver(&self) -> CargoResolverVersion {
166        self.resolver
167    }
168
169    /// Pretends that the provided packages don't exist during graph traversals.
170    ///
171    /// Users may wish to not consider certain packages while figuring out the unified feature set.
172    /// Setting this option prevents those packages from being considered.
173    ///
174    /// Practically, this means that:
175    /// * If a workspace package is specified, Cargo build simulations for it will not be run.
176    /// * If a third-party package is specified, it will not be present in the output, nor will
177    ///   any transitive dependencies or features enabled by it that aren't enabled any other way.
178    ///   In other words, any packages excluded during traversal are also [excluded from the final
179    ///   output](Self::add_final_excludes).
180    ///
181    /// Returns an error if any package IDs specified aren't known to the graph.
182    pub fn add_traversal_excludes<'b>(
183        &mut self,
184        excludes: impl IntoIterator<Item = &'b PackageId>,
185    ) -> Result<&mut Self, guppy::Error> {
186        let traversal_exclude: Vec<&'g PackageId> = excludes
187            .into_iter()
188            .map(|package_id| Ok(self.graph.metadata(package_id)?.id()))
189            .collect::<Result<_, _>>()?;
190        self.traversal_excludes.extend(traversal_exclude);
191        Ok(self)
192    }
193
194    /// Returns the packages currently excluded during graph traversals.
195    ///
196    /// Also returns the Hakari package if specified. This is because the Hakari package is treated
197    /// as excluded while performing unification.
198    pub fn traversal_excludes<'b>(&'b self) -> impl Iterator<Item = &'g PackageId> + 'b {
199        let excludes = self.make_traversal_excludes();
200        excludes.iter()
201    }
202
203    /// Returns true if a package ID is currently excluded during traversal.
204    ///
205    /// Also returns true for the Hakari package if specified. This is because the Hakari package is
206    /// treated as excluded by the algorithm.
207    ///
208    /// Returns an error if this package ID isn't known to the underlying graph.
209    pub fn is_traversal_excluded(&self, package_id: &PackageId) -> Result<bool, guppy::Error> {
210        self.graph.metadata(package_id)?;
211
212        let excludes = self.make_traversal_excludes();
213        Ok(excludes.is_excluded(package_id))
214    }
215
216    /// Adds packages to be removed from the final output.
217    ///
218    /// Unlike [`traversal_excludes`](Self::traversal_excludes), these packages are considered
219    /// during traversals, but removed at the end.
220    ///
221    /// Returns an error if any package IDs specified aren't known to the graph.
222    pub fn add_final_excludes<'b>(
223        &mut self,
224        excludes: impl IntoIterator<Item = &'b PackageId>,
225    ) -> Result<&mut Self, guppy::Error> {
226        let final_excludes: Vec<&'g PackageId> = excludes
227            .into_iter()
228            .map(|package_id| Ok(self.graph.metadata(package_id)?.id()))
229            .collect::<Result<_, _>>()?;
230        self.final_excludes.extend(final_excludes);
231        Ok(self)
232    }
233
234    /// Returns the packages to be removed from the final output.
235    pub fn final_excludes<'b>(&'b self) -> impl Iterator<Item = &'g PackageId> + 'b {
236        self.final_excludes.iter().copied()
237    }
238
239    /// Returns true if a package ID is currently excluded from the final output.
240    ///
241    /// Returns an error if this package ID isn't known to the underlying graph.
242    pub fn is_final_excluded(&self, package_id: &PackageId) -> Result<bool, guppy::Error> {
243        self.graph.metadata(package_id)?;
244        Ok(self.final_excludes.contains(package_id))
245    }
246
247    /// Returns true if a package ID is excluded from either the traversal or the final output.
248    ///
249    /// Also returns true for the Hakari package if specified. This is because the Hakari package is
250    /// treated as excluded by the algorithm.
251    ///
252    /// This does not cover [structural excludes](Hakari::structural_excludes).
253    ///
254    /// Returns an error if this package ID isn't known to the underlying graph.
255    #[inline]
256    pub fn is_excluded(&self, package_id: &PackageId) -> Result<bool, guppy::Error> {
257        Ok(self.is_traversal_excluded(package_id)? || self.is_final_excluded(package_id)?)
258    }
259
260    /// Returns true if `package` is a workspace member that hakari manages,
261    /// i.e. one that should depend on the hakari package.
262    ///
263    /// This consists of workspace packages that satisfy all of the following
264    /// criteria:
265    ///
266    /// * Not the hakari package itself.
267    /// * Not part of traversal excludes.
268    /// * Not part of final excludes.
269    ///
270    /// Returns false if no hakari package was specified, since nothing is
271    /// managed in that case.
272    pub(crate) fn is_managed_member(&self, package: &PackageMetadata<'g>) -> bool {
273        debug_assert!(
274            std::ptr::eq(package.graph(), *self.graph),
275            "package is from the same graph as this builder"
276        );
277        let Some(hakari_package) = self.hakari_package else {
278            return false;
279        };
280        // In verify mode, make_traversal_excludes leaves the hakari package in,
281        // so is_excluded alone is not enough here -- we have to check
282        // explicitly for the hakari package ID.
283        let is_hakari_package = hakari_package.id() == package.id();
284        package.in_workspace()
285            && !is_hakari_package
286            && !self
287                .is_excluded(package.id())
288                .expect("package is from the same graph as this builder")
289    }
290
291    /// Add alternate registries by (name, URL) pairs.
292    ///
293    /// This is a temporary workaround until [Cargo issue #9052](https://github.com/rust-lang/cargo/issues/9052)
294    /// is resolved.
295    pub fn add_registries(
296        &mut self,
297        registries: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
298    ) -> &mut Self {
299        self.registries
300            .extend(registries.into_iter().map(|(name, url)| Registry {
301                name: name.into(),
302                url: url.into(),
303            }));
304        self
305    }
306
307    /// Whether and how to unify feature sets across target and host platforms.
308    ///
309    /// This is an advanced feature that most users don't need to set. For more information about
310    /// this option, see the documentation for [`UnifyTargetHost`](UnifyTargetHost).
311    pub fn set_unify_target_host(&mut self, unify_target_host: UnifyTargetHost) -> &mut Self {
312        self.unify_target_host = unify_target_host;
313        self
314    }
315
316    /// Returns the current value of `unify_target_host`.
317    pub fn unify_target_host(&self) -> UnifyTargetHost {
318        self.unify_target_host
319    }
320
321    /// Whether to unify feature sets for all dependencies.
322    ///
323    /// By default, Hakari only produces output for dependencies that are built with more
324    /// than one feature set. If set to true, Hakari will produce outputs for all dependencies,
325    /// including those that don't need to be unified.
326    ///
327    /// This is rarely needed in production, and is most useful for testing and debugging scenarios.
328    pub fn set_output_single_feature(&mut self, output_single_feature: bool) -> &mut Self {
329        self.output_single_feature = output_single_feature;
330        self
331    }
332
333    /// Returns the current value of `output_single_feature`.
334    pub fn output_single_feature(&self) -> bool {
335        self.output_single_feature
336    }
337
338    /// Version of hakari data to output.
339    ///
340    /// For more, see the documentation for [`DepFormatVersion`](DepFormatVersion).
341    pub fn set_dep_format_version(&mut self, dep_format_version: DepFormatVersion) -> &mut Self {
342        self.dep_format_version = dep_format_version;
343        self
344    }
345
346    /// Returns the current value of `dep_format_version`.
347    pub fn dep_format_version(&self) -> DepFormatVersion {
348        self.dep_format_version
349    }
350
351    /// Kind of `workspace-hack = ...` lines to output.
352    ///
353    /// For more, see the documentation for [`WorkspaceHackLineStyle`].
354    pub fn set_workspace_hack_line_style(
355        &mut self,
356        line_style: WorkspaceHackLineStyle,
357    ) -> &mut Self {
358        self.workspace_hack_line_style = line_style;
359        self
360    }
361
362    /// Returns the current value of `workspace_hack_line_style`.
363    pub fn workspace_hack_line_style(&self) -> WorkspaceHackLineStyle {
364        self.workspace_hack_line_style
365    }
366
367    /// Computes the `Hakari` for this builder.
368    pub fn compute(self) -> Hakari<'g> {
369        Hakari::build(self)
370    }
371
372    // ---
373    // Helper methods
374    // ---
375
376    #[cfg(feature = "cli-support")]
377    pub(crate) fn traversal_excludes_only<'b>(
378        &'b self,
379    ) -> impl Iterator<Item = &'g PackageId> + 'b {
380        self.traversal_excludes.iter().copied()
381    }
382
383    fn make_traversal_excludes<'b>(&'b self) -> TraversalExcludes<'g, 'b> {
384        let hakari_package = if self.verify_mode {
385            None
386        } else {
387            self.hakari_package.map(|package| package.id())
388        };
389
390        TraversalExcludes {
391            excludes: &self.traversal_excludes,
392            hakari_package,
393        }
394    }
395
396    pub(crate) fn make_structural_excludes(&self) -> StructuralExcludes<'g> {
397        let cycle_forming = match &self.hakari_package {
398            Some(hakari_package) => {
399                let roots = std::iter::once(hakari_package.id()).chain(
400                    self.graph
401                        .workspace()
402                        .iter()
403                        .filter(|member| self.is_managed_member(member))
404                        .map(|member| member.id()),
405                );
406                self.graph
407                    .query_reverse(roots)
408                    .expect("roots are package IDs from this graph")
409                    .resolve_with_fn(|_, link| !link.dev_only())
410                    // The direction of the package IDs here doesn't matter since we
411                    // collect into a set anyway.
412                    .packages(DependencyDirection::Reverse)
413                    .filter(|package| !package.in_workspace())
414                    .map(|package| package.id())
415                    .collect()
416            }
417            None => BTreeSet::new(),
418        };
419
420        StructuralExcludes { cycle_forming }
421    }
422
423    fn make_features_only<'b>(&'b self) -> FeatureSet<'g> {
424        if self.verify_mode {
425            match &self.hakari_package {
426                Some(package) => package.to_package_set(),
427                None => self.graph.resolve_none(),
428            }
429            .to_feature_set(StandardFeatures::Default)
430        } else {
431            self.graph.feature_graph().resolve_none()
432        }
433    }
434}
435
436#[cfg(feature = "cli-support")]
437mod summaries {
438    use super::*;
439    use crate::summaries::HakariBuilderSummary;
440    use guppy::platform::TargetFeatures;
441
442    impl<'g> HakariBuilder<'g> {
443        /// Constructs a `HakariBuilder` from a `PackageGraph` and a serialized summary.
444        ///
445        /// Requires the `cli-support` feature to be enabled.
446        ///
447        /// Returns an error if the summary references a package that's not present, or if there was
448        /// some other issue while creating a `HakariBuilder` from the summary.
449        pub fn from_summary(
450            graph: &'g PackageGraph,
451            summary: &HakariBuilderSummary,
452        ) -> Result<Self, guppy::Error> {
453            let hakari_package = summary
454                .hakari_package
455                .as_ref()
456                .map(|name| graph.workspace().member_by_name(name))
457                .transpose()?;
458            let platforms = summary
459                .platforms
460                .iter()
461                .map(|triple_str| {
462                    let platform = Platform::new(triple_str.clone(), TargetFeatures::Unknown)
463                        .map_err(|err| {
464                            guppy::Error::TargetSpecError(
465                                "while resolving hakari config or summary".to_owned(),
466                                err,
467                            )
468                        })?;
469                    Ok(platform.into())
470                })
471                .collect::<Result<Vec<_>, _>>()?;
472
473            let registries: BiHashMap<_, ahash::RandomState> = summary
474                .registries
475                .iter()
476                .map(|(name, url)| Registry {
477                    name: name.clone(),
478                    url: url.clone(),
479                })
480                .collect();
481
482            let traversal_excludes = summary
483                .traversal_excludes
484                .to_package_set_registry(
485                    graph,
486                    |name| registries.get1(name).map(|registry| registry.url.as_str()),
487                    "resolving hakari traversal-excludes",
488                )?
489                .package_ids(DependencyDirection::Forward)
490                .collect();
491            let final_excludes = summary
492                .final_excludes
493                .to_package_set_registry(
494                    graph,
495                    |name| registries.get1(name).map(|registry| registry.url.as_str()),
496                    "resolving hakari final-excludes",
497                )?
498                .package_ids(DependencyDirection::Forward)
499                .collect();
500
501            Ok(Self {
502                graph: DebugIgnore(graph),
503                hakari_package,
504                resolver: summary.resolver,
505                verify_mode: false,
506                unify_target_host: summary.unify_target_host,
507                output_single_feature: summary.output_single_feature,
508                dep_format_version: summary.dep_format_version,
509                workspace_hack_line_style: summary.workspace_hack_line_style,
510                platforms,
511                registries,
512                traversal_excludes,
513                final_excludes,
514            })
515        }
516    }
517}
518
519/// Whether to unify feature sets for a given dependency across target and host platforms.
520///
521/// Consider a dependency that is built as both normally (on the target platform) and in a build
522/// script or proc macro. The normal dependency is considered to be built on the *target platform*,
523/// and is represented in the `[dependencies]` section in the generated `Cargo.toml`.
524/// The build dependency is built on the *host platform*, represented in the `[build-dependencies]`
525/// section.
526///
527/// Now consider that the target and host platforms need two different sets of features:
528///
529/// ```toml
530/// ## feature set on target platform
531/// [dependencies]
532/// my-dep = { version = "1.0", features = ["a", "b"] }
533///
534/// ## feature set on host platform
535/// [build-dependencies]
536/// my-dep = { version = "1.0", features = ["b", "c"] }
537/// ```
538///
539/// Should hakari unify the feature sets across the `[dependencies]` and `[build-dependencies]`
540/// feature sets?
541///
542/// Call `HakariBuilder::set_unify_target_host` to configure this option.
543#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
544#[cfg_attr(feature = "proptest1", derive(proptest_derive::Arbitrary))]
545#[cfg_attr(feature = "cli-support", derive(serde::Serialize, serde::Deserialize))]
546#[cfg_attr(feature = "cli-support", serde(rename_all = "kebab-case"))]
547#[non_exhaustive]
548pub enum UnifyTargetHost {
549    /// Perform no unification across the target and host feature sets.
550    ///
551    /// This is the most conservative option, but it means that some dependencies may be built with
552    /// two different sets of features. In this mode, Hakari will likely be significantly less
553    /// efficient.
554    None,
555
556    /// Automatically choose between the [`UnifyIfBoth`](Self::UnifyIfBoth) and the
557    /// [`ReplicateTargetOnHost`](Self::ReplicateTargetOnHost) options:
558    /// * If the workspace contains proc macros, or crates that are build dependencies of other
559    ///   crates, choose the `ReplicateTargetAsHost` strategy.
560    /// * Otherwise, choose the `UnifyIfBoth` strategy.
561    ///
562    /// This is the default behavior.
563    Auto,
564
565    /// Perform unification across target and host feature sets, but only if a dependency is built
566    /// on both the target and the host.
567    ///
568    /// This is useful if cross-compilations are uncommon and one wishes to avoid the same package
569    /// being built two different ways: once for the target and once for the host.
570    UnifyIfBoth,
571
572    /// Perform unification across target and host feature sets, and also replicate all target-only
573    /// lines to the host.
574    ///
575    /// This is most useful if some workspace packages are proc macros or build dependencies
576    /// used by other packages.
577    ReplicateTargetOnHost,
578}
579
580/// The default for `UnifyTargetHost`: automatically choose unification strategy based on the
581/// workspace.
582impl Default for UnifyTargetHost {
583    #[inline]
584    fn default() -> Self {
585        UnifyTargetHost::Auto
586    }
587}
588
589/// Format version for hakari.
590///
591/// Older versions are kept around for backwards compatibility.
592#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
593#[cfg_attr(feature = "cli-support", derive(serde::Deserialize, serde::Serialize))]
594#[cfg_attr(feature = "proptest1", derive(proptest_derive::Arbitrary))]
595#[non_exhaustive]
596#[derive(Default)]
597pub enum DepFormatVersion {
598    /// `workspace-hack = { path = ...}`. (Note the lack of a trailing space.)
599    ///
600    /// This was used until `cargo hakari 0.9.6`.
601    #[cfg_attr(feature = "cli-support", serde(rename = "1"))]
602    #[default]
603    V1,
604
605    /// `workspace-hack = { version = "0.1", path = ... }`. This was introduced in
606    /// `cargo hakari 0.9.8`.
607    #[cfg_attr(feature = "cli-support", serde(rename = "2"))]
608    V2,
609
610    /// Elides build metadata. This was introduced in `cargo hakari 0.9.18`.
611    #[cfg_attr(feature = "cli-support", serde(rename = "3"))]
612    V3,
613
614    /// Sorts dependency names alphabetically. This was introduced in `cargo hakari 0.9.22`.
615    ///
616    /// (Dependency names were usually produced in sorted order before V4, but there are
617    /// some edge cases where they weren't: see [issue
618    /// #65](https://github.com/guppy-rs/guppy/issues/65).
619    #[cfg_attr(feature = "cli-support", serde(rename = "4"))]
620    V4,
621}
622
623impl DepFormatVersion {
624    /// Returns the highest format version supported by this version of `cargo hakari`.
625    #[inline]
626    pub fn latest() -> Self {
627        DepFormatVersion::V4
628    }
629}
630
631impl fmt::Display for DepFormatVersion {
632    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
633        match self {
634            DepFormatVersion::V1 => write!(f, "1"),
635            DepFormatVersion::V2 => write!(f, "2"),
636            DepFormatVersion::V3 => write!(f, "3"),
637            DepFormatVersion::V4 => write!(f, "4"),
638        }
639    }
640}
641
642/// Style of `workspace-hack = ...` lines to output.
643#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
644#[cfg_attr(feature = "cli-support", derive(serde::Deserialize, serde::Serialize))]
645#[cfg_attr(feature = "cli-support", serde(rename_all = "kebab-case"))]
646#[cfg_attr(feature = "proptest1", derive(proptest_derive::Arbitrary))]
647#[non_exhaustive]
648#[derive(Default)]
649pub enum WorkspaceHackLineStyle {
650    /// `workspace-hack = { version = "0.1", path = ... }`.
651    #[default]
652    Full,
653
654    /// `workspace-hack = { version = "0.1" }`.
655    VersionOnly,
656
657    /// `workspace-hack.workspace = true`
658    WorkspaceDotted,
659}
660
661/// A key representing a platform and host/target. Returned by `Hakari`.
662#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
663pub struct OutputKey {
664    /// The index of the build platform for this key, or `None` if the computation was done in a
665    /// platform-independent manner.
666    pub platform_idx: Option<usize>,
667
668    /// The build platform: target or host.
669    pub build_platform: BuildPlatform,
670}
671
672/// The result of a Hakari computation.
673///
674/// This contains all the data required to generate a workspace package.
675///
676/// Produced by [`HakariBuilder::compute`](HakariBuilder::compute).
677#[derive(Clone, Debug)]
678#[non_exhaustive]
679pub struct Hakari<'g> {
680    pub(crate) builder: HakariBuilder<'g>,
681
682    /// The map built by Hakari of dependencies that need to be unified.
683    ///
684    /// This map is used to construct the TOML output. Public access is provided in case some
685    /// post-processing needs to be done.
686    pub output_map: OutputMap<'g>,
687
688    /// The complete map of dependency build results built by Hakari.
689    ///
690    /// The map does not include workspace packages or the packages reported by
691    /// [`structural_excludes`](Self::structural_excludes).
692    ///
693    /// This map is not used to generate the TOML output.
694    pub computed_map: ComputedMap<'g>,
695
696    structural_excludes: StructuralExcludes<'g>,
697}
698
699impl<'g> Hakari<'g> {
700    /// Returns the `HakariBuilder` used to create this instance.
701    pub fn builder(&self) -> &HakariBuilder<'g> {
702        &self.builder
703    }
704
705    /// Returns the *structural excludes*: third-party packages that depend on
706    /// the Hakari package, or on a workspace member that Hakari manages,
707    /// directly or transitively through normal or build dependencies.
708    ///
709    /// Unlike traversal and final excludes, which come from configuration,
710    /// structural excludes are determined by the shape of the dependency graph
711    /// and can't be configured away.
712    ///
713    /// These packages are never added to the Hakari package, because doing so
714    /// would form a dependency cycle (assuming the `manage-deps` command says
715    /// the workspace is up-to-date). This typically happens when a workspace
716    /// member is also published on a registry such as crates.io, and a
717    /// `[patch]` directive redirects the published version's Hakari dependency
718    /// back into the workspace.
719    ///
720    /// * Dev-only dependencies are not followed, because Cargo permits cycles
721    ///   through them.
722    /// * Target-specific dependencies are followed, no matter which platforms
723    ///   this builder is configured with, because Cargo rejects these cycles
724    ///   even on platforms where the dependency isn't enabled.
725    ///
726    /// Unlike [`traversal_excludes`](HakariBuilder::traversal_excludes), these packages
727    /// are still considered while simulating builds, so their own dependencies
728    /// are unified as usual.
729    ///
730    /// Returns an empty iterator if the builder had no Hakari package
731    /// specified.
732    pub fn structural_excludes(&self) -> impl Iterator<Item = &'g PackageId> + '_ {
733        self.structural_excludes.cycle_forming.iter().copied()
734    }
735
736    /// Returns true if `package_id` is one of the
737    /// [`structural_excludes`](Self::structural_excludes).
738    ///
739    /// Note that this returns `Ok(false)` for workspace members, even though
740    /// those are structurally excluded as well.
741    ///
742    /// Returns an error if this package ID isn't known to the underlying graph.
743    pub fn is_structural_excluded(&self, package_id: &PackageId) -> Result<bool, guppy::Error> {
744        self.builder.graph().metadata(package_id)?;
745        Ok(self.structural_excludes.cycle_forming.contains(package_id))
746    }
747
748    /// Reads the existing TOML file for the Hakari package from disk, returning a
749    /// `HakariCargoToml`.
750    ///
751    /// This can be used with [`to_toml_string`](Self::to_toml_string) to manage the contents of
752    /// the given TOML file on disk.
753    ///
754    /// Returns an error if there was an issue reading the TOML file from disk, or `None` if
755    /// the builder's [`hakari_package`](HakariBuilder::hakari_package) is `None`.
756    pub fn read_toml(&self) -> Option<Result<HakariCargoToml, CargoTomlError>> {
757        self.builder.read_toml()
758    }
759
760    /// Writes `[dependencies]` and other `Cargo.toml` lines to the given `fmt::Write` instance.
761    ///
762    /// `&mut String` and `fmt::Formatter` both implement `fmt::Write`.
763    pub fn write_toml(
764        &self,
765        options: &HakariOutputOptions,
766        out: impl fmt::Write,
767    ) -> Result<(), TomlOutError> {
768        write_toml(
769            &self.builder,
770            &self.output_map,
771            options,
772            self.builder.dep_format_version,
773            out,
774        )
775    }
776
777    /// Returns a map of dependency names as present in the workspace-hack's `Cargo.toml` to their
778    /// corresponding [`PackageMetadata`].
779    ///
780    /// Packages which have one version are present as their original names, while packages with
781    /// more than one version have a hash appended to them.
782    pub fn toml_name_map(&self) -> AHashMap<Cow<'g, str>, PackageMetadata<'g>> {
783        toml_name_map(&self.output_map, self.builder.dep_format_version)
784    }
785
786    /// Returns a `HakariExplain`, which can be used to print out why a specific package is
787    /// in the workspace-hack's `Cargo.toml`.
788    ///
789    /// Returns an error if the package ID was not found in the output.
790    pub fn explain(
791        &self,
792        package_id: &'g PackageId,
793    ) -> Result<HakariExplain<'g, '_>, guppy::Error> {
794        HakariExplain::new(self, package_id)
795    }
796
797    /// A convenience method around `write_toml` that returns a new string with `Cargo.toml` lines.
798    ///
799    /// The returned string is guaranteed to be valid TOML, and can be provided to
800    /// a [`HakariCargoToml`](crate::HakariCargoToml) obtained from [`read_toml`](Self::read_toml).
801    pub fn to_toml_string(&self, options: &HakariOutputOptions) -> Result<String, TomlOutError> {
802        let mut out = String::new();
803        self.write_toml(options, &mut out)?;
804        Ok(out)
805    }
806
807    // ---
808    // Helper methods
809    // ---
810
811    fn build(builder: HakariBuilder<'g>) -> Self {
812        let graph = *builder.graph;
813        let mut computed_map_build = ComputedMapBuild::new(&builder);
814        let platform_specs: Vec<_> = builder
815            .platforms
816            .iter()
817            .map(|platform| PlatformSpec::from(platform.clone()))
818            .collect();
819
820        let unify_target_host = builder.unify_target_host.to_impl(graph);
821
822        // Collect all the dependencies that need to be unified, by platform and build type.
823        let mut map_build: OutputMapBuild<'g> = OutputMapBuild::new(graph);
824        map_build.insert_all(
825            computed_map_build.iter(),
826            builder.output_single_feature,
827            unify_target_host,
828        );
829
830        if !builder.output_single_feature {
831            // Adding packages might cause different feature sets for some dependencies. Simulate
832            // further builds with the given target and host features, and use that to add in any
833            // extra features that need to be considered.
834            loop {
835                let mut add_extra = HashSet::new();
836                for (output_key, features) in map_build.iter_feature_sets() {
837                    let initials_platform = match output_key.build_platform {
838                        BuildPlatform::Target => InitialsPlatform::Standard,
839                        BuildPlatform::Host => InitialsPlatform::Host,
840                    };
841
842                    let mut cargo_opts = CargoOptions::new();
843                    let platform_spec = match output_key.platform_idx {
844                        Some(idx) => platform_specs[idx].clone(),
845                        None => PlatformSpec::Always,
846                    };
847                    // Third-party dependencies are built without including dev.
848                    cargo_opts
849                        .set_include_dev(false)
850                        .set_initials_platform(initials_platform)
851                        .set_platform(platform_spec)
852                        .set_resolver(builder.resolver)
853                        .add_omitted_packages(computed_map_build.traversal_excludes.iter());
854                    let cargo_set = features
855                        .into_cargo_set(&cargo_opts)
856                        .expect("into_cargo_set processed successfully");
857
858                    // Check the features for the cargo set to see if any further dependencies were
859                    // built with a different result and weren't included in the hakari map
860                    // originally.
861                    for &(build_platform, feature_set) in cargo_set.all_features().iter() {
862                        for feature_list in
863                            feature_set.packages_with_features(DependencyDirection::Forward)
864                        {
865                            let dep = feature_list.package();
866                            if computed_map_build.structural_excludes.never_unified(dep) {
867                                continue;
868                            }
869                            let dep_id = dep.id();
870                            // This is "get or insert" because we could be adding whole new
871                            // dependencies here rather than just new features to existing
872                            // dependencies.
873                            let v_mut = computed_map_build
874                                .get_or_insert_mut(output_key.platform_idx, dep_id);
875
876                            // Is it already present in the output?
877                            let new_key = OutputKey {
878                                platform_idx: output_key.platform_idx,
879                                build_platform,
880                            };
881
882                            if map_build.is_inserted(new_key, dep_id) {
883                                continue;
884                            }
885
886                            let this_list: BTreeSet<_> = feature_list.named_features().collect();
887
888                            let already_present = v_mut.contains(build_platform, &this_list);
889                            if !already_present {
890                                // The feature list added by this dependency is non-unique.
891                                v_mut.mark_fixed_up(build_platform, this_list);
892                                add_extra.insert((output_key.platform_idx, dep_id));
893                            }
894                        }
895                    }
896                }
897
898                if add_extra.is_empty() {
899                    break;
900                }
901
902                map_build.insert_all(
903                    add_extra.iter().map(|&(platform_idx, dep_id)| {
904                        let v = computed_map_build
905                            .get(platform_idx, dep_id)
906                            .expect("full value should be present");
907                        (platform_idx, dep_id, v)
908                    }),
909                    builder.output_single_feature,
910                    unify_target_host,
911                );
912            }
913        }
914
915        let ComputedMapBuild {
916            structural_excludes,
917            computed_map,
918            ..
919        } = computed_map_build;
920        let output_map = map_build.finish(
921            &builder.final_excludes,
922            builder.dep_format_version,
923            builder.output_single_feature,
924        );
925
926        Self {
927            builder,
928            output_map,
929            computed_map,
930            structural_excludes,
931        }
932    }
933}
934
935/// The map used by Hakari to generate output TOML.
936///
937/// This is a two-level `BTreeMap`, where:
938/// * the top-level keys are [`OutputKey`](OutputKey) instances.
939/// * the inner map is keyed by dependency [`PackageId`](PackageId) instances, and the values are
940///   the corresponding [`PackageMetadata`](PackageMetadata) for this dependency, and the set of
941///   features enabled for this package.
942///
943/// This is an alias for the type of [`Hakari::output_map`](Hakari::output_map).
944pub type OutputMap<'g> =
945    BTreeMap<OutputKey, BTreeMap<&'g PackageId, (PackageMetadata<'g>, BTreeSet<&'g str>)>>;
946
947/// The map of all build results computed by Hakari.
948///
949/// The keys are the platform index and the dependency's package ID, and the values are
950/// [`ComputedValue`](ComputedValue) instances that represent the different feature sets this
951/// dependency is built with on both the host and target platforms.
952///
953/// The values that are most interesting are the ones where maps have two elements or more: they
954/// indicate dependencies with features that need to be unified.
955///
956/// This is an alias for the type of [`Hakari::computed_map`](Hakari::computed_map).
957pub type ComputedMap<'g> = BTreeMap<(Option<usize>, &'g PackageId), ComputedValue<'g>>;
958
959/// The values of a [`ComputedMap`](ComputedMap).
960///
961/// This represents a pair of `ComputedInnerMap` instances: one for the target platform and one for
962/// the host. For more about the values, see the documentation for
963/// [`ComputedInnerMap`](ComputedInnerMap).
964#[derive(Clone, Debug, Default)]
965pub struct ComputedValue<'g> {
966    /// The feature sets built on the target platform.
967    pub target_inner: ComputedInnerMap<'g>,
968
969    /// The feature sets built on the host platform.
970    pub host_inner: ComputedInnerMap<'g>,
971}
972
973/// A target map or a host map in a [`ComputedValue`](ComputedValue).
974///
975/// * The keys are sets of feature names (or empty for no features).
976/// * The values are [`ComputedInnerValue`] instances.
977pub type ComputedInnerMap<'g> = BTreeMap<BTreeSet<&'g str>, ComputedInnerValue<'g>>;
978
979/// The values of [`ComputedInnerMap`].
980#[derive(Clone, Debug, Default)]
981pub struct ComputedInnerValue<'g> {
982    /// The workspace packages, selected features, and include dev that cause the key in
983    /// `ComputedMap` to be built with the feature set that forms the key of `ComputedInnerMap`.
984    /// They are not defined to be in any particular order.
985    pub workspace_packages: Vec<(PackageMetadata<'g>, StandardFeatures, bool)>,
986
987    /// Whether at least one post-computation fixup was performed with this feature set.
988    pub fixed_up: bool,
989}
990
991impl<'g> ComputedInnerValue<'g> {
992    fn extend(&mut self, other: ComputedInnerValue<'g>) {
993        self.workspace_packages.extend(other.workspace_packages);
994        self.fixed_up |= other.fixed_up;
995    }
996
997    #[inline]
998    fn push(
999        &mut self,
1000        package: PackageMetadata<'g>,
1001        features: StandardFeatures,
1002        include_dev: bool,
1003    ) {
1004        self.workspace_packages
1005            .push((package, features, include_dev));
1006    }
1007}
1008
1009#[derive(Debug)]
1010struct TraversalExcludes<'g, 'b> {
1011    excludes: &'b HashSet<&'g PackageId>,
1012    hakari_package: Option<&'g PackageId>,
1013}
1014
1015impl<'g, 'b> TraversalExcludes<'g, 'b> {
1016    fn iter(&self) -> impl Iterator<Item = &'g PackageId> + 'b + use<'g, 'b> {
1017        self.excludes.iter().copied().chain(self.hakari_package)
1018    }
1019
1020    fn is_excluded(&self, package_id: &PackageId) -> bool {
1021        self.hakari_package == Some(package_id) || self.excludes.contains(package_id)
1022    }
1023}
1024
1025/// Packages that can never be unified into the Hakari package, regardless of
1026/// configuration.
1027///
1028/// For the definition, and how these differ from [`TraversalExcludes`], see
1029/// [`Hakari::structural_excludes`].
1030#[derive(Clone, Debug)]
1031pub(crate) struct StructuralExcludes<'g> {
1032    /// The third-party packages reported by [`Hakari::structural_excludes`].
1033    ///
1034    /// Workspace packages (including the Hakari package itself) are never in
1035    /// this set; [`Self::never_unified`] handles them.
1036    pub(crate) cycle_forming: BTreeSet<&'g PackageId>,
1037}
1038
1039impl<'g> StructuralExcludes<'g> {
1040    /// Returns true if `package` must never be unified into the Hakari
1041    /// package: it is a workspace package (Hakari only unifies third-party
1042    /// dependencies), or a member of [`Self::cycle_forming`].
1043    fn never_unified(&self, package: &PackageMetadata<'g>) -> bool {
1044        package.in_workspace() || self.cycle_forming.contains(package.id())
1045    }
1046}
1047
1048/// Intermediate build state used by Hakari.
1049#[derive(Debug)]
1050struct ComputedMapBuild<'g, 'b> {
1051    traversal_excludes: TraversalExcludes<'g, 'b>,
1052    structural_excludes: StructuralExcludes<'g>,
1053    computed_map: ComputedMap<'g>,
1054}
1055
1056impl<'g, 'b> ComputedMapBuild<'g, 'b> {
1057    fn new(builder: &'b HakariBuilder<'g>) -> Self {
1058        // This was just None or All for a bit under the theory that feature sets are additive only,
1059        // but unfortunately we cannot exploit this property because it doesn't account for the fact
1060        // that some dependencies might not be built *at all*, under certain feature combinations.
1061        //
1062        // That's also why we simulate builds with and without dev-only dependencies in all cases.
1063        //
1064        // For example, for:
1065        //
1066        // ```toml
1067        // [dependencies]
1068        // dep = { version = "1", optional = true }
1069        //
1070        // [dev-dependencies]
1071        // dep = { version = "1", optional = true, features = ["dev-feature"] }
1072        //
1073        // [features]
1074        // default = ["dep"]
1075        // extra = ["dep/extra", "dep/dev-feature"]
1076        // ```
1077        //
1078        // | feature set | include dev | dep status         |
1079        // | ----------- | ----------- | ------------------ |
1080        // | none        | no          | not built          |
1081        // | none        | yes         | not built          |
1082        // | default     | no          | no features        |
1083        // | default     | yes         | dev-feature        |
1084        // | all         | no          | extra, dev-feature |
1085        // | all         | yes         | extra, dev-feature |
1086        //
1087        // (And there's further complexity possible with transitive deps as well.)
1088        let features_include_dev = [
1089            (StandardFeatures::None, false),
1090            (StandardFeatures::None, true),
1091            (StandardFeatures::Default, false),
1092            (StandardFeatures::Default, true),
1093            (StandardFeatures::All, false),
1094            (StandardFeatures::All, true),
1095        ];
1096
1097        // Features for the "always" platform spec.
1098        let always_features = features_include_dev
1099            .iter()
1100            .map(|&(features, include_dev)| (None, PlatformSpec::Always, features, include_dev));
1101
1102        // Features for specified platforms.
1103        let specified_features =
1104            features_include_dev
1105                .iter()
1106                .flat_map(|&(features, include_dev)| {
1107                    builder
1108                        .platforms
1109                        .iter()
1110                        .enumerate()
1111                        .map(move |(idx, platform)| {
1112                            (
1113                                Some(idx),
1114                                PlatformSpec::from(platform.clone()),
1115                                features,
1116                                include_dev,
1117                            )
1118                        })
1119                });
1120        let platforms_features: Vec<_> = always_features.chain(specified_features).collect();
1121
1122        let workspace = builder.graph.workspace();
1123        let traversal_excludes = builder.make_traversal_excludes();
1124        let structural_excludes = builder.make_structural_excludes();
1125        let features_only = builder.make_features_only();
1126        let traversal_excludes_ref = &traversal_excludes;
1127        let structural_excludes_ref = &structural_excludes;
1128        let features_only_ref = &features_only;
1129
1130        let computed_map: ComputedMap<'g> = platforms_features
1131            .into_par_iter()
1132            // The cargo_set computation in the inner iterator is the most expensive part of the
1133            // process, so use flat_map instead of flat_map_iter.
1134            .flat_map(|(idx, platform_spec, feature_filter, include_dev)| {
1135                let mut cargo_options = CargoOptions::new();
1136                cargo_options
1137                    .set_include_dev(include_dev)
1138                    .set_resolver(builder.resolver)
1139                    .set_platform(platform_spec)
1140                    .add_omitted_packages(traversal_excludes.iter());
1141
1142                workspace.par_iter().map(move |workspace_package| {
1143                    if traversal_excludes_ref.is_excluded(workspace_package.id()) {
1144                        // Skip this package since it was excluded during traversal.
1145                        return BTreeMap::new();
1146                    }
1147
1148                    let initials = workspace_package
1149                        .to_package_set()
1150                        .to_feature_set(feature_filter);
1151                    let cargo_set =
1152                        CargoSet::new(initials, features_only_ref.clone(), &cargo_options)
1153                            .expect("cargo resolution should succeed");
1154
1155                    let all_features = cargo_set.all_features();
1156
1157                    let values = all_features.iter().flat_map(|&(build_platform, features)| {
1158                        features
1159                            .packages_with_features(DependencyDirection::Forward)
1160                            .filter_map(move |feature_list| {
1161                                let dep = feature_list.package();
1162                                if structural_excludes_ref.never_unified(dep) {
1163                                    return None;
1164                                }
1165
1166                                let features: BTreeSet<&'g str> =
1167                                    feature_list.named_features().collect();
1168                                Some((
1169                                    idx,
1170                                    build_platform,
1171                                    dep.id(),
1172                                    features,
1173                                    workspace_package,
1174                                    feature_filter,
1175                                    include_dev,
1176                                ))
1177                            })
1178                    });
1179
1180                    let mut map = ComputedMap::new();
1181                    for (
1182                        platform_idx,
1183                        build_platform,
1184                        package_id,
1185                        features,
1186                        package,
1187                        feature_filter,
1188                        include_dev,
1189                    ) in values
1190                    {
1191                        // Accumulate the features and package for each key.
1192                        map.entry((platform_idx, package_id)).or_default().insert(
1193                            build_platform,
1194                            features,
1195                            package,
1196                            feature_filter,
1197                            include_dev,
1198                        );
1199                    }
1200
1201                    map
1202                })
1203            })
1204            .reduce(ComputedMap::new, |mut acc, map| {
1205                // Accumulate across all threads.
1206                for (k, v) in map {
1207                    acc.entry(k).or_default().merge(v);
1208                }
1209                acc
1210            });
1211
1212        Self {
1213            traversal_excludes,
1214            structural_excludes,
1215            computed_map,
1216        }
1217    }
1218
1219    fn get(
1220        &self,
1221        platform_idx: Option<usize>,
1222        package_id: &'g PackageId,
1223    ) -> Option<&ComputedValue<'g>> {
1224        self.computed_map.get(&(platform_idx, package_id))
1225    }
1226
1227    fn get_or_insert_mut(
1228        &mut self,
1229        platform_idx: Option<usize>,
1230        package_id: &'g PackageId,
1231    ) -> &mut ComputedValue<'g> {
1232        self.computed_map
1233            .entry((platform_idx, package_id))
1234            .or_default()
1235    }
1236
1237    fn iter<'a>(
1238        &'a self,
1239    ) -> impl Iterator<Item = (Option<usize>, &'g PackageId, &'a ComputedValue<'g>)> + 'a {
1240        self.computed_map
1241            .iter()
1242            .map(move |(&(platform_idx, package_id), v)| (platform_idx, package_id, v))
1243    }
1244}
1245
1246impl<'g> ComputedValue<'g> {
1247    /// Returns both the inner maps along with the build platforms they represent.
1248    pub fn inner_maps(&self) -> [(BuildPlatform, &ComputedInnerMap<'g>); 2] {
1249        [
1250            (BuildPlatform::Target, &self.target_inner),
1251            (BuildPlatform::Host, &self.host_inner),
1252        ]
1253    }
1254
1255    /// Converts `self` into [`ComputedInnerMap`] instances, along with the build platforms they
1256    /// represent.
1257    pub fn into_inner_maps(self) -> [(BuildPlatform, ComputedInnerMap<'g>); 2] {
1258        [
1259            (BuildPlatform::Target, self.target_inner),
1260            (BuildPlatform::Host, self.host_inner),
1261        ]
1262    }
1263
1264    /// Returns a reference to the inner map corresponding to the given build platform.
1265    pub fn get_inner(&self, build_platform: BuildPlatform) -> &ComputedInnerMap<'g> {
1266        match build_platform {
1267            BuildPlatform::Target => &self.target_inner,
1268            BuildPlatform::Host => &self.host_inner,
1269        }
1270    }
1271
1272    /// Returns a mutable reference to the inner map corresponding to the given build platform.
1273    pub fn get_inner_mut(&mut self, build_platform: BuildPlatform) -> &mut ComputedInnerMap<'g> {
1274        match build_platform {
1275            BuildPlatform::Target => &mut self.target_inner,
1276            BuildPlatform::Host => &mut self.host_inner,
1277        }
1278    }
1279
1280    /// Adds all the instances in `other` to `self`.
1281    fn merge(&mut self, other: ComputedValue<'g>) {
1282        for (features, details) in other.target_inner {
1283            self.target_inner
1284                .entry(features)
1285                .or_default()
1286                .extend(details);
1287        }
1288        for (features, details) in other.host_inner {
1289            self.host_inner.entry(features).or_default().extend(details);
1290        }
1291    }
1292
1293    fn contains(&mut self, build_platform: BuildPlatform, features: &BTreeSet<&'g str>) -> bool {
1294        self.get_inner(build_platform).contains_key(features)
1295    }
1296
1297    fn insert(
1298        &mut self,
1299        build_platform: BuildPlatform,
1300        features: BTreeSet<&'g str>,
1301        package: PackageMetadata<'g>,
1302        feature_filter: StandardFeatures,
1303        include_dev: bool,
1304    ) {
1305        self.get_inner_mut(build_platform)
1306            .entry(features)
1307            .or_default()
1308            .push(package, feature_filter, include_dev);
1309    }
1310
1311    fn mark_fixed_up(&mut self, build_platform: BuildPlatform, features: BTreeSet<&'g str>) {
1312        self.get_inner_mut(build_platform)
1313            .entry(features)
1314            .or_default()
1315            .fixed_up = true;
1316    }
1317
1318    fn describe<'a>(&'a self) -> ValueDescribe<'g, 'a> {
1319        match (self.target_inner.len(), self.host_inner.len()) {
1320            (0, 0) => ValueDescribe::None,
1321            (0, 1) => ValueDescribe::SingleHost(&self.host_inner),
1322            (1, 0) => ValueDescribe::SingleTarget(&self.target_inner),
1323            (1, 1) => {
1324                let target_features = self.target_inner.keys().next().expect("1 element");
1325                let host_features = self.host_inner.keys().next().expect("1 element");
1326                if target_features == host_features {
1327                    ValueDescribe::SingleMatchingBoth {
1328                        target_inner: &self.target_inner,
1329                        host_inner: &self.host_inner,
1330                    }
1331                } else {
1332                    ValueDescribe::SingleNonMatchingBoth {
1333                        target_inner: &self.target_inner,
1334                        host_inner: &self.host_inner,
1335                    }
1336                }
1337            }
1338            (_m, 0) => ValueDescribe::MultiTarget(&self.target_inner),
1339            (_m, 1) => ValueDescribe::MultiTargetSingleHost {
1340                target_inner: &self.target_inner,
1341                host_inner: &self.host_inner,
1342            },
1343            (0, _n) => ValueDescribe::MultiHost(&self.host_inner),
1344            (1, _n) => ValueDescribe::MultiHostSingleTarget {
1345                target_inner: &self.target_inner,
1346                host_inner: &self.host_inner,
1347            },
1348            (_m, _n) => ValueDescribe::MultiBoth {
1349                target_inner: &self.target_inner,
1350                host_inner: &self.host_inner,
1351            },
1352        }
1353    }
1354}
1355
1356#[derive(Copy, Clone, Debug)]
1357enum ValueDescribe<'g, 'a> {
1358    None,
1359    SingleTarget(&'a ComputedInnerMap<'g>),
1360    SingleHost(&'a ComputedInnerMap<'g>),
1361    MultiTarget(&'a ComputedInnerMap<'g>),
1362    MultiHost(&'a ComputedInnerMap<'g>),
1363    SingleMatchingBoth {
1364        target_inner: &'a ComputedInnerMap<'g>,
1365        host_inner: &'a ComputedInnerMap<'g>,
1366    },
1367    SingleNonMatchingBoth {
1368        target_inner: &'a ComputedInnerMap<'g>,
1369        host_inner: &'a ComputedInnerMap<'g>,
1370    },
1371    MultiTargetSingleHost {
1372        target_inner: &'a ComputedInnerMap<'g>,
1373        host_inner: &'a ComputedInnerMap<'g>,
1374    },
1375    MultiHostSingleTarget {
1376        target_inner: &'a ComputedInnerMap<'g>,
1377        host_inner: &'a ComputedInnerMap<'g>,
1378    },
1379    MultiBoth {
1380        target_inner: &'a ComputedInnerMap<'g>,
1381        host_inner: &'a ComputedInnerMap<'g>,
1382    },
1383}
1384
1385impl<'g, 'a> ValueDescribe<'g, 'a> {
1386    #[allow(dead_code)]
1387    fn description(self) -> &'static str {
1388        match self {
1389            ValueDescribe::None => "None",
1390            ValueDescribe::SingleTarget(_) => "SingleTarget",
1391            ValueDescribe::SingleHost(_) => "SingleHost",
1392            ValueDescribe::MultiTarget(_) => "MultiTarget",
1393            ValueDescribe::MultiHost(_) => "MultiHost",
1394            ValueDescribe::SingleMatchingBoth { .. } => "SingleMatchingBoth",
1395            ValueDescribe::SingleNonMatchingBoth { .. } => "SingleNonMatchingBoth",
1396            ValueDescribe::MultiTargetSingleHost { .. } => "MultiTargetSingleHost",
1397            ValueDescribe::MultiHostSingleTarget { .. } => "MultiHostSingleTarget",
1398            ValueDescribe::MultiBoth { .. } => "MultiBoth",
1399        }
1400    }
1401
1402    fn insert(
1403        self,
1404        output_single_feature: bool,
1405        unify_target_host: UnifyTargetHostImpl,
1406        mut insert_cb: impl FnMut(BuildPlatform, &'a ComputedInnerMap<'g>),
1407    ) {
1408        use BuildPlatform::*;
1409
1410        match self {
1411            ValueDescribe::None => {
1412                // Empty, ignore. (This should probably never happen anyway.)
1413            }
1414            ValueDescribe::SingleTarget(target_inner) => {
1415                // Just one way to unify these.
1416                if output_single_feature {
1417                    insert_cb(Target, target_inner);
1418                    if unify_target_host == UnifyTargetHostImpl::ReplicateTargetOnHost {
1419                        insert_cb(Host, target_inner);
1420                    }
1421                }
1422            }
1423            ValueDescribe::SingleHost(host_inner) => {
1424                // Just one way to unify other.
1425                if output_single_feature {
1426                    insert_cb(Host, host_inner);
1427                }
1428            }
1429            ValueDescribe::MultiTarget(target_inner) => {
1430                // Unify features for target.
1431                insert_cb(Target, target_inner);
1432                if unify_target_host == UnifyTargetHostImpl::ReplicateTargetOnHost {
1433                    insert_cb(Host, target_inner);
1434                }
1435            }
1436            ValueDescribe::MultiHost(host_inner) => {
1437                // Unify features for host.
1438                insert_cb(Host, host_inner);
1439            }
1440            ValueDescribe::SingleMatchingBoth {
1441                target_inner,
1442                host_inner,
1443            } => {
1444                // Just one way to unify across both.
1445                if output_single_feature {
1446                    insert_cb(Target, target_inner);
1447                    insert_cb(Host, host_inner);
1448                }
1449            }
1450            ValueDescribe::SingleNonMatchingBoth {
1451                target_inner,
1452                host_inner,
1453            } => {
1454                // Unify features for both across both.
1455                insert_cb(Target, target_inner);
1456                insert_cb(Host, host_inner);
1457                if unify_target_host != UnifyTargetHostImpl::None {
1458                    insert_cb(Target, host_inner);
1459                    insert_cb(Host, target_inner);
1460                }
1461            }
1462            ValueDescribe::MultiTargetSingleHost {
1463                target_inner,
1464                host_inner,
1465            } => {
1466                // Unify features for both across both.
1467                insert_cb(Target, target_inner);
1468                insert_cb(Host, host_inner);
1469                if unify_target_host != UnifyTargetHostImpl::None {
1470                    insert_cb(Target, host_inner);
1471                    insert_cb(Host, target_inner);
1472                }
1473            }
1474            ValueDescribe::MultiHostSingleTarget {
1475                target_inner,
1476                host_inner,
1477            } => {
1478                // Unify features for both across both.
1479                insert_cb(Target, target_inner);
1480                insert_cb(Host, host_inner);
1481                if unify_target_host != UnifyTargetHostImpl::None {
1482                    insert_cb(Target, host_inner);
1483                    insert_cb(Host, target_inner);
1484                }
1485            }
1486            ValueDescribe::MultiBoth {
1487                target_inner,
1488                host_inner,
1489            } => {
1490                // Unify features for both across both.
1491                insert_cb(Target, target_inner);
1492                insert_cb(Host, host_inner);
1493                if unify_target_host != UnifyTargetHostImpl::None {
1494                    insert_cb(Target, host_inner);
1495                    insert_cb(Host, target_inner);
1496                }
1497            }
1498        }
1499    }
1500}
1501
1502#[derive(Debug)]
1503struct OutputMapBuild<'g> {
1504    graph: &'g PackageGraph,
1505    output_map: OutputMap<'g>,
1506}
1507
1508impl<'g> OutputMapBuild<'g> {
1509    fn new(graph: &'g PackageGraph) -> Self {
1510        Self {
1511            graph,
1512            output_map: OutputMap::new(),
1513        }
1514    }
1515
1516    fn is_inserted(&self, output_key: OutputKey, package_id: &'g PackageId) -> bool {
1517        match self.output_map.get(&output_key) {
1518            Some(inner_map) => inner_map.contains_key(package_id),
1519            None => false,
1520        }
1521    }
1522
1523    #[allow(dead_code)]
1524    fn get(
1525        &self,
1526        output_key: OutputKey,
1527        package_id: &'g PackageId,
1528    ) -> Option<&(PackageMetadata<'g>, BTreeSet<&'g str>)> {
1529        match self.output_map.get(&output_key) {
1530            Some(inner_map) => inner_map.get(package_id),
1531            None => None,
1532        }
1533    }
1534
1535    fn insert_all<'a>(
1536        &mut self,
1537        values: impl IntoIterator<Item = (Option<usize>, &'g PackageId, &'a ComputedValue<'g>)>,
1538        output_single_feature: bool,
1539        unify_target_host: UnifyTargetHostImpl,
1540    ) where
1541        'g: 'a,
1542    {
1543        for (platform_idx, dep_id, v) in values {
1544            let describe = v.describe();
1545            describe.insert(
1546                output_single_feature,
1547                unify_target_host,
1548                |build_platform, inner| {
1549                    self.insert_inner(platform_idx, build_platform, dep_id, inner);
1550                },
1551            );
1552        }
1553    }
1554
1555    fn insert_inner(
1556        &mut self,
1557        platform_idx: Option<usize>,
1558        build_platform: BuildPlatform,
1559        package_id: &'g PackageId,
1560        inner: &ComputedInnerMap<'g>,
1561    ) {
1562        let output_key = OutputKey {
1563            platform_idx,
1564            build_platform,
1565        };
1566        self.insert(
1567            output_key,
1568            package_id,
1569            inner.keys().flat_map(|f| f.iter().copied()),
1570        )
1571    }
1572
1573    fn insert(
1574        &mut self,
1575        output_key: OutputKey,
1576        package_id: &'g PackageId,
1577        features: impl IntoIterator<Item = &'g str>,
1578    ) {
1579        let map = self.output_map.entry(output_key).or_default();
1580        let graph = self.graph;
1581        let (_, inner) = map.entry(package_id).or_insert_with(|| {
1582            (
1583                graph.metadata(package_id).expect("valid package ID"),
1584                BTreeSet::new(),
1585            )
1586        });
1587        inner.extend(features);
1588    }
1589
1590    fn iter_feature_sets<'a>(&'a self) -> impl Iterator<Item = (OutputKey, FeatureSet<'g>)> + 'a {
1591        self.output_map.iter().map(move |(&output_key, deps)| {
1592            let feature_ids = deps.iter().flat_map(|(&package_id, (_, features))| {
1593                features
1594                    .iter()
1595                    .map(move |&feature| FeatureId::new(package_id, FeatureLabel::Named(feature)))
1596            });
1597            (
1598                output_key,
1599                self.graph
1600                    .feature_graph()
1601                    .resolve_ids(feature_ids)
1602                    .expect("specified feature IDs are valid"),
1603            )
1604        })
1605    }
1606
1607    fn finish(
1608        mut self,
1609        final_excludes: &HashSet<&'g PackageId>,
1610        dep_format: DepFormatVersion,
1611        output_single_feature: bool,
1612    ) -> OutputMap<'g> {
1613        // Remove all features that are already unified in the "always" set.
1614        for &build_platform in BuildPlatform::VALUES {
1615            let always_key = OutputKey {
1616                platform_idx: None,
1617                build_platform,
1618            };
1619
1620            // Temporarily remove the set to avoid &mut issues.
1621            let mut always_map = match self.output_map.remove(&always_key) {
1622                Some(always_map) => always_map,
1623                None => {
1624                    // No features unified for the always set.
1625                    continue;
1626                }
1627            };
1628
1629            if dep_format >= DepFormatVersion::V3 {
1630                Self::filter_root_features(&mut always_map, output_single_feature);
1631            }
1632
1633            for (key, inner_map) in &mut self.output_map {
1634                // Treat the host and target maps as separate.
1635                if key.build_platform != build_platform {
1636                    continue;
1637                }
1638                if dep_format >= DepFormatVersion::V3 {
1639                    Self::filter_root_features(inner_map, output_single_feature);
1640                }
1641
1642                for (package_id, (_always_package, always_features)) in &always_map {
1643                    let (package, remaining_features) = {
1644                        let (package, features) = match inner_map.get(package_id) {
1645                            Some(v) => v,
1646                            None => {
1647                                // The package ID isn't present in the platform-specific map --
1648                                // nothing to be done.
1649                                continue;
1650                            }
1651                        };
1652                        (*package, features - always_features)
1653                    };
1654                    if remaining_features.is_empty() {
1655                        // No features left.
1656                        inner_map.remove(package_id);
1657                    } else {
1658                        inner_map.insert(package_id, (package, remaining_features));
1659                    }
1660                }
1661            }
1662
1663            // Put always_map back into the output map.
1664            self.output_map.insert(always_key, always_map);
1665        }
1666
1667        // Remove final-excludes, and get rid of any maps that are empty.
1668        self.output_map.retain(|_, inner_map| {
1669            for package_id in final_excludes {
1670                inner_map.remove(package_id);
1671            }
1672            !inner_map.is_empty()
1673        });
1674
1675        self.output_map
1676    }
1677
1678    /// Removes all features from the map that aren't at the root of the provided feature graph.
1679    ///
1680    /// Many crates have a notion of public and private features. Private features are not
1681    /// intended to be used by consumers of the crate, and are only used by the crate itself.
1682    ///
1683    /// As a heuristic, we assume that all root features are public.
1684    ///
1685    /// There aren't any platform-related considerations here, because internal feature dependencies
1686    /// aren't platform-specific.
1687    fn filter_root_features(
1688        inner_map: &mut BTreeMap<&'g PackageId, (PackageMetadata<'g>, BTreeSet<&'g str>)>,
1689        output_single_feature: bool,
1690    ) {
1691        inner_map.retain(|_, (package, features)| {
1692            let feature_set = package.to_feature_set(named_feature_filter(
1693                StandardFeatures::None,
1694                features.iter().copied(),
1695            ));
1696
1697            let root_features: BTreeSet<_> = feature_set
1698                .root_ids(DependencyDirection::Forward)
1699                .filter_map(|f| match f.label() {
1700                    FeatureLabel::Named(name) => Some(name),
1701                    FeatureLabel::Base => None,
1702                    FeatureLabel::OptionalDependency(name) => {
1703                        debug_assert!(
1704                            false,
1705                            "root features must be named or base, found optional dependency {name}",
1706                        );
1707                        None
1708                    }
1709                })
1710                .collect();
1711
1712            if root_features.is_empty() {
1713                // No features left -- remove it from the map if output_single_feature is false. If
1714                // it's true, then we might be tracking a feature set that was originally provided
1715                // as empty to us.
1716                output_single_feature && features.is_empty()
1717            } else {
1718                *features = root_features;
1719                true
1720            }
1721        });
1722    }
1723}
1724
1725#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
1726enum UnifyTargetHostImpl {
1727    None,
1728    UnifyIfBoth,
1729    ReplicateTargetOnHost,
1730}
1731
1732impl UnifyTargetHost {
1733    fn to_impl(self, graph: &PackageGraph) -> UnifyTargetHostImpl {
1734        match self {
1735            UnifyTargetHost::None => UnifyTargetHostImpl::None,
1736            UnifyTargetHost::UnifyIfBoth => UnifyTargetHostImpl::UnifyIfBoth,
1737            UnifyTargetHost::ReplicateTargetOnHost => UnifyTargetHostImpl::ReplicateTargetOnHost,
1738            UnifyTargetHost::Auto => {
1739                let workspace_set = graph.resolve_workspace();
1740                // Is any package a proc macro?
1741                if workspace_set
1742                    .packages(DependencyDirection::Forward)
1743                    .any(|package| package.is_proc_macro())
1744                {
1745                    return UnifyTargetHostImpl::ReplicateTargetOnHost;
1746                }
1747
1748                // Is any package a build dependency of any other?
1749                if workspace_set
1750                    .links(DependencyDirection::Forward)
1751                    .any(|link| link.build().is_present())
1752                {
1753                    return UnifyTargetHostImpl::ReplicateTargetOnHost;
1754                }
1755
1756                UnifyTargetHostImpl::UnifyIfBoth
1757            }
1758        }
1759    }
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764    use super::*;
1765    use crate::UnifyTargetHost;
1766    use fixtures::json::*;
1767
1768    #[test]
1769    fn unify_target_host_auto() {
1770        // Test that this "guppy" fixture (which does not have internal proc macros or build deps)
1771        // turns into "unify if both".
1772        let res = UnifyTargetHost::Auto.to_impl(JsonFixture::metadata_guppy_78cb7e8().graph());
1773        assert_eq!(
1774            res,
1775            UnifyTargetHostImpl::UnifyIfBoth,
1776            "no proc macros => unify if both"
1777        );
1778
1779        // Test that this "libra" fixture (which has internal proc macros) turns into "replicate
1780        // target on host".
1781        let res = UnifyTargetHost::Auto.to_impl(JsonFixture::metadata_libra_9ffd93b().graph());
1782        assert_eq!(
1783            res,
1784            UnifyTargetHostImpl::ReplicateTargetOnHost,
1785            "proc macros => replicate target on host"
1786        );
1787
1788        // Test that the "builddep" fixture (which has an internal build dependency) turns into
1789        // "replicate target on host".
1790        let res = UnifyTargetHost::Auto.to_impl(JsonFixture::metadata_builddep().graph());
1791        assert_eq!(
1792            res,
1793            UnifyTargetHostImpl::ReplicateTargetOnHost,
1794            "internal build deps => replicate target on host"
1795        );
1796    }
1797
1798    #[test]
1799    fn fixpoint_never_adds_workspace_packages() {
1800        // This fixture has third-party-to-member edges:
1801        //
1802        // * hrd-via-member-dev -> hrd-member-dev
1803        // * hrd-via-member-unlinked -> hrd-member-unlinked
1804        // * hrd-via-member-published -> hrd-member-published
1805        //
1806        // so the fixpoint loop reaches workspace members.
1807        let mut builder = reverse_dep_builder();
1808        let graph = builder.graph();
1809        // The fixpoint loop only runs when this is false. (This is the default,
1810        // but let's be explicit anyway.)
1811        builder.set_output_single_feature(false);
1812
1813        let member_dev_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_MEMBER_DEV);
1814        let member_unlinked_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_MEMBER_UNLINKED);
1815        builder
1816            .add_final_excludes([&member_dev_id, &member_unlinked_id])
1817            .expect("final excludes are known to the graph");
1818        let hakari = builder.compute();
1819
1820        // Guard against the test becoming vacuous -- the fixpoint loop iterates
1821        // over the output feature sets, so ensure the corresponding packages
1822        // are present in the output map.
1823        let target_key = OutputKey {
1824            platform_idx: None,
1825            build_platform: BuildPlatform::Target,
1826        };
1827        let output_ids: BTreeSet<&PackageId> =
1828            hakari.output_map[&target_key].keys().copied().collect();
1829        for bridge_id in [
1830            METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_DEV,
1831            METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_UNLINKED,
1832        ] {
1833            assert!(
1834                output_ids.contains(&PackageId::new(bridge_id)),
1835                "{bridge_id} depends on a workspace member and is in the output \
1836                 map, so the fixpoint loop reaches that member"
1837            );
1838        }
1839
1840        for &(platform_idx, package_id) in hakari.computed_map.keys() {
1841            let package = graph.metadata(package_id).expect("package is in the graph");
1842            assert!(
1843                !package.in_workspace(),
1844                "the computed map only has third-party packages, but {} \
1845                 is a workspace member (platform_idx: {platform_idx:?})",
1846                package.name()
1847            );
1848        }
1849
1850        for (output_key, inner_map) in &hakari.output_map {
1851            for (package, _) in inner_map.values() {
1852                assert!(
1853                    !package.in_workspace(),
1854                    "the output map only has third-party packages, but {} \
1855                     is a workspace member (output key: {output_key:?})",
1856                    package.name()
1857                );
1858            }
1859        }
1860    }
1861
1862    #[test]
1863    fn hakari_reverse_deps_not_unified() {
1864        let builder = reverse_dep_builder();
1865        let graph = builder.graph();
1866        let leaf_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_LEAF);
1867        let hakari = builder.compute();
1868
1869        let expected_excludes = expected_structural_excludes();
1870        let structural_excludes: BTreeSet<PackageId> =
1871            hakari.structural_excludes().cloned().collect();
1872        assert_eq!(
1873            structural_excludes, expected_excludes,
1874            "structural excludes are exactly the third-party packages that would form a cycle"
1875        );
1876        for package_id in &expected_excludes {
1877            assert!(
1878                hakari
1879                    .is_structural_excluded(package_id)
1880                    .expect("package ID is known"),
1881                "{package_id} is structurally excluded"
1882            );
1883        }
1884        assert!(
1885            !hakari
1886                .is_structural_excluded(&leaf_id)
1887                .expect("package ID is known"),
1888            "{leaf_id} is not structurally excluded"
1889        );
1890        for package in graph.workspace().iter() {
1891            assert!(
1892                !hakari
1893                    .is_structural_excluded(package.id())
1894                    .expect("package ID is known"),
1895                "workspace package {} is not structurally excluded (workspace packages are \
1896                 handled separately)",
1897                package.name()
1898            );
1899        }
1900        assert!(
1901            hakari
1902                .is_structural_excluded(&PackageId::new("unknown-package 0.1.0"))
1903                .is_err(),
1904            "unknown package IDs are an error"
1905        );
1906
1907        let computed_ids: BTreeSet<&PackageId> = hakari
1908            .computed_map
1909            .keys()
1910            .map(|(_, package_id)| *package_id)
1911            .collect();
1912        for package_id in &expected_excludes {
1913            assert!(
1914                !computed_ids.contains(package_id),
1915                "{package_id} is structurally excluded so it is never computed: {computed_ids:?}"
1916            );
1917        }
1918        for package in graph.workspace().iter() {
1919            assert!(
1920                !computed_ids.contains(package.id()),
1921                "workspace package {} is never computed",
1922                package.name()
1923            );
1924        }
1925
1926        let target_key = OutputKey {
1927            platform_idx: None,
1928            build_platform: BuildPlatform::Target,
1929        };
1930        let host_key = OutputKey {
1931            platform_idx: None,
1932            build_platform: BuildPlatform::Host,
1933        };
1934        let output_keys: BTreeSet<OutputKey> = hakari.output_map.keys().copied().collect();
1935        assert_eq!(
1936            output_keys,
1937            [target_key, host_key].into_iter().collect(),
1938            "hrd-member-build's build-dependency on the hakari package makes \
1939             UnifyTargetHost::Auto resolve to replicate-target-on-host, so leaf's target \
1940             entry is replicated under the always/host key alongside always/target"
1941        );
1942        for key in [target_key, host_key] {
1943            let map = &hakari.output_map[&key];
1944            let output_ids: BTreeSet<&PackageId> = map.keys().copied().collect();
1945            let expected_ids: BTreeSet<&PackageId> = [&leaf_id].into_iter().collect();
1946            assert_eq!(
1947                output_ids, expected_ids,
1948                "[{key:?}] only leaf is unified: its features differ across workspace \
1949                 members, and every other third-party package would form a cycle"
1950            );
1951            assert_eq!(
1952                map[&leaf_id].1,
1953                ["feat1", "feat2"].into_iter().collect::<BTreeSet<_>>(),
1954                "[{key:?}] leaf features unified"
1955            );
1956        }
1957
1958        // The fixture's workspace-hack is up-to-date, so verify should pass.
1959        if let Err(errs) = hakari.builder().clone().verify() {
1960            panic!("verify failed for packages: {:?}", errs.dependency_ids);
1961        }
1962    }
1963
1964    #[test]
1965    fn unmanaged_members_are_not_cycle_roots() {
1966        let member_dev_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_MEMBER_DEV);
1967        let member_unlinked_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_MEMBER_UNLINKED);
1968
1969        let mut final_excluded = reverse_dep_builder();
1970        final_excluded
1971            .add_final_excludes([&member_dev_id, &member_unlinked_id])
1972            .expect("final excludes are known to the graph");
1973        assert_unmanaged_members_unified(final_excluded, "final-excluded");
1974
1975        let mut traversal_excluded = reverse_dep_builder();
1976        traversal_excluded
1977            .add_traversal_excludes([&member_dev_id, &member_unlinked_id])
1978            .expect("traversal excludes are known to the graph");
1979        assert_unmanaged_members_unified(traversal_excluded, "traversal-excluded");
1980    }
1981
1982    fn assert_unmanaged_members_unified(builder: HakariBuilder<'_>, scenario: &str) {
1983        let via_member_published_id =
1984            PackageId::new(METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_PUBLISHED);
1985        let via_member_dev_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_DEV);
1986        let via_member_unlinked_id =
1987            PackageId::new(METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_UNLINKED);
1988        let leaf_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_LEAF);
1989        let hakari = builder.compute();
1990
1991        // member-dev is no longer managed, and its only link to the hakari
1992        // package is a dev-dependency; member-unlinked is no longer managed
1993        // and has no link to the hakari package at all.
1994        for package_id in [&via_member_dev_id, &via_member_unlinked_id] {
1995            assert!(
1996                !hakari
1997                    .is_structural_excluded(package_id)
1998                    .expect("package ID is known"),
1999                "[{scenario}] {package_id} only reaches unmanaged members, so it is \
2000                 not structurally excluded"
2001            );
2002        }
2003        // member-published is still managed, so via-member-published would
2004        // still form a cycle.
2005        assert!(
2006            hakari
2007                .is_structural_excluded(&via_member_published_id)
2008                .expect("package ID is known"),
2009            "[{scenario}] {via_member_published_id} reaches managed member hrd-member-published, so it \
2010             is structurally excluded"
2011        );
2012
2013        let target_key = OutputKey {
2014            platform_idx: None,
2015            build_platform: BuildPlatform::Target,
2016        };
2017        let target_map = &hakari.output_map[&target_key];
2018        let output_ids: BTreeSet<&PackageId> = target_map.keys().copied().collect();
2019        let expected_ids: BTreeSet<&PackageId> =
2020            [&leaf_id, &via_member_dev_id, &via_member_unlinked_id]
2021                .into_iter()
2022                .collect();
2023        assert_eq!(
2024            output_ids, expected_ids,
2025            "[{scenario}] via-member-dev and via-member-unlinked are unified now that they can't \
2026             form a cycle"
2027        );
2028    }
2029
2030    #[test]
2031    fn hakari_reverse_deps_verify_failure() {
2032        let fixture = JsonFixture::metadata_hakari_reverse_dep();
2033        let hakari_id = fixture
2034            .details()
2035            .hakari_package()
2036            .expect("fixture declares a hakari package");
2037
2038        // Make the fixture's workspace-hack stale by dropping the features it
2039        // requests from hrd-leaf. This will make verify fail for hrd-leaf.
2040        let leaf_dep_unified = r#""name":"hrd-leaf","source":null,"req":"*","kind":null,"rename":null,"optional":false,"uses_default_features":true,"features":["feat1","feat2"]"#;
2041        let leaf_dep_stale = leaf_dep_unified.replace(r#"["feat1","feat2"]"#, "[]");
2042        let json = fixture.json();
2043        assert_eq!(
2044            json.matches(leaf_dep_unified).count(),
2045            1,
2046            "hakari package's dependency on hrd-leaf occurs exactly once"
2047        );
2048        let stale_json = json.replace(leaf_dep_unified, &leaf_dep_stale);
2049        let graph = PackageGraph::from_json(stale_json).expect("stale fixture parsed");
2050
2051        let builder = HakariBuilder::new(&graph, Some(hakari_id)).expect("builder created");
2052        let structural_excludes: BTreeSet<PackageId> = builder
2053            .clone()
2054            .compute()
2055            .structural_excludes()
2056            .cloned()
2057            .collect();
2058        let errs = builder
2059            .verify()
2060            .expect_err("stale hakari package fails verification");
2061
2062        let leaf_id = PackageId::new(METADATA_HAKARI_REVERSE_DEP_LEAF);
2063        assert_eq!(
2064            errs.dependency_ids,
2065            [&leaf_id].into_iter().collect::<BTreeSet<_>>(),
2066            "only hrd-leaf is reported: the structurally excluded packages are \
2067             built with more than one feature set too, but verify skips them"
2068        );
2069        assert_eq!(
2070            structural_excludes,
2071            expected_structural_excludes(),
2072            "the cycle-forming packages really are structurally excluded here, so the check \
2073             above isn't tautological"
2074        );
2075    }
2076
2077    // The third-party packages in the metadata_hakari_reverse_dep fixture that
2078    // reach the hakari package or a managed member. See the fixture's
2079    // definition for the graph and why each one counts.
2080    fn expected_structural_excludes() -> BTreeSet<PackageId> {
2081        [
2082            METADATA_HAKARI_REVERSE_DEP_NORMAL_ON_HACK,
2083            METADATA_HAKARI_REVERSE_DEP_VIA_NORMAL_ON_HACK,
2084            METADATA_HAKARI_REVERSE_DEP_BUILD_ON_HACK,
2085            METADATA_HAKARI_REVERSE_DEP_CFG_ON_HACK,
2086            METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_PUBLISHED,
2087            METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_DEV,
2088            METADATA_HAKARI_REVERSE_DEP_VIA_MEMBER_UNLINKED,
2089        ]
2090        .into_iter()
2091        .map(PackageId::new)
2092        .collect()
2093    }
2094
2095    fn reverse_dep_builder() -> HakariBuilder<'static> {
2096        let fixture = JsonFixture::metadata_hakari_reverse_dep();
2097        let hakari_id = fixture
2098            .details()
2099            .hakari_package()
2100            .expect("hakari-reverse-dep fixture names a hakari package");
2101        HakariBuilder::new(fixture.graph(), Some(hakari_id)).expect("hakari builder is created")
2102    }
2103}