Skip to main content

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

1#[cfg(test)]
2use googletest::prelude::*;
3use wowlab_fs::path::Path;
4
5use super::{
6    CARGO_DEP_TABLES, CARGO_WORKSPACE_REL, cargo_document, is_cargo_member, key_line, nested_table,
7};
8use crate::{Example, TomlCtx, Violation, violation};
9
10#[rustfmt::skip]
11const EXAMPLES: &[Example] = &[
12    Example { label: "sibling resolved through workspace", code: "[package]\nname = \"foo\"\n\n[dependencies]\nsibling.workspace = true\n", pass: true },
13    Example { label: "sibling linked via path", code: "[package]\nname = \"foo\"\n\n[dependencies]\nsibling = { path = \"../sibling\" }\n", pass: false },
14    Example { label: "path in dev-dependencies", code: "[package]\nname = \"foo\"\n\n[dev-dependencies]\nsibling = { path = \"../sibling\", version = \"0.1\" }\n", pass: false },
15];
16
17crate::toml_rule!(
18    toml_cargo_crates_in_workspace,
19    "Require every crate directory to be a workspace member and ban `path = ...` dependencies in member manifests.",
20    "Unlisted crates escape workspace-wide checks, and path dependencies bypass the single canonical version in [workspace.dependencies] (M-CRATES-IN-WORKSPACE).",
21    Medium,
22);
23
24fn check_toml_cargo_crates_in_workspace(ctx: &TomlCtx<'_>) -> Vec<Violation> {
25    let Some(document) = cargo_document(ctx) else {
26        return Vec::new();
27    };
28
29    if ctx.file.rel == CARGO_WORKSPACE_REL {
30        let Some(members) = member_patterns(&document) else {
31            return Vec::new();
32        };
33        let Some(crates_dir) = ctx.file.path.parent() else {
34            return Vec::new();
35        };
36
37        return unlisted_member_violations(ctx.file.rel, ctx.file.lines, crates_dir, &members);
38    }
39
40    if is_cargo_member(ctx.file.rel) && document.contains_key("package") {
41        return path_dependency_violations(ctx.file.rel, ctx.file.lines, &document);
42    }
43
44    Vec::new()
45}
46
47fn member_patterns(document: &toml::Table) -> Option<Vec<String>> {
48    let members = nested_table(document, &["workspace"])?
49        .get("members")?
50        .as_array()?;
51
52    Some(
53        members
54            .iter()
55            .filter_map(|member| member.as_str().map(String::from))
56            .collect(),
57    )
58}
59
60fn unlisted_member_violations(
61    rel: &str,
62    lines: &[&str],
63    crates_dir: &Path,
64    members: &[String],
65) -> Vec<Violation> {
66    let mut violations = Vec::new();
67
68    for manifest in crate::infra::workspace::member_manifests(crates_dir) {
69        let Some(dir) = manifest.parent() else {
70            continue;
71        };
72
73        if dir.parent() != Some(crates_dir) {
74            continue;
75        }
76
77        let Some(name) = dir.file_name().and_then(|name| name.to_str()) else {
78            continue;
79        };
80
81        if members
82            .iter()
83            .any(|pattern| glob_match::glob_match(pattern, name))
84        {
85            continue;
86        }
87
88        violations.push(violation(
89            rel,
90            key_line(lines, "workspace", "members"),
91            format!("crate directory `{name}` is not listed in [workspace] members"),
92        ));
93    }
94
95    violations
96}
97
98fn path_dependency_violations(rel: &str, lines: &[&str], document: &toml::Table) -> Vec<Violation> {
99    let mut violations = Vec::new();
100
101    for table_name in CARGO_DEP_TABLES {
102        let Some(dependencies) = nested_table(document, &[table_name]) else {
103            continue;
104        };
105
106        for (name, value) in dependencies {
107            let uses_path = value
108                .as_table()
109                .is_some_and(|spec| spec.contains_key("path"));
110
111            if uses_path {
112                violations.push(violation(
113                    rel,
114                    key_line(lines, table_name, name),
115                    format!(
116                        "[{table_name}] `{name}` links a sibling via `path`; declare it in \
117                         [workspace.dependencies] and use `{name}.workspace = true`"
118                    ),
119                ));
120            }
121        }
122    }
123
124    violations
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[gtest]
132    fn examples() -> Result<()> {
133        for example in EXAMPLES {
134            let violations = crate::test_support::check_source_toml_at(
135                "crates/foo/Cargo.toml",
136                example.code,
137                check_toml_cargo_crates_in_workspace,
138            );
139
140            verify_eq!(violations.is_empty(), example.pass)?;
141        }
142
143        Ok(())
144    }
145
146    #[gtest]
147    fn unlisted_crate_directory_is_flagged() -> Result<()> {
148        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
149        let root = directory.path();
150
151        for name in ["listed", "unlisted"] {
152            let dir = root.join(name);
153
154            wowlab_fs::directory::ensure(&dir).or_fail()?;
155            wowlab_fs::file::write_text(&dir.join("Cargo.toml"), "[package]\nname = \"x\"\n")
156                .or_fail()?;
157        }
158
159        wowlab_fs::directory::ensure(&root.join("no-manifest")).or_fail()?;
160
161        let members = vec![String::from("listed")];
162        let violations = unlisted_member_violations("crates/Cargo.toml", &[], root, &members);
163
164        verify_eq!(violations.len(), 1)?;
165        verify_true!(violations[0].message.contains("`unlisted`"))?;
166
167        Ok(())
168    }
169}