xtask/check.rs
1//! `cargo xtask check`: the typo and TOML checks, clippy, the doc build, then a
2//! formatting check.
3
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use crate::step;
8
9/// Run the typo and TOML checks, clippy twice, the doc build, then the
10/// formatting check.
11///
12/// The two checks that need no compiler go first, so a misspelling or an
13/// unsorted feature table costs a second rather than the minutes clippy takes.
14///
15/// Clippy next: its warnings and the diagnostics it raises may leave the tree
16/// in a state `rustfmt` would rewrite, so formatting is the last word. The doc
17/// build sits between the two because a rustdoc warning is a lint like clippy's
18/// — it should fail here rather than only in CI — while a doc comment it would
19/// rewrite is still rustfmt's to settle.
20///
21/// Clippy runs twice, because each compiles a different set of code: the
22/// library crates with no default features at all, and the whole workspace
23/// with every feature, which is what the doc build and the full test run
24/// compile.
25///
26/// The no-features pass is the one the test run cannot stand in for. Those
27/// crates' built-in backends are optional, so this is the combination that
28/// proves the general facilities stand on their own — and the one a false
29/// optional feature, whose dependants never really compiled without it, fails
30/// to compile. The crates are named one by one rather than picked with
31/// `--workspace`, because `--no-default-features` reaches only the packages
32/// the command names: for every other package the features come from the
33/// dependency edges that reach it, and feature unification applies all of
34/// them. Every application crate asks `unlit3d` for `ui` and `winit`, so a
35/// workspace-wide run would compile the libraries with exactly the backends
36/// this pass exists to leave out.
37///
38/// Every run denies warnings, as CI does. Without it a warning here is a
39/// warning CI turns into an error, which is the same gap in a different guise.
40pub fn run(release: bool) -> Result<(), String> {
41 let root = workspace_root()?;
42
43 // Both tools take the directory to work on as their only argument, and both
44 // are run from here rather than from the working directory: a task may be
45 // started from anywhere inside the workspace, and these two read their
46 // configuration — `typos.toml`, `tombi.toml` — from where they are pointed.
47 require("typos")?;
48 require("tombi")?;
49
50 let mut typos = Command::new("typos");
51 typos.arg(&root);
52 step::run(&mut typos, "typos")?;
53
54 // `lint` and `format --check` are separate invocations because they report
55 // different things: a schema violation and a file the formatter would
56 // rewrite. The lint denies warnings, as CI does, so the two runs agree.
57 let mut tombi_lint = Command::new("tombi");
58 tombi_lint.args(["lint", "--error-on-warnings"]).arg(&root);
59 step::run(&mut tombi_lint, "tombi lint")?;
60
61 let mut tombi_format = Command::new("tombi");
62 tombi_format
63 .args(["format", "--check", "--diff"])
64 .arg(&root);
65 step::run(&mut tombi_format, "tombi format")?;
66
67 let profile: &[&str] = if release { &["--release"] } else { &[] };
68 let deny: &[&str] = &["--", "-D", "warnings"];
69
70 let mut bare = Command::new("cargo");
71 bare.args(["clippy"]);
72 for package in BARE_PACKAGES {
73 bare.args(["-p", package]);
74 }
75 bare.args(["--no-default-features", "--all-targets"])
76 .args(profile)
77 .args(deny);
78 step::run(&mut bare, "clippy (no features)")?;
79
80 let mut all = Command::new("cargo");
81 all.args(["clippy", "--workspace", "--all-targets", "--all-features"])
82 .args(profile)
83 .args(deny);
84 step::run(&mut all, "clippy (all features)")?;
85
86 // The doc build, so a broken intra-doc link or an undocumented private item
87 // fails here rather than only in CI. `cargo doc` has no `--` for handing
88 // flags to rustdoc — unlike clippy — so denying warnings goes through the
89 // environment, as CI does it. Every feature, because that is the build the
90 // published documentation is generated from: an item behind an optional
91 // feature is one whose docs only that combination compiles.
92 let mut doc = Command::new("cargo");
93 doc.args([
94 "doc",
95 "--workspace",
96 "--no-deps",
97 "--document-private-items",
98 "--keep-going",
99 "--all-features",
100 ])
101 .args(profile)
102 .env("RUSTDOCFLAGS", "-D warnings");
103 step::run(&mut doc, "doc")?;
104
105 let mut fmt = Command::new("cargo");
106 fmt.args(["fmt", "--all", "--", "--check"]);
107 step::run(&mut fmt, "fmt")?;
108
109 Ok(())
110}
111
112/// The crates the no-features clippy run names, one by one.
113///
114/// These are the library crates with optional backends: their general
115/// facilities have to compile and lint on their own, without the backends a
116/// caller may leave out. Naming them keeps the feature graph to the edges
117/// between the four, which ask for nothing, because the application crates
118/// are left out — each of them asks `unlit3d` for `ui` and `winit`, and
119/// feature unification would hand those to the libraries too. The same reason
120/// rules out `--workspace`: `--no-default-features` does not follow a
121/// dependency edge.
122const BARE_PACKAGES: [&str; 4] = ["unlit_ecs", "unlit_wgpu", "unlit3d", "unlit3dx"];
123
124/// Fail, naming the tool, when `tool` is not usable.
125///
126/// Neither tool ships with cargo, so a missing one reaches the step as a bare
127/// "No such file or directory" under a name that says what the step wanted;
128/// this reports which tool is absent instead. How to install it is left to
129/// the contributor: both are distributed outside cargo, so a command named
130/// here would be a guess.
131fn require(tool: &str) -> Result<(), String> {
132 let output = Command::new(tool)
133 .arg("--version")
134 .output()
135 .map_err(|error| format!("`{tool}` is not on PATH: {error}"))?;
136 if !output.status.success() {
137 return Err(format!(
138 "`{tool} --version` failed:\n{}",
139 String::from_utf8_lossy(&output.stderr)
140 ));
141 }
142 Ok(())
143}
144
145/// The directory the task's tools work on, which is the workspace root.
146///
147/// Asked of cargo rather than read from the working directory, so that a task
148/// started from a crate's own directory covers the whole workspace: these
149/// tools read their configuration from the directory they are pointed at, and
150/// `typos.toml` and `tombi.toml` live at the root.
151fn workspace_root() -> Result<PathBuf, String> {
152 let mut metadata = Command::new("cargo");
153 metadata.args(["locate-project", "--workspace", "--message-format", "plain"]);
154 let output = metadata
155 .output()
156 .map_err(|error| format!("running `cargo locate-project`: {error}"))?;
157 if !output.status.success() {
158 return Err(format!(
159 "`cargo locate-project` failed:\n{}",
160 String::from_utf8_lossy(&output.stderr)
161 ));
162 }
163 let manifest = String::from_utf8(output.stdout)
164 .map_err(|error| format!("reading `cargo locate-project`'s output: {error}"))?;
165 Path::new(manifest.trim())
166 .parent()
167 .map(Path::to_path_buf)
168 .ok_or_else(|| "`cargo locate-project` named a manifest with no directory".to_owned())
169}