wowlab_tidy/languages/toml/rules/cargo/
workspace_inheritance.rs1#[cfg(test)]
2use googletest::prelude::*;
3use wowlab_fs::{file, path::Path};
4
5use super::{CARGO_DEP_TABLES, cargo_document, is_cargo_member, key_line, nested_table};
6use crate::{Example, TomlCtx, Violation, violation};
7
8const INHERITED_PACKAGE_KEYS: &[&str] = &[
9 "version",
10 "edition",
11 "authors",
12 "license",
13 "repository",
14 "rust-version",
15];
16
17#[rustfmt::skip]
18const EXAMPLES: &[Example] = &[
19 Example { label: "workspace-inherited dependency", code: "[package]\nname = \"foo\"\n\n[dependencies]\nserde.workspace = true\n", pass: true },
20 Example { label: "workspace dependency with extra features", code: "[package]\nname = \"foo\"\n\n[dependencies]\nserde = { workspace = true, features = [\"derive\"] }\n", pass: true },
21 Example { label: "bare string version", code: "[package]\nname = \"foo\"\n\n[dependencies]\nserde = \"1\"\n", pass: false },
22 Example { label: "inline version in dependency table", code: "[package]\nname = \"foo\"\n\n[dev-dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n", pass: false },
23 Example { label: "inline version in build-dependencies", code: "[package]\nname = \"foo\"\n\n[build-dependencies]\ncc = \"1\"\n", pass: false },
24];
25
26crate::toml_rule!(
27 toml_cargo_workspace_inheritance,
28 "Require member crates to inherit dependency versions and shared package metadata from the workspace root.",
29 "Inline versions and duplicated package metadata drift silently between crates; workspace inheritance keeps one canonical value (M-CARGO-WORKSPACE).",
30 Medium,
31);
32
33fn check_toml_cargo_workspace_inheritance(ctx: &TomlCtx<'_>) -> Vec<Violation> {
34 if !is_cargo_member(ctx.file.rel) {
35 return Vec::new();
36 }
37
38 let Some(document) = cargo_document(ctx) else {
39 return Vec::new();
40 };
41
42 if !document.contains_key("package") {
43 return Vec::new();
44 }
45
46 let mut violations = dependency_violations(ctx.file.rel, ctx.file.lines, &document);
47
48 if let Some(workspace_package) = workspace_package(ctx.file.path) {
49 violations.extend(package_violations(
50 ctx.file.rel,
51 ctx.file.lines,
52 &document,
53 &workspace_package,
54 ));
55 }
56
57 violations
58}
59
60fn dependency_violations(rel: &str, lines: &[&str], document: &toml::Table) -> Vec<Violation> {
61 let mut violations = Vec::new();
62
63 for table_name in CARGO_DEP_TABLES {
64 let Some(dependencies) = nested_table(document, &[table_name]) else {
65 continue;
66 };
67
68 for (name, value) in dependencies {
69 let inline_version = match value {
70 toml::Value::String(_) => true,
71 toml::Value::Table(spec) => spec.contains_key("version"),
72 _ => false,
73 };
74
75 if inline_version {
76 violations.push(violation(
77 rel,
78 key_line(lines, table_name, name),
79 format!(
80 "[{table_name}] `{name}` pins a version inline; declare it in \
81 [workspace.dependencies] and use `{name}.workspace = true`"
82 ),
83 ));
84 }
85 }
86 }
87
88 violations
89}
90
91fn package_violations(
92 rel: &str,
93 lines: &[&str],
94 document: &toml::Table,
95 workspace_package: &toml::Table,
96) -> Vec<Violation> {
97 let Some(package) = nested_table(document, &["package"]) else {
98 return Vec::new();
99 };
100 let mut violations = Vec::new();
101
102 for key in INHERITED_PACKAGE_KEYS {
103 if !workspace_package.contains_key(*key) {
104 continue;
105 }
106
107 let Some(value) = package.get(*key) else {
108 continue;
109 };
110
111 if value.is_table() {
112 continue;
113 }
114
115 violations.push(violation(
116 rel,
117 key_line(lines, "package", key),
118 format!(
119 "[package] `{key}` duplicates [workspace.package]; use `{key}.workspace = true`"
120 ),
121 ));
122 }
123
124 violations
125}
126
127fn workspace_package(path: &Path) -> Option<toml::Table> {
128 let crates_dir = path
129 .ancestors()
130 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == "crates"))?;
131 let contents = file::read_text(&crates_dir.join("Cargo.toml")).ok()?;
132 let document: toml::Table = toml::from_str(&contents).ok()?;
133
134 nested_table(&document, &["workspace", "package"]).cloned()
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[gtest]
142 fn examples() -> Result<()> {
143 for example in EXAMPLES {
144 let violations = crate::test_support::check_source_toml_at(
145 "crates/foo/Cargo.toml",
146 example.code,
147 check_toml_cargo_workspace_inheritance,
148 );
149
150 verify_eq!(violations.is_empty(), example.pass)?;
151 }
152
153 Ok(())
154 }
155
156 #[gtest]
157 fn literal_metadata_flagged_only_when_workspace_defines_it() -> Result<()> {
158 let member: toml::Table = toml::from_str(
159 "[package]\nname = \"foo\"\nversion = \"0.1.0\"\nedition.workspace = true\nlicense = \"MIT\"\n",
160 )
161 .or_fail()?;
162 let workspace: toml::Table =
163 toml::from_str("[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n")
164 .or_fail()?;
165 let workspace_package = nested_table(&workspace, &["workspace", "package"]).or_fail()?;
166
167 let source = "[package]\nname = \"foo\"\nversion = \"0.1.0\"\nedition.workspace = true\nlicense = \"MIT\"\n";
168 let lines: Vec<&str> = source.lines().collect();
169 let violations =
170 package_violations("crates/foo/Cargo.toml", &lines, &member, workspace_package);
171
172 verify_eq!(violations.len(), 1)?;
173 verify_true!(violations[0].message.contains("`version`"))?;
174 verify_eq!(violations[0].line, 3)?;
175
176 Ok(())
177 }
178}