Skip to main content

wowlab_tidy/languages/toml/rules/cargo/
workspace_inheritance.rs

1#[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}