Skip to main content

stern4rust/rules/manifest/
workspace_dependencies_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use crate::finding::model::manifest_dependency::ManifestDependency;
6use crate::reporting::offence::Offence;
7use crate::reporting::rule_explanation::RuleExplanation;
8use crate::rule::Rule;
9use crate::source_file::SourceFile;
10
11// A workspace declares its dependencies once, in the root, and every member
12// takes them from there.
13//
14// Three requirements were asked for: the root holds every reference, each member
15// uses `.workspace`, and no member declares one of its own. **They are the same
16// requirement**, and only the middle one needs code.
17//
18// A member that writes `foo = { workspace = true }` for a `foo` the root does
19// not declare does not compile -- `cargo` rejects it outright. So requiring the
20// root to hold every reference costs nothing here, and "no new dependencies in a
21// member" is what a member using nothing but `.workspace` already means. One
22// syntactic check delivers all three, which is the split
23// [R009], [R014] and [R016] each found before it.
24//
25// The declaration is read from the TOML rather than from `cargo metadata`,
26// because the question is *how it was written*. Resolution erases exactly that
27// distinction: a dependency taken from the workspace and one spelled out in the
28// member are identical once resolved.
29//
30// A package that is not a workspace has no root to centralise into, so the rule
31// says nothing rather than reporting something wrong -- the same silence
32// `tests-layout` keeps about a package with no tests tree.
33pub struct WorkspaceDependenciesRule {
34    declared: Option<Vec<ManifestDependency>>,
35}
36
37impl WorkspaceDependenciesRule {
38    pub fn new(declared: Option<Vec<ManifestDependency>>) -> Self {
39        Self { declared }
40    }
41
42    // Reported against the member manifest at line 1: the edit is one line in
43    // that file, plus one in the root.
44    fn offence(&self, dependency: &ManifestDependency) -> Offence {
45        let name = &dependency.name;
46        Offence::new(
47            &dependency.manifest,
48            1,
49            self.name(),
50            format!(
51                "{} declares `{name}` in [{}] rather than taking it from the workspace",
52                dependency.manifest, dependency.section
53            ),
54            format!(
55                "add `{name}` to [workspace.dependencies] in the root manifest, and write \
56                 `{name} = {{ workspace = true }}` here"
57            ),
58        )
59        .with_subject(name)
60        .with_expected(&format!("{name} = {{ workspace = true }}"))
61    }
62}
63
64impl Rule for WorkspaceDependenciesRule {
65    fn name(&self) -> &'static str {
66        "workspace-dependencies"
67    }
68
69    fn check(&self, _file: &SourceFile) -> Vec<Offence> {
70        Vec::new()
71    }
72
73    // A fact about the workspace, and about files the walker never reads: the
74    // manifests are gathered once by `ManifestResolver` rather than found here.
75    fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
76        self.declared
77            .iter()
78            .flatten()
79            .filter(|dependency| !dependency.takes_from_workspace)
80            .map(|dependency| self.offence(dependency))
81            .collect()
82    }
83
84    fn requirement(&self) -> Option<&'static str> {
85        None
86    }
87
88    fn is_configured(&self) -> bool {
89        true
90    }
91
92    fn explanation(&self) -> RuleExplanation {
93        RuleExplanation::new(
94            self.name(),
95            "A workspace declares its dependencies once, in the root, and every member takes them from there.",
96            "serde = { version = \"1\" }   -- in a member manifest",
97            "serde.workspace = true",
98        )
99    }
100}