Skip to main content

hakari/cli_ops/
manage_deps.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Add and remove dependencies.
5
6use crate::{
7    HakariBuilder, WorkspaceHackLineStyle,
8    cli_ops::{WorkspaceOp, WorkspaceOps},
9    hakari::DepFormatVersion,
10};
11use guppy::{
12    VersionReq,
13    graph::{DependencyDirection, PackageLink, PackageMetadata, PackageSet},
14    platform::{EnabledTernary, PlatformSpec},
15};
16
17impl<'g> HakariBuilder<'g> {
18    /// Returns the set of operations that need to be performed to add the workspace-hack
19    /// dependency to the given set of workspace crates.
20    ///
21    /// Also includes remove operations for the workspace-hack dependency from excluded crates.
22    ///
23    /// A workspace crate is *managed* if it isn't the hakari package itself and isn't
24    /// [excluded](Self::is_excluded). For each crate in `workspace_set`, the operation depends
25    /// on its existing dependency on the hakari package:
26    ///
27    /// | Crate    | Existing dependency on the hakari package | Operation |
28    /// |----------|-------------------------------------------|-----------|
29    /// | managed  | none | add to `[dependencies]` |
30    /// | managed  | only `[dev-dependencies]` and/or `[build-dependencies]`, top-level or under `[target.*]` | add to `[dependencies]`, and remove the `[dev-dependencies]` line |
31    /// | managed  | unconditional `[dependencies]` | keep; update the line if needed (see below) |
32    /// | managed  | only under `[target.*]`, or `optional` | keep as is |
33    /// | excluded | any | remove from every section, including `[target.*]` |
34    /// | excluded | none | nothing |
35    ///
36    /// A line needs updating (with [`DepFormatVersion::V2`] or later) if its
37    /// version requirement doesn't match the hakari package's version, or if it
38    /// has no version requirement and the [`WorkspaceHackLineStyle`] isn't
39    /// `WorkspaceDotted`.
40    ///
41    /// Platform-specific lines under `[target.*]` and optional lines count as
42    /// present and are never rewritten, so crates that deliberately gate the
43    /// hakari package behind a `cfg` are left alone. A `[build-dependencies]`
44    /// line is retained when adding dependency lines, since it pulls the hakari
45    /// package into the host build on purpose.
46    ///
47    /// Packages outside the workspace are ignored.
48    ///
49    /// Returns `None` if the hakari package wasn't specified at construction time.
50    ///
51    /// Requires the `cli-support` feature to be enabled.
52    pub fn manage_dep_ops(&self, workspace_set: &PackageSet<'g>) -> Option<WorkspaceOps<'g, '_>> {
53        let graph = self.graph();
54        let hakari_package = self.hakari_package()?;
55
56        let (add_to, remove_from) =
57            workspace_set.filter_partition(DependencyDirection::Reverse, |package| {
58                // manage-deps only rewrites manifests inside the workspace.
59                // Ignore anything else.
60                if !package.in_workspace() {
61                    return None;
62                }
63                let link_opt = package
64                    .link_to(hakari_package.id())
65                    .expect("valid package ID");
66                let should_be_included = self.is_managed_member(&package);
67                match (link_opt, should_be_included) {
68                    (None, true) => Some(true),
69                    (Some(_), false) => Some(false),
70                    (Some(link), true) => {
71                        // A dev-only or build-only link doesn't unify features
72                        // for regular builds, so treat it the same as no link
73                        // at all. (Do this regardless of dep format version.)
74                        if !link.normal().is_present() {
75                            return Some(true);
76                        }
77                        // A platform-specific or optional line is deliberate,
78                        // and the add operation only writes to the top-level
79                        // [dependencies] table, so "updating" it would add a
80                        // duplicate dependency line next to it. Leave such
81                        // lines alone.
82                        if link.normal().status().required_on(&PlatformSpec::Always)
83                            != EnabledTernary::Enabled
84                        {
85                            return None;
86                        }
87                        match self.dep_format_version {
88                            DepFormatVersion::V1 => None,
89                            DepFormatVersion::V2 | DepFormatVersion::V3 | DepFormatVersion::V4 => {
90                                needs_update_v2(
91                                    hakari_package,
92                                    link,
93                                    self.workspace_hack_line_style,
94                                )
95                                .then_some(true)
96                            }
97                        }
98                    }
99                    (None, false) => None,
100                }
101            });
102
103        let mut ops = Vec::with_capacity(2);
104        if !add_to.is_empty() {
105            ops.push(WorkspaceOp::AddDependency {
106                name: hakari_package.name(),
107                crate_path: hakari_package
108                    .source()
109                    .workspace_path()
110                    .expect("hakari package is in workspace"),
111                version: hakari_package.version(),
112                dep_format: self.dep_format_version,
113                line_style: self.workspace_hack_line_style,
114                add_to,
115            });
116        }
117        if !remove_from.is_empty() {
118            ops.push(WorkspaceOp::RemoveDependency {
119                name: hakari_package.name(),
120                remove_from,
121            });
122        }
123        Some(WorkspaceOps::new(graph, ops))
124    }
125
126    /// Returns the set of operations that need to be performed to add the workspace-hack
127    /// dependency to the given set of workspace crates.
128    ///
129    /// Returns `None` if the hakari package wasn't specified at construction time.
130    ///
131    /// Requires the `cli-support` feature to be enabled.
132    pub fn add_dep_ops(
133        &self,
134        workspace_set: &PackageSet<'g>,
135        force: bool,
136    ) -> Option<WorkspaceOps<'g, '_>> {
137        let graph = self.graph();
138        let hakari_package = self.hakari_package()?;
139
140        let add_to = if force {
141            workspace_set.clone()
142        } else {
143            workspace_set.filter(DependencyDirection::Reverse, |package| {
144                let link_opt = package
145                    .link_to(hakari_package.id())
146                    .expect("valid package ID");
147                match link_opt {
148                    Some(link) => {
149                        needs_update_v2(hakari_package, link, self.workspace_hack_line_style)
150                    }
151                    None => true,
152                }
153            })
154        };
155
156        let op = if !add_to.is_empty() {
157            Some(WorkspaceOp::AddDependency {
158                name: hakari_package.name(),
159                version: hakari_package.version(),
160                crate_path: hakari_package
161                    .source()
162                    .workspace_path()
163                    .expect("hakari package is in workspace"),
164                dep_format: self.dep_format_version,
165                line_style: self.workspace_hack_line_style,
166                add_to,
167            })
168        } else {
169            None
170        };
171        Some(WorkspaceOps::new(graph, op))
172    }
173
174    /// Returns the set of operations that need to be performed to remove the workspace-hack
175    /// dependency from the given set of workspace crates.
176    ///
177    /// Returns `None` if the hakari package wasn't specified at construction time.
178    ///
179    /// Requires the `cli-support` feature to be enabled.
180    pub fn remove_dep_ops(
181        &self,
182        workspace_set: &PackageSet<'g>,
183        force: bool,
184    ) -> Option<WorkspaceOps<'g, '_>> {
185        let graph = self.graph();
186        let hakari_package = self.hakari_package()?;
187
188        let remove_from = if force {
189            workspace_set.clone()
190        } else {
191            workspace_set.filter(DependencyDirection::Reverse, |package| {
192                graph
193                    .directly_depends_on(package.id(), hakari_package.id())
194                    .expect("valid package ID")
195            })
196        };
197
198        let op = if !remove_from.is_empty() {
199            Some(WorkspaceOp::RemoveDependency {
200                name: hakari_package.name(),
201                remove_from,
202            })
203        } else {
204            None
205        };
206        Some(WorkspaceOps::new(graph, op))
207    }
208}
209
210#[allow(clippy::if_same_then_else, clippy::needless_bool)]
211fn needs_update_v2(
212    hakari_package: &PackageMetadata<'_>,
213    link: PackageLink<'_>,
214    line_style: WorkspaceHackLineStyle,
215) -> bool {
216    if !link.version_req().matches(hakari_package.version()) {
217        // The version number doesn't match: it must be updated.
218        true
219    } else if link.version_req() == &VersionReq::STAR {
220        // The version number isn't specified. Require it in case line_style isn't workspace-dotted.
221        match line_style {
222            WorkspaceHackLineStyle::Full | WorkspaceHackLineStyle::VersionOnly => true,
223            WorkspaceHackLineStyle::WorkspaceDotted => false,
224        }
225    } else {
226        false
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::cli_ops::WorkspaceOp;
234    use fixtures::{
235        json::{
236            JsonFixture, METADATA_HAKARI_REVERSE_DEP_MEMBER_BUILD,
237            METADATA_HAKARI_REVERSE_DEP_MEMBER_CFG, METADATA_HAKARI_REVERSE_DEP_MEMBER_DEV,
238            METADATA_HAKARI_REVERSE_DEP_MEMBER_NORMAL,
239            METADATA_HAKARI_REVERSE_DEP_MEMBER_PUBLISHED,
240            METADATA_HAKARI_REVERSE_DEP_MEMBER_UNLINKED,
241            METADATA_HAKARI_REVERSE_DEP_NORMAL_ON_HACK,
242        },
243        package_id,
244    };
245    use guppy::PackageId;
246    use std::collections::BTreeSet;
247
248    #[test]
249    fn manage_dep_ops_skips_non_workspace_packages() {
250        let fixture = JsonFixture::metadata_hakari_reverse_dep();
251        let graph = fixture.graph();
252        let hakari_id = fixture
253            .details()
254            .hakari_package()
255            .expect("hakari-reverse-dep fixture names a hakari package");
256        let mut builder =
257            HakariBuilder::new(graph, Some(hakari_id)).expect("hakari builder is created");
258        // V1 doesn't rewrite existing dependency lines, so set the format
259        // version to v4 to also exercise the "already depends on the hack, but
260        // needs an update" path.
261        builder.set_dep_format_version(DepFormatVersion::V4);
262
263        // * hrd-member-unlinked is a workspace member without a dependency on
264        //   the hakari package.
265        // * hrd-member-published is a workspace member that depends on the
266        //   hakari package with `req = "*"`.
267        // * hrd-member-dev is a workspace member whose only dependency on the
268        //   hakari package is dev-only.
269        // * hrd-normal-on-hack is a non-workspace package that depends on the
270        //   hakari package.
271        let member_unlinked_id = package_id(METADATA_HAKARI_REVERSE_DEP_MEMBER_UNLINKED);
272        let member_published_id = package_id(METADATA_HAKARI_REVERSE_DEP_MEMBER_PUBLISHED);
273        let member_dev_id = package_id(METADATA_HAKARI_REVERSE_DEP_MEMBER_DEV);
274        let normal_on_hack_id = package_id(METADATA_HAKARI_REVERSE_DEP_NORMAL_ON_HACK);
275        let package_set = graph
276            .resolve_ids([
277                &member_unlinked_id,
278                &member_published_id,
279                &member_dev_id,
280                &normal_on_hack_id,
281            ])
282            .expect("all package IDs are known to the graph");
283
284        let ops = builder
285            .manage_dep_ops(&package_set)
286            .expect("hakari package was specified, so ops are returned");
287        let mut add_to = None;
288        for op in ops.ops() {
289            match op {
290                WorkspaceOp::AddDependency { add_to: set, .. } => {
291                    assert!(add_to.is_none(), "at most one add op is generated");
292                    add_to = Some(set);
293                }
294                WorkspaceOp::RemoveDependency { remove_from, .. } => {
295                    let remove_ids: Vec<_> = remove_from
296                        .package_ids(DependencyDirection::Forward)
297                        .collect();
298                    panic!(
299                        "hrd-normal-on-hack is outside the workspace and the other two \
300                         packages are managed members, so nothing in the set \
301                         should have the hack removed, but got a remove op for \
302                         {remove_ids:?}"
303                    );
304                }
305                WorkspaceOp::NewCrate { .. } => {
306                    panic!("manage-deps never creates crates");
307                }
308            }
309        }
310
311        let add_to = add_to.expect("an add op is generated");
312        let add_ids: BTreeSet<_> = add_to.package_ids(DependencyDirection::Forward).collect();
313        let expected_ids: BTreeSet<_> = [&member_unlinked_id, &member_published_id, &member_dev_id]
314            .into_iter()
315            .collect();
316        assert_eq!(
317            add_ids, expected_ids,
318            "hrd-member-unlinked has no dependency on the hack, hrd-member-published's \
319             `req = \"*\"` needs updating under dep format V4, and \
320             hrd-member-dev's only dependency on the hack is dev-only, so all \
321             three are added to; hrd-normal-on-hack is outside the workspace, so it \
322             is ignored"
323        );
324    }
325
326    // A platform-specific line with no version requirement should not be
327    // updated.
328    #[test]
329    fn manage_dep_ops_never_updates_platform_specific_lines() {
330        let fixture = JsonFixture::metadata_hakari_reverse_dep();
331        let graph = fixture.graph();
332        let hakari_id = fixture
333            .details()
334            .hakari_package()
335            .expect("hakari-reverse-dep fixture names a hakari package");
336        let mut builder =
337            HakariBuilder::new(graph, Some(hakari_id)).expect("hakari builder is created");
338        builder.set_dep_format_version(DepFormatVersion::V4);
339        assert_eq!(
340            builder.workspace_hack_line_style(),
341            WorkspaceHackLineStyle::Full,
342            "the full line style requires a version, so a `*` requirement needs updating"
343        );
344
345        // Both members depend on the hakari package with `req = "*"`, but
346        // hrd-member-cfg's line is cfg(windows)-only.
347        let member_published_id = package_id(METADATA_HAKARI_REVERSE_DEP_MEMBER_PUBLISHED);
348        let member_cfg_id = package_id(METADATA_HAKARI_REVERSE_DEP_MEMBER_CFG);
349        let package_set = graph
350            .resolve_ids([&member_published_id, &member_cfg_id])
351            .expect("all package IDs are known to the graph");
352
353        let ops = builder
354            .manage_dep_ops(&package_set)
355            .expect("hakari package was specified, so ops are returned");
356        let mut add_ids: BTreeSet<PackageId> = BTreeSet::new();
357        for op in ops.ops() {
358            match op {
359                WorkspaceOp::AddDependency { add_to, .. } => {
360                    add_ids.extend(add_to.package_ids(DependencyDirection::Forward).cloned());
361                }
362                WorkspaceOp::RemoveDependency { .. } => {
363                    panic!("both members are managed, so nothing should be removed");
364                }
365                WorkspaceOp::NewCrate { .. } => {
366                    panic!("manage-deps never creates crates");
367                }
368            }
369        }
370        assert_eq!(
371            add_ids,
372            [member_published_id].into_iter().collect(),
373            "hrd-member-published's unconditional `*` line is updated; hrd-member-cfg's \
374             cfg(windows)-only line is left alone"
375        );
376    }
377
378    #[test]
379    fn manage_dep_ops_requires_normal_dep() {
380        let fixture = JsonFixture::metadata_hakari_reverse_dep();
381        let graph = fixture.graph();
382        let hakari_id = fixture
383            .details()
384            .hakari_package()
385            .expect("hakari-reverse-dep fixture names a hakari package");
386        let builder =
387            HakariBuilder::new(graph, Some(hakari_id)).expect("hakari builder is created");
388        // V1 never rewrites an existing dependency line, so the shape of the
389        // link is the only reason an add op could be generated here.
390        assert_eq!(
391            builder.dep_format_version(),
392            DepFormatVersion::V1,
393            "dep format version defaults to V1"
394        );
395
396        // (member, its link to the hakari package, whether an add op is expected)
397        //
398        // Note that platform-specific dependencies still count as managed,
399        // since some workspaces deliberately gate the hakari package behind a
400        // `cfg`.
401        let cases = [
402            (METADATA_HAKARI_REVERSE_DEP_MEMBER_DEV, "dev-only", true),
403            (METADATA_HAKARI_REVERSE_DEP_MEMBER_BUILD, "build-only", true),
404            (
405                METADATA_HAKARI_REVERSE_DEP_MEMBER_CFG,
406                "cfg(windows)-only",
407                false,
408            ),
409            (
410                METADATA_HAKARI_REVERSE_DEP_MEMBER_NORMAL,
411                "unconditional normal",
412                false,
413            ),
414        ];
415        let ids: Vec<PackageId> = cases.iter().map(|(id, _, _)| package_id(*id)).collect();
416        let package_set = graph
417            .resolve_ids(&ids)
418            .expect("all package IDs are known to the graph");
419
420        let ops = builder
421            .manage_dep_ops(&package_set)
422            .expect("hakari package was specified, so ops are returned");
423        let mut add_ids: BTreeSet<PackageId> = BTreeSet::new();
424        for op in ops.ops() {
425            match op {
426                WorkspaceOp::AddDependency { add_to, .. } => {
427                    add_ids.extend(add_to.package_ids(DependencyDirection::Forward).cloned());
428                }
429                WorkspaceOp::RemoveDependency { remove_from, .. } => {
430                    let remove_ids: Vec<_> = remove_from
431                        .package_ids(DependencyDirection::Forward)
432                        .collect();
433                    panic!(
434                        "all of these members are managed, so nothing should have \
435                         the hack removed, but got a remove op for {remove_ids:?}"
436                    );
437                }
438                WorkspaceOp::NewCrate { .. } => {
439                    panic!("manage-deps never creates crates");
440                }
441            }
442        }
443
444        for (id, description, expect_add) in cases {
445            let id = package_id(id);
446            assert_eq!(
447                add_ids.contains(&id),
448                expect_add,
449                "{id} has a {description} link on the hakari package, so an add op is \
450                 {}generated",
451                if expect_add { "" } else { "not " }
452            );
453        }
454    }
455}