use std::path::Path;
use serde::Deserialize;
use crate::adapter::{
Adapter, AssetPaths, InstallHint, Invocation, NativeContext, RUST_TOOLCHAIN, snippet_hash_at,
};
use crate::guidance::{Guidance, Line};
use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
use crate::runner::{ExecError, check_reported_path};
use rto_graph::{Severity, Span};
pub const ANALYZER: &str = "clippy";
pub const COMPONENT_ADD: &str = "rustup component add clippy";
const INSTALL_HINTS: &[InstallHint] = &[
InstallHint {
program: "cargo",
guidance: RUST_TOOLCHAIN,
},
InstallHint {
program: "cargo-clippy",
guidance: Guidance::new(&[
Line::Note(&[
"Roteiro does not install toolchain components, and has not installed",
"this one. Clippy ships with the toolchain and is added to it with:",
]),
Line::Command(COMPONENT_ADD),
Line::Note(&["Upstream: https://doc.rust-lang.org/clippy/installation.html"]),
]),
},
];
pub const UNCODED_RULE: &str = "rustc";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum FeatureSet {
#[default]
Defaults,
All,
Explicit(Vec<String>),
}
impl FeatureSet {
#[must_use]
pub fn args(&self) -> Vec<String> {
match self {
Self::Defaults => Vec::new(),
Self::All => vec!["--all-features".to_owned()],
Self::Explicit(features) => {
vec!["--features".to_owned(), features.join(",")]
}
}
}
#[must_use]
pub fn label(&self) -> String {
match self {
Self::Defaults => "default (each crate's own default features)".to_owned(),
Self::All => "all (--all-features)".to_owned(),
Self::Explicit(features) => features.join(", "),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Clippy;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Summary {
pub build_succeeded: bool,
pub compiler_messages: usize,
pub without_location: usize,
pub outside_worktree: usize,
pub duplicates_collapsed: usize,
}
impl Clippy {
#[must_use]
pub fn invocation(features: &FeatureSet) -> Invocation {
let mut args = vec![
"clippy".to_owned(),
"--workspace".to_owned(),
"--all-targets".to_owned(),
"--locked".to_owned(),
];
args.extend(features.args());
args.push("--message-format=json".to_owned());
args.push("--quiet".to_owned());
Invocation {
program: "cargo".to_owned(),
args,
success_statuses: vec![0, 101],
}
}
#[must_use]
pub fn offline_invocation(features: &FeatureSet) -> Invocation {
let mut invocation = Self::invocation(features);
invocation.args.push("--offline".to_owned());
invocation
}
pub fn parse(
native: &[u8],
ctx: &NativeContext<'_>,
) -> Result<(NormalizedReport, Summary), ExecError> {
let text = String::from_utf8_lossy(native);
let mut summary = Summary::default();
let mut finished = false;
let mut findings: Vec<ReportFinding> = Vec::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let Ok(message) = serde_json::from_str::<CargoMessage>(line) else {
continue;
};
match message.reason.as_str() {
"build-finished" => {
finished = true;
summary.build_succeeded = message.success.unwrap_or(false);
}
"compiler-message" => {
summary.compiler_messages += 1;
if let Some(diagnostic) = message.message {
convert(&diagnostic, ctx, &mut summary, &mut findings);
}
}
_ => {}
}
}
if !finished {
return Err(ExecError::MalformedReport(
"not a cargo --message-format=json stream: no `build-finished` message, so this \
is not a completed run and its emptiness means nothing"
.to_owned(),
));
}
findings.sort_by(|a, b| a.identity.cmp(&b.identity));
let before = findings.len();
findings.dedup_by(|a, b| a.identity == b.identity);
summary.duplicates_collapsed = before - findings.len();
Ok((
NormalizedReport {
schema: REPORT_SCHEMA.to_owned(),
analyzer: ANALYZER.to_owned(),
analyzer_version: ctx.version_or(None),
started_at: ctx.started_at.clone(),
ended_at: ctx.ended_at.clone(),
exit_status: ctx.exit_status,
rules_digest: None,
image_digest: None,
advisory_db: None,
source: ctx.source.clone(),
findings,
},
summary,
))
}
}
impl Adapter for Clippy {
fn analyzer(&self) -> &'static str {
ANALYZER
}
fn summary(&self) -> &'static str {
"Rust lints from the toolchain's own linter, reported and never stored"
}
fn languages(&self) -> &'static [&'static str] {
&["rust"]
}
fn asset_ids(&self) -> &'static [&'static str] {
&[]
}
fn host_programs(&self) -> &'static [&'static str] {
&["cargo", "cargo-clippy"]
}
fn install_hints(&self) -> &'static [InstallHint] {
INSTALL_HINTS
}
fn command(&self, _assets: &AssetPaths<'_>) -> Invocation {
Self::invocation(&FeatureSet::Defaults)
}
fn normalize(
&self,
native: &[u8],
ctx: &NativeContext<'_>,
) -> Result<NormalizedReport, ExecError> {
Self::parse(native, ctx).map(|(report, _)| report)
}
}
fn convert(
diagnostic: &Diagnostic,
ctx: &NativeContext<'_>,
summary: &mut Summary,
findings: &mut Vec<ReportFinding>,
) {
let Some(span) = primary_span(diagnostic) else {
summary.without_location += 1;
return;
};
let Some(path) = worktree_relative(&span.file_name, ctx.worktree) else {
summary.outside_worktree += 1;
return;
};
let start = u32::try_from(span.byte_start).unwrap_or(u32::MAX);
let end = u32::try_from(span.byte_end).unwrap_or(u32::MAX).max(start);
let message = diagnostic.message.trim();
let rule = diagnostic
.code
.as_ref()
.map(|c| c.code.trim())
.filter(|c| !c.is_empty())
.unwrap_or(UNCODED_RULE)
.to_owned();
findings.push(ReportFinding {
identity: vec![
rule.clone(),
path.clone(),
start.to_string(),
snippet_hash_at(ctx.snippets, &path, start, end),
],
rule,
severity: severity(&diagnostic.level),
title: title_from(message, &span.file_name),
message: message.to_owned(),
path: Some(path),
span: Some(Span::new(start, end)),
meta: serde_json::json!({
"line": span.line_start,
"column": span.column_start,
"end_line": span.line_end,
"rustc_level": diagnostic.level,
}),
});
}
fn primary_span(diagnostic: &Diagnostic) -> Option<&DiagnosticSpan> {
diagnostic
.spans
.iter()
.find(|s| s.is_primary)
.or_else(|| diagnostic.spans.first())
}
fn worktree_relative(file: &str, worktree: Option<&Path>) -> Option<String> {
let path = Path::new(file);
let relative = if path.is_absolute() {
path.strip_prefix(worktree?).ok()?
} else {
path.strip_prefix("./").unwrap_or(path)
};
let text = relative.to_string_lossy().into_owned();
check_reported_path(&text).ok()?;
Some(text)
}
fn title_from(message: &str, file: &str) -> String {
let first = message.lines().next().unwrap_or("").trim();
if first.is_empty() {
file.to_owned()
} else {
first.to_owned()
}
}
fn severity(level: &str) -> Severity {
match level.trim().to_ascii_lowercase().as_str() {
"error" | "error: internal compiler error" => Severity::High,
"warning" => Severity::Medium,
"note" | "help" | "failure-note" => Severity::Info,
other => Severity::from_token(other),
}
}
#[derive(Debug, Deserialize)]
struct CargoMessage {
reason: String,
#[serde(default)]
message: Option<Diagnostic>,
#[serde(default)]
success: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct Diagnostic {
#[serde(default)]
message: String,
#[serde(default)]
level: String,
#[serde(default)]
code: Option<DiagnosticCode>,
#[serde(default)]
spans: Vec<DiagnosticSpan>,
}
#[derive(Debug, Deserialize)]
struct DiagnosticCode {
#[serde(default)]
code: String,
}
#[derive(Debug, Deserialize)]
struct DiagnosticSpan {
#[serde(default)]
file_name: String,
#[serde(default)]
byte_start: u64,
#[serde(default)]
byte_end: u64,
#[serde(default)]
line_start: u64,
#[serde(default)]
line_end: u64,
#[serde(default)]
column_start: u64,
#[serde(default)]
is_primary: bool,
}
#[cfg(test)]
mod tests {
use super::{ANALYZER, Clippy, FeatureSet, UNCODED_RULE, severity};
use crate::adapter::{Adapter, AssetPaths, NativeContext, adapter_for, known_analyzers};
use crate::runner::ExecError;
use rto_graph::{Severity, SourceIdentity};
fn ctx() -> NativeContext<'static> {
static SOURCE: std::sync::LazyLock<SourceIdentity> =
std::sync::LazyLock::new(SourceIdentity::default);
NativeContext {
started_at: "2026-08-18T09:00:00Z".to_owned(),
ended_at: "2026-08-18T09:04:00Z".to_owned(),
analyzer_version: Some("0.1.94".to_owned()),
exit_status: 101,
source: &SOURCE,
rules_digest: None,
advisory_db: None,
worktree: Some(std::path::Path::new("/checkout")),
snippets: &crate::snippet::NoSnippets,
}
}
const STREAM: &str = r#"
{"reason":"compiler-artifact","target":{"name":"rto-exec"},"fresh":false}
{"reason":"compiler-message","package_id":"path+file:///checkout#rto-exec@1.23.0","target":{"kind":["lib"],"name":"rto-exec"},"message":{"message":"this expression creates a reference which is immediately dereferenced by the compiler\nchange this to remove the borrow","code":{"code":"clippy::needless_borrow"},"level":"warning","spans":[{"file_name":"crates/rto-exec/src/lib.rs","byte_start":120,"byte_end":132,"line_start":9,"line_end":9,"column_start":13,"is_primary":true}]}}
{"reason":"compiler-message","package_id":"path+file:///checkout#rto-exec@1.23.0","target":{"kind":["test"],"name":"rto-exec"},"message":{"message":"this expression creates a reference which is immediately dereferenced by the compiler\nchange this to remove the borrow","code":{"code":"clippy::needless_borrow"},"level":"warning","spans":[{"file_name":"crates/rto-exec/src/lib.rs","byte_start":120,"byte_end":132,"line_start":9,"line_end":9,"column_start":13,"is_primary":true}]}}
{"reason":"compiler-message","message":{"message":"mismatched types","code":{"code":"E0308"},"level":"error","spans":[{"file_name":"/checkout/crates/roteiro/src/main.rs","byte_start":40,"byte_end":48,"line_start":3,"line_end":3,"column_start":5,"is_primary":true}]}}
{"reason":"compiler-message","message":{"message":"aborting due to 1 previous error","level":"error","spans":[]}}
{"reason":"compiler-message","message":{"message":"unused variable: `x`","code":{"code":"unused_variables"},"level":"warning","spans":[{"file_name":"/home/dev/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde-1.0.0/src/lib.rs","byte_start":1,"byte_end":2,"line_start":1,"line_end":1,"column_start":1,"is_primary":true}]}}
{"reason":"build-finished","success":false}
"#;
#[test]
fn normalizes_a_cargo_message_stream() {
let (report, summary) = Clippy::parse(STREAM.as_bytes(), &ctx()).expect("parse");
assert_eq!(report.analyzer, ANALYZER);
assert_eq!(report.analyzer_version, "0.1.94");
assert!(report.rules_digest.is_none());
assert!(report.advisory_db.is_none());
assert_eq!(summary.compiler_messages, 5);
assert!(!summary.build_succeeded, "the stream said `success: false`");
assert_eq!(summary.without_location, 1, "the `aborting due to` summary");
assert_eq!(summary.outside_worktree, 1, "the dependency's own source");
assert_eq!(summary.duplicates_collapsed, 1, "lib and test targets");
let rules: Vec<&str> = report.findings.iter().map(|f| f.rule.as_str()).collect();
assert_eq!(rules, vec!["E0308", "clippy::needless_borrow"]);
let lint = &report.findings[1];
assert_eq!(lint.severity, Severity::Medium);
assert_eq!(lint.path.as_deref(), Some("crates/rto-exec/src/lib.rs"));
assert_eq!(lint.span.map(|s| (s.start, s.end)), Some((120, 132)));
assert_eq!(
lint.title,
"this expression creates a reference which is immediately dereferenced by the compiler"
);
assert!(lint.message.contains("change this to remove the borrow"));
}
#[test]
fn relativises_an_absolute_path_inside_the_worktree() {
let (report, _) = Clippy::parse(STREAM.as_bytes(), &ctx()).expect("parse");
let error = &report.findings[0];
assert_eq!(error.path.as_deref(), Some("crates/roteiro/src/main.rs"));
assert_eq!(error.severity, Severity::High);
}
#[test]
fn refuses_a_stream_that_never_finished() {
for native in [
&b""[..],
&b"\n"[..],
&br#"{"reason":"compiler-artifact","fresh":true}"#[..],
&b"error: no such command: `clippy`"[..],
] {
let err = Clippy::parse(native, &ctx()).expect_err("must be refused");
assert!(matches!(err, ExecError::MalformedReport(_)));
assert!(
err.to_string().contains("build-finished"),
"the refusal must name what was missing: {err}"
);
}
}
#[test]
fn a_completed_clean_build_is_a_valid_empty_report() {
let (report, summary) =
Clippy::parse(br#"{"reason":"build-finished","success":true}"#, &ctx()).expect("parse");
assert!(report.findings.is_empty());
assert!(summary.build_succeeded);
assert_eq!(summary.compiler_messages, 0);
}
#[test]
fn a_diagnostic_with_no_lint_code_is_reported_under_a_name() {
let native = concat!(
r#"{"reason":"compiler-message","message":{"message":"expected a semicolon","#,
r#""level":"error","spans":[{"file_name":"src/a.rs","byte_start":4,"byte_end":5,"#,
r#""line_start":1,"line_end":1,"column_start":5,"is_primary":true}]}}"#,
"\n",
r#"{"reason":"build-finished","success":false}"#
);
let (report, _) = Clippy::parse(native.as_bytes(), &ctx()).expect("parse");
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, UNCODED_RULE);
}
#[test]
fn carries_no_package_or_version_so_it_cannot_enter_the_dependency_join() {
let (report, _) = Clippy::parse(STREAM.as_bytes(), &ctx()).expect("parse");
for finding in &report.findings {
assert!(finding.meta.get("package").is_none(), "{:?}", finding.meta);
assert!(finding.meta.get("version").is_none(), "{:?}", finding.meta);
}
}
#[test]
fn is_absent_from_the_registry_that_ingest_can_store() {
assert!(
adapter_for(ANALYZER).is_none(),
"clippy must not be resolvable as a storable analyzer"
);
assert!(
!known_analyzers().contains(&ANALYZER),
"clippy must not be offered by `ingest`"
);
}
#[test]
fn maps_rustc_levels_and_keeps_an_unknown_one_verbatim() {
for (raw, want) in [
("error", Severity::High),
("warning", Severity::Medium),
("note", Severity::Info),
("help", Severity::Info),
("failure-note", Severity::Info),
] {
assert_eq!(severity(raw), want, "{raw}");
}
assert_eq!(severity("lint"), Severity::Other("lint".to_owned()));
}
#[test]
fn the_invocation_mirrors_the_repository_gate_and_states_its_features() {
let default = Clippy::invocation(&FeatureSet::Defaults);
assert_eq!(default.program, "cargo");
assert_eq!(default.args[0], "clippy");
assert!(default.args.contains(&"--workspace".to_owned()));
assert!(default.args.contains(&"--all-targets".to_owned()));
assert!(default.args.contains(&"--message-format=json".to_owned()));
assert!(!default.args.iter().any(|a| a == "-D" || a == "warnings"));
assert_eq!(default.success_statuses, vec![0, 101]);
let all = Clippy::invocation(&FeatureSet::All);
assert!(all.args.contains(&"--all-features".to_owned()));
let some = Clippy::invocation(&FeatureSet::Explicit(vec![
"serve".to_owned(),
"mcp".to_owned(),
]));
let at = some
.args
.iter()
.position(|a| a == "--features")
.expect("--features");
assert_eq!(some.args[at + 1], "serve,mcp");
}
#[test]
fn every_feature_set_labels_itself() {
assert!(FeatureSet::Defaults.label().contains("default"));
assert!(FeatureSet::All.label().contains("--all-features"));
assert_eq!(
FeatureSet::Explicit(vec!["a".to_owned(), "b".to_owned()]).label(),
"a, b"
);
}
#[test]
fn declares_no_pinned_assets() {
assert!(Clippy.asset_ids().is_empty());
assert_eq!(Clippy.languages(), &["rust"]);
assert!(!Clippy.summary().is_empty());
assert_eq!(
Clippy.command(&AssetPaths::default()),
Clippy::invocation(&FeatureSet::Defaults)
);
}
}