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}