wowlab_tidy/languages/toml/rules/cargo/
app_error_crates.rs1#[cfg(test)]
2use googletest::prelude::*;
3use wowlab_fs::{file, path::Path};
4
5use super::{assignment_line, is_workspace_member_manifest};
6use crate::{Example, TomlCtx, Violation, violation};
7
8const LIBRARY_DEPENDENT_THRESHOLD: usize = 2;
9
10#[rustfmt::skip]
11const EXAMPLES: &[Example] = &[
12 Example { label: "single error crate", code: "[package]\nname = \"foo\"\n\n[dependencies]\nanyhow = \"1\"\n", pass: true },
13 Example { label: "no error crates", code: "[package]\nname = \"foo\"\n\n[dependencies]\nserde = \"1\"\n", pass: true },
14 Example { label: "mixed error crates", code: "[package]\nname = \"foo\"\n\n[dependencies]\nanyhow = \"1\"\neyre = \"0.6\"\n", pass: false },
15 Example { label: "mixed via rename", code: "[package]\nname = \"foo\"\n\n[dependencies]\nanyhow = \"1\"\nerr = { package = \"eyre\", version = \"0.6\" }\n", pass: false },
16];
17
18crate::toml_rule!(
19 toml_cargo_app_error_crates,
20 "Restrict application error crates to application crates and forbid mixing them.",
21 "Libraries must expose canonical error structs, and one application error type keeps `?` conversions coherent (M-APP-ERROR).",
22 Medium,
23 params {
24 error_crates: [String] = ["anyhow", "eyre", "ohno"],
25 },
26);
27
28fn check_toml_cargo_app_error_crates(ctx: &TomlCtx<'_>) -> Vec<Violation> {
29 if !is_workspace_member_manifest(ctx.file.rel) || !ctx.parse.errors.is_empty() {
30 return Vec::new();
31 }
32
33 let Ok(document) = toml::from_str::<toml::Table>(ctx.file.contents) else {
34 return Vec::new();
35 };
36 let error_crates = ctx
37 .file
38 .config
39 .get_str_array("toml_cargo_app_error_crates", &PARAMS[0]);
40 let present = present_error_crates(&document, &error_crates);
41
42 if present.is_empty() {
43 return Vec::new();
44 }
45
46 let mut violations = Vec::new();
47
48 if present.len() > 1 {
49 violations.push(violation(
50 ctx.file.rel,
51 assignment_line(ctx.file.lines, present[0]),
52 format!(
53 "depends on multiple application error crates ({}); pick one",
54 present.join(", ")
55 ),
56 ));
57 }
58
59 if let Some(dependents) = dependent_count(ctx.file.path, &document) {
60 if dependents >= LIBRARY_DEPENDENT_THRESHOLD {
61 violations.extend(present.iter().map(|name| {
62 violation(
63 ctx.file.rel,
64 assignment_line(ctx.file.lines, name),
65 format!(
66 "library crate ({dependents} workspace dependents) must not depend on `{name}`; expose canonical error structs instead (M-ERRORS-CANONICAL-STRUCTS)"
67 ),
68 )
69 }));
70 }
71 }
72
73 violations
74}
75
76fn present_error_crates<'a>(document: &'a toml::Table, error_crates: &[String]) -> Vec<&'a str> {
77 let mut present = Vec::new();
78
79 for table in normal_dependency_tables(document) {
80 for (key, value) in table {
81 let name = resolved_name(key, value);
82
83 if error_crates.iter().any(|configured| configured == name) && !present.contains(&name)
84 {
85 present.push(name);
86 }
87 }
88 }
89
90 present
91}
92
93fn dependent_count(manifest_path: &Path, document: &toml::Table) -> Option<usize> {
94 let package = manifest_package_name(document)?;
95 let package_dir = manifest_path.parent()?;
96 let workspace_dir = package_dir.parent()?;
97 let patterns = workspace_member_patterns(workspace_dir)?;
98
99 let mut count = 0;
100
101 for manifest in crate::infra::workspace::member_manifests(workspace_dir) {
102 let Some(member_dir) = manifest.parent() else {
103 continue;
104 };
105
106 if !is_other_workspace_member(member_dir, package_dir, workspace_dir, &patterns) {
107 continue;
108 }
109
110 if manifest_depends_on(&manifest, package) {
111 count += 1;
112 }
113 }
114
115 Some(count)
116}
117
118fn manifest_package_name(document: &toml::Table) -> Option<&str> {
119 document.get("package")?.as_table()?.get("name")?.as_str()
120}
121
122fn workspace_member_patterns(crates_dir: &Path) -> Option<Vec<String>> {
123 let source = file::read_text(&crates_dir.join("Cargo.toml")).ok()?;
124 let root: toml::Table = toml::from_str(&source).ok()?;
125
126 Some(
127 root.get("workspace")?
128 .as_table()?
129 .get("members")?
130 .as_array()?
131 .iter()
132 .filter_map(toml::Value::as_str)
133 .map(str::to_owned)
134 .collect(),
135 )
136}
137
138fn is_other_workspace_member(
139 member_dir: &Path,
140 package_dir: &Path,
141 workspace_dir: &Path,
142 patterns: &[String],
143) -> bool {
144 member_dir.parent() == Some(workspace_dir)
145 && member_dir != package_dir
146 && member_dir
147 .file_name()
148 .and_then(|name| name.to_str())
149 .is_some_and(|name| {
150 patterns
151 .iter()
152 .any(|pattern| glob_match::glob_match(pattern, name))
153 })
154}
155
156fn manifest_depends_on(manifest: &Path, package: &str) -> bool {
157 let Ok(source) = file::read_text(manifest) else {
158 return false;
159 };
160 let Ok(document) = toml::from_str::<toml::Table>(&source) else {
161 return false;
162 };
163
164 normal_dependency_tables(&document)
165 .into_iter()
166 .flat_map(|table| table.iter())
167 .any(|(key, value)| resolved_name(key, value) == package)
168}
169
170fn normal_dependency_tables(document: &toml::Table) -> Vec<&toml::Table> {
171 let mut tables = Vec::new();
172
173 if let Some(table) = document.get("dependencies").and_then(toml::Value::as_table) {
174 tables.push(table);
175 }
176
177 if let Some(targets) = document.get("target").and_then(toml::Value::as_table) {
178 for target in targets.values().filter_map(toml::Value::as_table) {
179 if let Some(table) = target.get("dependencies").and_then(toml::Value::as_table) {
180 tables.push(table);
181 }
182 }
183 }
184
185 tables
186}
187
188fn resolved_name<'a>(key: &'a str, value: &'a toml::Value) -> &'a str {
189 value
190 .as_table()
191 .and_then(|table| table.get("package"))
192 .and_then(toml::Value::as_str)
193 .unwrap_or(key)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 fn run(source: &str) -> Vec<Violation> {
201 crate::test_support::check_source_toml_at(
202 "crates/foo/Cargo.toml",
203 source,
204 check_toml_cargo_app_error_crates,
205 )
206 }
207
208 crate::example_tests!(EXAMPLES, check_toml_cargo_app_error_crates);
209
210 #[gtest]
211 fn detects_renamed_dependency() -> Result<()> {
212 let document: toml::Table =
213 toml::from_str("[dependencies]\nerr = { package = \"eyre\", version = \"0.6\" }\n")
214 .or_fail()?;
215 let present = present_error_crates(&document, &["eyre".to_string()]);
216
217 verify_eq!(present, vec!["eyre"])?;
218
219 Ok(())
220 }
221
222 #[gtest]
223 fn skips_workspace_root_manifest() -> Result<()> {
224 let violations = crate::test_support::check_source_toml_at(
225 "crates/Cargo.toml",
226 "[dependencies]\nanyhow = \"1\"\neyre = \"0.6\"\n",
227 check_toml_cargo_app_error_crates,
228 );
229
230 verify_true!(violations.is_empty())?;
231
232 Ok(())
233 }
234}