use serde::Deserialize;
use std::collections::HashMap;
use tokio::process::Command;
use crate::core::error::{ErrorCode, Result, UpkeepError};
use crate::core::output::{ClippyIssue, ClippyOutput};
#[derive(Debug, Deserialize)]
struct CargoMessage {
reason: String,
message: Option<DiagnosticMessage>,
}
#[derive(Debug, Deserialize)]
struct DiagnosticMessage {
level: String,
code: Option<DiagnosticCode>,
message: String,
spans: Vec<DiagnosticSpan>,
}
#[derive(Debug, Deserialize)]
struct DiagnosticCode {
code: String,
}
#[derive(Debug, Deserialize)]
struct DiagnosticSpan {
file_name: String,
line_start: u32,
is_primary: bool,
}
pub async fn run_clippy() -> Result<ClippyOutput> {
let output = Command::new("cargo")
.args([
"clippy",
"--message-format=json",
"--all-targets",
"--all-features",
])
.output()
.await
.map_err(|err| {
UpkeepError::context(
ErrorCode::ExternalCommand,
"failed to execute cargo clippy",
err,
)
})?;
interpret_clippy_run(&output)
}
fn interpret_clippy_run(output: &std::process::Output) -> Result<ClippyOutput> {
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() && is_clippy_missing(&stderr) {
return Err(UpkeepError::message(
ErrorCode::MissingTool,
"clippy is not installed; run `rustup component add clippy`",
));
}
let clippy_failed = !output.status.success();
let clippy_status = output.status.to_string();
let stdout = String::from_utf8_lossy(&output.stdout);
let mut parsed = parse_diagnostics(&stdout);
if clippy_failed && parsed.errors == 0 {
if parsed.details.is_empty() {
return Err(UpkeepError::message(
ErrorCode::ExternalCommand,
format!(
"cargo clippy exited with status {clippy_status} without emitting any \
diagnostics; the crate likely failed to compile"
),
));
}
push_driver_error(&mut parsed, &clippy_status);
}
Ok(parsed)
}
fn is_clippy_missing(stderr: &str) -> bool {
let stderr = stderr.to_lowercase();
let absent = stderr.contains("rustup component add")
|| stderr.contains("is not installed")
|| stderr.contains("no such subcommand")
|| stderr.contains("no such command");
(absent && stderr.contains("clippy")) || stderr.contains("component 'clippy' is missing")
}
fn parse_diagnostics(stdout: &str) -> ClippyOutput {
let mut warnings = 0;
let mut errors = 0;
let mut warnings_by_lint: HashMap<String, usize> = HashMap::new();
let mut details = Vec::new();
for line in stdout.lines() {
let message: CargoMessage = match serde_json::from_str(line) {
Ok(message) => message,
Err(_) => continue,
};
if message.reason != "compiler-message" {
continue;
}
let Some(diagnostic) = message.message else {
continue;
};
if diagnostic.level != "warning" && diagnostic.level != "error" {
continue;
}
let lint = match diagnostic.code.as_ref().map(|code| code.code.clone()) {
Some(code) if code.starts_with("clippy::") => code,
_ => continue,
};
if diagnostic.level == "warning" {
warnings += 1;
*warnings_by_lint.entry(lint.clone()).or_insert(0) += 1;
} else {
errors += 1;
}
let span = diagnostic
.spans
.iter()
.find(|span| span.is_primary)
.or_else(|| diagnostic.spans.first());
details.push(ClippyIssue {
lint,
level: diagnostic.level,
message: diagnostic.message,
file: span.map(|span| span.file_name.clone()),
line: span.map(|span| span.line_start),
});
}
ClippyOutput {
score: clippy_score(warnings, errors),
warnings,
errors,
warnings_by_lint,
details,
}
}
fn push_driver_error(output: &mut ClippyOutput, clippy_status: &str) {
output.errors += 1;
output.details.push(ClippyIssue {
lint: "clippy::driver".to_string(),
level: "error".to_string(),
message: format!("cargo clippy exited with status {clippy_status}"),
file: None,
line: None,
});
output.score = clippy_score(output.warnings, output.errors);
}
fn clippy_score(warnings: usize, errors: usize) -> f32 {
let penalty = (warnings as u64 * 2) + (errors as u64 * 10);
100u64.saturating_sub(penalty) as f32
}
#[cfg(test)]
mod tests {
use super::*;
const RUSTUP_MISSING_CLIPPY: &str = "\
error: 'cargo-clippy' is not installed for the toolchain '1.96.0-aarch64-apple-darwin'.
help: run `rustup component add --toolchain 1.96.0-aarch64-apple-darwin clippy` to install it
";
#[test]
fn clippy_missing_detection() {
assert!(is_clippy_missing(RUSTUP_MISSING_CLIPPY));
assert!(is_clippy_missing(
"error: 'cargo-clippy' is not installed for the toolchain 'stable'.\n\
help: run `rustup component add clippy` to install it\n"
));
assert!(is_clippy_missing("error: no such command: `clippy`\n"));
assert!(is_clippy_missing("error: no such subcommand: `clippy`\n"));
assert!(is_clippy_missing("component 'clippy' is missing"));
assert!(!is_clippy_missing("some other error"));
assert!(!is_clippy_missing(
"warning: unused variable: `x`\n = help: for further information visit \
https://rust-lang.github.io/rust-clippy/master/index.html#unused_variable\n\
error: could not compile `demo` (lib) due to 1 previous error\n"
));
}
#[test]
fn parse_clippy_output_counts_and_details() {
let stdout = r#"{"reason":"compiler-message","message":{"level":"warning","code":{"code":"clippy::needless_return"},"message":"avoid needless return","spans":[{"file_name":"src/lib.rs","line_start":10,"is_primary":true}]}}
{"reason":"compiler-message","message":{"level":"error","code":{"code":"clippy::panic"},"message":"do not panic","spans":[{"file_name":"src/main.rs","line_start":42,"is_primary":false},{"file_name":"src/main.rs","line_start":43,"is_primary":true}]}}
{"reason":"compiler-message","message":{"level":"warning","code":{"code":"dead_code"},"message":"unused","spans":[]}}
{"reason":"compiler-message","message":{"level":"note","code":{"code":"clippy::style"},"message":"note","spans":[]}}"#;
let output = parse_diagnostics(stdout);
assert_eq!(output.warnings, 1);
assert_eq!(output.errors, 1);
assert_eq!(
output.warnings_by_lint.get("clippy::needless_return"),
Some(&1)
);
assert_eq!(output.details.len(), 2);
assert_eq!(output.details[0].file.as_deref(), Some("src/lib.rs"));
assert_eq!(output.details[0].line, Some(10));
assert_eq!(output.details[1].line, Some(43));
assert_eq!(output.score, 88.0);
}
#[test]
fn parse_diagnostics_reports_nothing_when_clippy_emitted_nothing() {
let output = parse_diagnostics("");
assert_eq!(output.warnings, 0);
assert_eq!(output.errors, 0);
assert!(output.details.is_empty());
assert_eq!(output.score, 100.0);
}
#[test]
fn push_driver_error_records_failure_alongside_real_lints() {
let stdout = r#"{"reason":"compiler-message","message":{"level":"warning","code":{"code":"clippy::needless_return"},"message":"avoid needless return","spans":[{"file_name":"src/lib.rs","line_start":10,"is_primary":true}]}}"#;
let mut output = parse_diagnostics(stdout);
assert_eq!(output.score, 98.0);
push_driver_error(&mut output, "exit status: 101");
assert_eq!(output.warnings, 1);
assert_eq!(output.errors, 1);
assert_eq!(output.details.len(), 2);
assert_eq!(output.details[1].lint, "clippy::driver");
assert_eq!(output.details[1].level, "error");
assert!(output.details[1].message.contains("exit status: 101"));
assert_eq!(output.score, 88.0);
}
#[cfg(unix)]
fn exit_status(code: i32) -> std::process::ExitStatus {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code << 8)
}
#[cfg(windows)]
fn exit_status(code: i32) -> std::process::ExitStatus {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code as u32)
}
fn output_with(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
std::process::Output {
status: exit_status(code),
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
}
}
#[test]
fn interpret_clippy_run_errors_when_driver_failed_without_diagnostics() {
let output = output_with(
101,
r#"{"reason":"compiler-message","message":{"level":"error","code":null,"message":"expected one of `)`","spans":[]}}"#,
"error: could not compile `broken` (lib) due to 1 previous error\n",
);
let err = interpret_clippy_run(&output)
.expect_err("a crate that does not compile is not a clippy score");
assert_eq!(err.code(), ErrorCode::ExternalCommand);
assert!(
err.to_string().contains("without emitting any diagnostics"),
"unexpected error: {err}"
);
}
#[test]
fn interpret_clippy_run_reports_a_missing_component_as_missing_tool() {
let output = output_with(101, "", RUSTUP_MISSING_CLIPPY);
let err = interpret_clippy_run(&output).unwrap_err();
assert_eq!(err.code(), ErrorCode::MissingTool);
assert!(err.to_string().contains("rustup component add clippy"));
}
#[test]
fn interpret_clippy_run_accepts_a_successful_run_with_no_lints() {
let output = interpret_clippy_run(&output_with(0, "", "")).unwrap();
assert_eq!(output.warnings, 0);
assert_eq!(output.errors, 0);
assert_eq!(output.score, 100.0);
}
#[test]
fn interpret_clippy_run_keeps_lints_when_the_exit_status_is_non_zero() {
let stdout = r#"{"reason":"compiler-message","message":{"level":"warning","code":{"code":"clippy::needless_return"},"message":"avoid needless return","spans":[{"file_name":"src/lib.rs","line_start":10,"is_primary":true}]}}"#;
let parsed = interpret_clippy_run(&output_with(101, stdout, "")).unwrap();
assert_eq!(parsed.warnings, 1);
assert_eq!(parsed.errors, 1);
assert_eq!(parsed.details[1].lint, "clippy::driver");
assert_eq!(parsed.score, 88.0);
}
}