wowlab_tidy/languages/toml/rules/cargo/
crates_in_workspace.rs1#[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}