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}