Skip to main content

hakari/
proptest_helpers.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    HakariBuilder, UnifyTargetHost,
6    hakari::{DepFormatVersion, WorkspaceHackLineStyle},
7};
8use guppy::{
9    PackageId,
10    graph::{PackageGraph, cargo::CargoResolverVersion},
11    platform::{Platform, TargetFeatures},
12};
13use proptest::{
14    collection::{hash_set, vec},
15    prelude::*,
16};
17
18/// ## Helpers for property testing
19///
20/// The methods in this section allow random instances of a `HakariBuilder` to be generated, for use
21/// in property-based testing scenarios.
22///
23/// Requires the `proptest1` feature to be enabled.
24impl<'g> HakariBuilder<'g> {
25    /// Returns a `Strategy` that generates random `HakariBuilder` instances based on this graph.
26    ///
27    /// Requires the `proptest1` feature to be enabled.
28    ///
29    /// ## Panics
30    ///
31    /// Panics if:
32    /// * there are no packages in this `PackageGraph`, or
33    /// * `hakari_id` is specified but it isn't known to the graph, or isn't in the workspace.
34    pub fn proptest1_strategy(
35        graph: &'g PackageGraph,
36        hakari_id_strategy: impl Strategy<Value = Option<&'g PackageId>> + 'g,
37    ) -> impl Strategy<Value = HakariBuilder<'g>> + 'g {
38        (
39            hakari_id_strategy,
40            vec(Platform::strategy(any::<TargetFeatures>()), 0..4),
41            any::<CargoResolverVersion>(),
42            hash_set(graph.proptest1_id_strategy(), 0..8),
43            hash_set(graph.proptest1_id_strategy(), 0..8),
44            any::<UnifyTargetHost>(),
45            any::<bool>(),
46            any::<DepFormatVersion>(),
47            any::<WorkspaceHackLineStyle>(),
48        )
49            .prop_map(
50                move |(
51                    hakari_id,
52                    platforms,
53                    version,
54                    traversal_excludes,
55                    final_excludes,
56                    unify_target_host,
57                    output_single_feature,
58                    dep_format_version,
59                    line_style,
60                )| {
61                    let mut builder = HakariBuilder::new(graph, hakari_id)
62                        .expect("HakariBuilder::new returned an error");
63                    let platforms: Vec<_> = platforms
64                        .iter()
65                        .map(|platform| platform.triple_str().to_owned())
66                        .collect();
67                    builder
68                        .set_platforms(platforms)
69                        .expect("all platforms are known")
70                        .set_resolver(version)
71                        .add_traversal_excludes(traversal_excludes)
72                        .expect("traversal excludes obtained from PackageGraph should work")
73                        .add_final_excludes(final_excludes)
74                        .expect("final excludes obtained from PackageGraph should work")
75                        .set_unify_target_host(unify_target_host)
76                        .set_dep_format_version(dep_format_version)
77                        .set_workspace_hack_line_style(line_style)
78                        .set_output_single_feature(output_single_feature);
79                    builder
80                },
81            )
82    }
83}
84
85#[cfg(all(test, feature = "cli-support"))]
86mod test {
87    use super::*;
88    use fixtures::json::JsonFixture;
89    use guppy::graph::{DependencyDirection, PackageMetadata};
90    use proptest::option;
91    use std::collections::HashSet;
92
93    /// Ensure that HakariBuilder roundtrips to its summary format.
94    #[test]
95    fn builder_summary_roundtrip() {
96        for (&name, fixture) in JsonFixture::all_fixtures() {
97            let graph = fixture.graph();
98            let workspace = graph.workspace();
99            let strategy = HakariBuilder::proptest1_strategy(
100                graph,
101                option::of(workspace.proptest1_id_strategy()),
102            );
103            proptest!(|(builder in strategy)| {
104                let summary = builder.to_summary().unwrap_or_else(|err| {
105                    panic!("for fixture {name}, builder -> summary conversion failed: {err}");
106                });
107                let builder2 = summary.to_hakari_builder(graph).unwrap_or_else(|err| {
108                    panic!("for fixture {name}, summary -> builder conversion failed: {err}");
109                });
110                let summary2 = builder2.to_summary().unwrap_or_else(|err| {
111                    panic!("for fixture {name}, second builder -> summary conversion failed: {err}");
112                });
113                assert_eq!(summary, summary2, "summary roundtripped correctly");
114            });
115        }
116    }
117
118    /// Ensure that HakariBuilder's traversal_excludes and is_traversal_excluded match up.
119    #[test]
120    fn traversal_excludes() {
121        for (&name, fixture) in JsonFixture::all_fixtures() {
122            let graph = fixture.graph();
123            let workspace = graph.workspace();
124            let strategy = HakariBuilder::proptest1_strategy(
125                graph,
126                option::of(workspace.proptest1_id_strategy()),
127            );
128            proptest!(|(builder in strategy, queries in vec(graph.proptest1_id_strategy(), 0..64))| {
129                // Ensure that the hakari package is omitted.
130                if let Some(package) = builder.hakari_package() {
131                    assert!(
132                        builder.is_traversal_excluded(package.id()).expect("valid package ID"),
133                        "for fixture {name}, hakari package is excluded from traversals",
134                    );
135                }
136                // Ensure that omits_package and omitted_packages match.
137                let traversal_excludes: HashSet<_> = builder.traversal_excludes().collect();
138                for query_id in queries {
139                    assert_eq!(
140                        traversal_excludes.contains(query_id),
141                        builder.is_traversal_excluded(query_id).expect("valid package ID"),
142                        "for fixture {name}, traversal_excludes and is_traversal_excluded match",
143                    );
144                }
145            });
146        }
147    }
148
149    /// Ensure that the structural excludes are exactly the third-party
150    /// packages that reach the hakari package or a managed member.
151    #[test]
152    fn structural_excludes() {
153        for (&name, fixture) in JsonFixture::all_fixtures() {
154            let graph = fixture.graph();
155            let workspace = graph.workspace();
156            let strategy = HakariBuilder::proptest1_strategy(
157                graph,
158                option::of(workspace.proptest1_id_strategy()),
159            );
160            proptest!(|(builder in strategy)| {
161                // This checks the reverse query itself rather than going
162                // through compute(), since the latter is quite slow especially
163                // in debug mode.
164                let structural_excludes = builder.make_structural_excludes().cycle_forming;
165
166                let is_root = |package: &PackageMetadata<'_>| {
167                    builder
168                        .hakari_package()
169                        .is_some_and(|hakari_package| hakari_package.id() == package.id())
170                        || builder.is_managed_member(package)
171                };
172
173                let reaches_root = |package: &PackageMetadata<'_>| {
174                    package
175                        .to_package_query(DependencyDirection::Forward)
176                        .resolve_with_fn(|_, link| !link.dev_only())
177                        .packages(DependencyDirection::Forward)
178                        .any(|dep| is_root(&dep))
179                };
180
181                // Soundness -- every ID in the set is a third-party package
182                // that really does reach a root.
183                for package_id in &structural_excludes {
184                    let package = graph.metadata(package_id).expect("valid package ID");
185                    assert!(
186                        !package.in_workspace(),
187                        "for fixture {name}, structurally excluded {} is a third-party package",
188                        package.name(),
189                    );
190                    assert!(
191                        reaches_root(&package),
192                        "for fixture {name}, structurally excluded {} reaches the hakari package \
193                         or a managed member",
194                        package.name(),
195                    );
196                }
197
198                // Completeness -- non-dev links are acyclic, so it is enough to
199                // check that no third-party package outside the set has a
200                // non-dev link to a root or to a member of the set. Workspace
201                // packages are never in the set, so links into the workspace
202                // are checked with a forward query instead; dedupe them since
203                // that query is the expensive part.
204                let mut workspace_targets = HashSet::new();
205                for package in graph.packages() {
206                    if package.in_workspace() || structural_excludes.contains(package.id()) {
207                        continue;
208                    }
209                    for link in package.direct_links() {
210                        if link.dev_only() {
211                            continue;
212                        }
213                        let to = link.to();
214                        assert!(
215                            !is_root(&to),
216                            "for fixture {name}, {} isn't structurally excluded, so it has no \
217                             non-dev link to root {}",
218                            package.name(),
219                            to.name(),
220                        );
221                        assert!(
222                            !structural_excludes.contains(to.id()),
223                            "for fixture {name}, {} isn't structurally excluded, so it has no \
224                             non-dev link to structurally excluded {}",
225                            package.name(),
226                            to.name(),
227                        );
228                        if to.in_workspace() {
229                            workspace_targets.insert(to.id());
230                        }
231                    }
232                }
233                for package_id in workspace_targets {
234                    let package = graph.metadata(package_id).expect("valid package ID");
235                    assert!(
236                        !reaches_root(&package),
237                        "for fixture {name}, workspace package {} is reached by a \
238                         third-party package that isn't structurally excluded, so it must not \
239                         reach a root itself",
240                        package.name(),
241                    );
242                }
243            });
244        }
245    }
246}