Skip to main content

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

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