xtask_lib/tasks/clippy.rs
1//! `cargo xtask clippy` — lint across the full feature matrix.
2//!
3//! The feature matrix is derived dynamically from the workspace `Cargo.toml`
4//! files, so adding a new feature flag automatically includes it in all
5//! clippy runs without any manual update.
6//!
7//! ## Feature matrix
8//!
9//! Every power-set combination of workspace features is checked, each scoped
10//! to `--workspace --all-targets`.
11//!
12//! ## Reviewdog reporting
13//!
14//! When `--github-report` is passed, clippy output is emitted as JSON and
15//! piped through [`reviewdog`](https://github.com/reviewdog/reviewdog).
16//! Reviewdog filters the diagnostics to only lines touched by the diff and
17//! posts them as inline review comments.
18//!
19//! Two modes are supported:
20//!
21//! | Flag | Reporter | Diff source | Use case |
22//! |------|----------|-------------|----------|
23//! | `--github-report` | `github-pr-review` | GitHub PR API | CI on pull requests |
24//! | `--github-report --diff-branch master` | `local` | `git diff master` | Local development |
25//!
26//! CI requirements:
27//! - `reviewdog` on `PATH`
28//! - `REVIEWDOG_GITHUB_API_TOKEN` set to a token with `pull-requests: write`
29//!
30//! Local requirements:
31//! - `reviewdog` on `PATH` (provided by the devenv shell)
32//!
33//! ## JSON artifact collection
34//!
35//! When `--save-json <path>` is passed, raw clippy JSON lines are written to
36//! the given file instead of running reviewdog or applying `-D warnings`.
37//! This is used by CI to collect per-feature-label artifacts that are later
38//! deduplicated and reported in a single `cargo xtask ci clippy-report` run.
39
40use std::fs;
41use std::io::{BufRead, BufReader, Write};
42use std::path::PathBuf;
43use std::process::{Command, Stdio};
44
45use anyhow::{Context, Result, bail};
46use clap::Args;
47
48use super::util::{cmd, matrix, workspace};
49
50/// Arguments for `cargo xtask clippy`.
51#[derive(Debug, Args)]
52pub struct ClippyArgs {
53 /// Run only the named feature combination (e.g. `"test + otel"`).
54 ///
55 /// When omitted, all matrix entries are checked in sequence.
56 #[arg(long)]
57 pub features: Option<String>,
58
59 /// Pipe clippy JSON output through reviewdog.
60 ///
61 /// Without `--diff-branch`, uses `github-pr-review` reporter (CI mode):
62 /// reviewdog fetches the PR diff from the GitHub API and posts inline
63 /// review comments. Requires `REVIEWDOG_GITHUB_API_TOKEN`.
64 ///
65 /// With `--diff-branch`, uses `local` reporter (local dev mode):
66 /// reviewdog diffs against the given branch and prints findings to the
67 /// terminal.
68 #[arg(long)]
69 pub github_report: bool,
70
71 /// Branch to diff against when running reviewdog locally.
72 ///
73 /// When set, reviewdog uses `git diff <branch>` as the diff source and
74 /// the `local` reporter instead of posting GitHub review comments.
75 /// Typically set to `master`.
76 #[arg(long)]
77 pub diff_branch: Option<String>,
78
79 /// Write raw clippy JSON lines to this file instead of linting or reporting.
80 ///
81 /// Skips `-D warnings` and reviewdog. The file is created (or truncated)
82 /// and each JSON line emitted by `cargo clippy --message-format=json` is
83 /// written verbatim. Used by CI to collect per-label artifacts for
84 /// `cargo xtask ci clippy-report`.
85 #[arg(long)]
86 pub save_json: Option<PathBuf>,
87}
88
89/// Runs `cargo clippy` across the feature matrix (or a single entry).
90///
91/// Three modes:
92///
93/// - **Normal**: runs clippy and exits non-zero only if clippy itself errors.
94/// - **Reviewdog** (`--github-report` or `--diff-branch`): pipes JSON output
95/// through `reviewdog`. `--diff-branch` alone is sufficient for local use;
96/// `--github-report` alone uses the `github-pr-review` reporter in CI.
97/// - **`--save-json <path>`**: writes raw JSON lines to a file for later
98/// deduplication by `cargo xtask ci clippy-report`.
99///
100/// # Errors
101///
102/// Returns the first clippy error encountered, or a spawn/IO error.
103pub fn run(args: ClippyArgs) -> Result<()> {
104 let root = workspace::root()?;
105 let entries = matrix::scan(&root, &["local"])?;
106 let entries = filter(&entries, args.features.as_deref())?;
107
108 for entry in entries {
109 println!("\n==> clippy ({})", entry.label);
110
111 if let Some(ref path) = args.save_json {
112 save_json(&root, &entry.cargo_args(), path)?;
113 } else if args.github_report || args.diff_branch.is_some() {
114 run_with_reviewdog(&root, &entry.cargo_args(), args.diff_branch.as_deref())?;
115 } else {
116 let mut clippy_args = vec!["clippy", "--all-targets"];
117 clippy_args.extend_from_slice(&entry.cargo_args());
118 cmd::run("cargo", &clippy_args, &root, &[])?;
119 }
120 }
121
122 Ok(())
123}
124
125/// Runs `cargo clippy --message-format=json` and writes every output line to
126/// `dest`, creating or truncating the file.
127///
128/// Does not apply `-D warnings` — the exit status of clippy is ignored so
129/// that artifact collection always succeeds even when warnings are present.
130///
131/// # Errors
132///
133/// Returns an error if the process cannot be spawned or the file cannot be
134/// written.
135pub(crate) fn save_json(
136 root: &std::path::Path,
137 cargo_args: &[&str],
138 dest: &std::path::Path,
139) -> Result<()> {
140 let mut clippy_args = vec!["clippy", "--all-targets", "--message-format=json"];
141 clippy_args.extend_from_slice(cargo_args);
142
143 println!("$ cargo {}", clippy_args.join(" "));
144
145 let mut child = Command::new("cargo")
146 .args(&clippy_args)
147 .current_dir(root)
148 .stdout(Stdio::piped())
149 .stderr(Stdio::inherit())
150 .spawn()
151 .context("failed to spawn `cargo clippy`")?;
152
153 let stdout = child.stdout.take().context("clippy stdout not captured")?;
154 let reader = BufReader::new(stdout);
155
156 if let Some(parent) = dest.parent() {
157 fs::create_dir_all(parent)
158 .with_context(|| format!("failed to create directory {}", parent.display()))?;
159 }
160
161 let mut file =
162 fs::File::create(dest).with_context(|| format!("failed to create {}", dest.display()))?;
163
164 for line in reader.lines() {
165 let line = line.context("failed to read clippy output")?;
166 writeln!(file, "{line}")
167 .with_context(|| format!("failed to write to {}", dest.display()))?;
168 }
169
170 child.wait().context("failed to wait for `cargo clippy`")?;
171
172 Ok(())
173}
174
175/// Runs `cargo clippy --message-format=short` and pipes the output through
176/// `reviewdog`.
177///
178/// When `diff_branch` is `Some`, reviewdog uses `git diff <branch>` as the
179/// diff source and the `local` reporter (terminal output). When `None`,
180/// reviewdog fetches the PR diff from the GitHub API and posts inline review
181/// comments via the `github-pr-review` reporter.
182///
183/// Both processes run concurrently via a pipe so neither buffers the full
184/// output in memory.
185///
186/// # Errors
187///
188/// Returns an error if either process fails to spawn or exits non-zero.
189fn run_with_reviewdog(
190 root: &std::path::Path,
191 cargo_args: &[&str],
192 diff_branch: Option<&str>,
193) -> Result<()> {
194 let mut clippy_args = vec![
195 "clippy",
196 "--all-targets",
197 "--message-format=short",
198 "--quiet",
199 ];
200 clippy_args.extend_from_slice(cargo_args);
201
202 let mut reviewdog_args = vec![
203 "-f=clippy".to_owned(),
204 "-filter-mode=added".to_owned(),
205 "-fail-on-error=false".to_owned(),
206 ];
207
208 if let Some(branch) = diff_branch {
209 reviewdog_args.push("-reporter=local".to_owned());
210 reviewdog_args.push(format!("-diff=git diff --no-ext-diff {branch}"));
211 } else {
212 reviewdog_args.push("-reporter=github-pr-review".to_owned());
213 }
214
215 println!("$ cargo {}", clippy_args.join(" "));
216 println!("$ reviewdog {}", reviewdog_args.join(" "));
217
218 let mut clippy = Command::new("sh")
219 .args(["-c", &format!("cargo {} 2>&1", clippy_args.join(" "))])
220 .current_dir(root)
221 .stdout(Stdio::piped())
222 .stderr(Stdio::inherit())
223 .spawn()
224 .context("failed to spawn `cargo clippy`")?;
225
226 let clippy_stdout = clippy.stdout.take().context("clippy stdout not captured")?;
227
228 let mut reviewdog = Command::new("reviewdog")
229 .args(&reviewdog_args)
230 .current_dir(root)
231 .stdin(clippy_stdout)
232 .stdout(Stdio::inherit())
233 .stderr(Stdio::inherit())
234 .spawn()
235 .context("failed to spawn `reviewdog` — is it installed and on PATH?")?;
236
237 let clippy_status = clippy.wait().context("failed to wait for `cargo clippy`")?;
238 let reviewdog_status = reviewdog.wait().context("failed to wait for `reviewdog`")?;
239
240 if !clippy_status.success() {
241 bail!("`cargo clippy` exited with status {}", clippy_status);
242 }
243 if !reviewdog_status.success() {
244 bail!("`reviewdog` exited with status {}", reviewdog_status);
245 }
246
247 Ok(())
248}
249
250/// Returns the matrix entries to run, optionally filtered by label.
251///
252/// When `label` is `None` all entries are returned. When a label is
253/// provided it is normalised via [`matrix::normalize_features_arg`] before
254/// matching, so both `"otel,test"` and `"otel + test"` resolve to the same
255/// entry. An unknown label after normalisation is an error.
256///
257/// # Errors
258///
259/// Returns an error when a label is provided but no matrix entry matches,
260/// listing all available labels.
261fn filter<'a>(
262 entries: &'a [matrix::MatrixEntry],
263 label: Option<&str>,
264) -> Result<Vec<&'a matrix::MatrixEntry>> {
265 let Some(raw) = label else {
266 return Ok(entries.iter().collect());
267 };
268
269 let normalised = matrix::normalize_features_arg(raw);
270 let matched: Vec<&matrix::MatrixEntry> =
271 entries.iter().filter(|e| e.label == normalised).collect();
272
273 if matched.is_empty() {
274 let available: Vec<&str> = entries
275 .iter()
276 .map(|e| e.label.as_str())
277 .collect::<std::collections::BTreeSet<_>>()
278 .into_iter()
279 .collect();
280 bail!(
281 "unknown feature combination {:?} (normalised to {:?})\n\nAvailable labels:\n {}",
282 raw,
283 normalised,
284 available.join("\n ")
285 );
286 }
287
288 Ok(matched)
289}