rto_exec/adapter/clippy.rs
1//! `clippy` — the Rust toolchain's own linter, normalised for a report that is
2//! **never stored**.
3//!
4//! This adapter has the same shape as every other one in this module: native
5//! analyzer output in, a [`NormalizedReport`] out, with the identity recipe and
6//! the severity mapping written beside the parser that needs them. What it does
7//! **not** have is a place in [`ADAPTERS`], and that omission is the decision
8//! rather than an oversight.
9//!
10//! # Why it is not in the registry
11//!
12//! [`ADAPTERS`] is the table `roteiro security ingest` consults, so anything in
13//! it can be filed as a findings layer. Clippy must not be, and leaving it out
14//! is what makes that structural instead of a rule someone has to remember:
15//! there is no `--analyzer clippy` for `ingest` to accept, no
16//! `security:clippy:<worktree>` layer key to collide, and no path from this file
17//! to [`rto_graph::Store::replace_findings_layer`].
18//!
19//! ADR-0020 v1.1 states the reason, and it is about what a lint *is*. An
20//! advisory id is **assigned**, and assignment is a promise: `RUSTSEC-2020-0071`
21//! will mean the same thing in five years, which is why it earns a row in a
22//! store. A lint name is a **symbol in a compiler** — renamed, removed, or moved
23//! between groups at the compiler's discretion, with the old name surviving only
24//! as a deprecation alias. The first is a durable fact about the repository; the
25//! second is a tool's opinion about the code as it stands today, for the person
26//! who asked.
27//!
28//! Storing the second is what produced every identity problem the investigation
29//! behind ADR-0020 found. A layer key renders `<prefix>:<analyzer>:<worktree-id>`
30//! and nothing else, `analyzer_version` is in neither the finding key nor the
31//! layer key, and the column is `UNIQUE` — so two runs of one commit differing
32//! only in toolchain version or feature set would collide, silently replace each
33//! other, and report the displaced findings as *removed*, which reads as
34//! **fixed**. For every stored analyzer the thing deciding the answer is a
35//! pinned asset with a digest; for a linter the rule set is the toolchain, and
36//! there is no asset to digest. Not storing removes all of it.
37//!
38//! # It carries no `package`/`version` pair, deliberately
39//!
40//! [`crate::crossref`] joins two findings when their identifier sets intersect
41//! **and** they name the same package at the same version, both read out of
42//! `meta`. A clippy finding therefore cannot enter that join, because this
43//! adapter never writes those two keys — the cargo message carries a
44//! `package_id` and it is deliberately dropped. That join's correctness rests on
45//! both upstreams publishing identifiers, and nobody publishes lint names; they
46//! are release notes.
47//!
48//! [`ADAPTERS`]: crate::adapter::ADAPTERS
49//!
50//! @rto:0012
51//! @rto:0020
52
53use std::path::Path;
54
55use serde::Deserialize;
56
57use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext, snippet_hash_at};
58use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
59use crate::runner::{ExecError, check_reported_path};
60use rto_graph::{Severity, Span};
61
62/// The analyzer id. It names a **reporting** analyzer, and is never the first
63/// component of a stored key, because nothing this adapter produces is stored.
64pub const ANALYZER: &str = "clippy";
65
66/// The rule recorded for a diagnostic that carries no lint code — a parse error,
67/// say. Such a diagnostic still has a location and still matters, so it is
68/// reported under a name rather than dropped: an empty result is the one thing a
69/// failed build must never look like.
70pub const UNCODED_RULE: &str = "rustc";
71
72/// Which features the build under review is resolved with.
73///
74/// Reported rather than assumed, because it is one of the two axes — the other
75/// being the toolchain — that move a lint count without the code changing. On
76/// this repository the difference is 355 crates and 54 build scripts at the
77/// default set against 672 and 87 at `--all-features` (ADR-0020), so a count
78/// quoted without its feature set is not comparable to any other count.
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub enum FeatureSet {
81 /// Whatever each crate declares as its default features.
82 #[default]
83 Defaults,
84 /// `--all-features`.
85 All,
86 /// `--features a,b,c`, as the caller wrote them.
87 Explicit(Vec<String>),
88}
89
90impl FeatureSet {
91 /// The cargo arguments this feature set contributes, in order.
92 #[must_use]
93 pub fn args(&self) -> Vec<String> {
94 match self {
95 Self::Defaults => Vec::new(),
96 Self::All => vec!["--all-features".to_owned()],
97 Self::Explicit(features) => {
98 vec!["--features".to_owned(), features.join(",")]
99 }
100 }
101 }
102
103 /// A one-line label for the report — never empty, so a reader is never left
104 /// to infer which of the three cases produced a count.
105 #[must_use]
106 pub fn label(&self) -> String {
107 match self {
108 Self::Defaults => "default (each crate's own default features)".to_owned(),
109 Self::All => "all (--all-features)".to_owned(),
110 Self::Explicit(features) => features.join(", "),
111 }
112 }
113}
114
115/// The adapter.
116#[derive(Debug, Clone, Copy)]
117pub struct Clippy;
118
119/// What a stream of cargo messages contained beyond the findings themselves.
120///
121/// Every field is a count of something that did **not** become a finding. They
122/// are reported rather than swallowed: a run that silently dropped half its
123/// diagnostics and printed a small number would be indistinguishable from a
124/// clean tree, which is the shape this project has been bitten by before.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
126pub struct Summary {
127 /// Whether cargo's `build-finished` message reported success. A failed build
128 /// still yields the diagnostics it managed to emit, and they are real — but
129 /// the set is partial, and a caller must say so.
130 pub build_succeeded: bool,
131 /// How many `compiler-message` entries the stream carried.
132 pub compiler_messages: usize,
133 /// Diagnostics with no primary span — rustc's own summaries ("aborting due
134 /// to 3 previous errors"), which are about the run rather than about a line
135 /// of code.
136 pub without_location: usize,
137 /// Diagnostics about a file outside the analyzed worktree — a dependency's
138 /// source under the cargo registry, most often.
139 pub outside_worktree: usize,
140 /// Identical diagnostics emitted more than once. `--all-targets` compiles
141 /// one file into several targets, so a lint in `src/main.rs` arrives once
142 /// per target; they are the same defect and are counted once.
143 pub duplicates_collapsed: usize,
144}
145
146impl Clippy {
147 /// The argv that produces the stream [`Clippy::normalize`] parses, at a
148 /// stated feature set.
149 ///
150 /// `--workspace --all-targets` mirrors the gate this repository already runs
151 /// (`AGENTS.md`), so the count a contributor sees here is the count CI will
152 /// see. `-D warnings` is deliberately **not** passed: this command reports,
153 /// and the levels the repository declares in `[workspace.lints]` are part of
154 /// what is being reported.
155 #[must_use]
156 pub fn invocation(features: &FeatureSet) -> Invocation {
157 let mut args = vec![
158 "clippy".to_owned(),
159 "--workspace".to_owned(),
160 "--all-targets".to_owned(),
161 // Not a reproducibility flourish — a write. Without it cargo creates
162 // or updates `Cargo.lock` when the manifest and the lockfile
163 // disagree, and it does that **in the tree being linted**, which is
164 // the tree `roteiro lint` promises to leave as it found it. Pointing
165 // `CARGO_TARGET_DIR` outside the worktree moves the build artefacts
166 // and does nothing about the lockfile; this is the other half of the
167 // same guarantee.
168 //
169 // The cost is real and is the correct one to pay: a tree whose
170 // lockfile is missing or stale now refuses to lint rather than
171 // silently being modified into a lintable one. `LintError::
172 // LockfileWouldBeWritten` is where that refusal is explained.
173 "--locked".to_owned(),
174 ];
175 args.extend(features.args());
176 args.push("--message-format=json".to_owned());
177 args.push("--quiet".to_owned());
178 Invocation {
179 program: "cargo".to_owned(),
180 args,
181 // 0 = the build completed. 101 = it did not, which for a repository
182 // that denies a lint group is the ordinary outcome of *finding
183 // something* — so treating it as failure would discard exactly the
184 // runs that matter. Every other status is a cargo that could not
185 // start, and falls through to a hard failure.
186 success_statuses: vec![0, 101],
187 }
188 }
189
190 /// The argv for a run whose network is denied **by a boundary** rather than
191 /// by good manners.
192 ///
193 /// [`Clippy::invocation`] plus `--offline`, and the two are separate
194 /// functions rather than a flag on one because they describe different
195 /// situations rather than different preferences. On the host, cargo may
196 /// legitimately reach a registry for a dependency the user has not fetched
197 /// yet; that is their machine and their choice, and `--locked` already stops
198 /// the one write into the tree that would follow. In a guest there is no
199 /// interface to reach it with, so the question is only whether cargo finds
200 /// that out from `--offline` or from a DNS timeout inside a VM.
201 ///
202 /// It is worth the flag for the error message alone. Without it a missing
203 /// crate surfaces as a network failure from inside a machine the user cannot
204 /// see; with it, cargo says *"attempting to make an HTTP request, but
205 /// --offline was specified"*, which [`crate::lint_sandbox`] turns into the
206 /// one thing that would actually help — fetch it on the host first.
207 #[must_use]
208 pub fn offline_invocation(features: &FeatureSet) -> Invocation {
209 let mut invocation = Self::invocation(features);
210 // Ahead of `--message-format`/`--quiet` only because `invocation`
211 // appends those last; cargo does not care about order, and this keeps
212 // the two argvs differing by exactly one token wherever they are printed
213 // side by side.
214 invocation.args.push("--offline".to_owned());
215 invocation
216 }
217
218 /// Parse a cargo `--message-format=json` stream, returning the normalised
219 /// report **and** what the stream contained besides findings.
220 ///
221 /// [`Adapter::normalize`] is this without the second half; the counts exist
222 /// because the ephemeral report prints them, and a trait shared with stored
223 /// analyzers has nowhere to carry them.
224 ///
225 /// # Errors
226 /// Returns [`ExecError::MalformedReport`] when the stream carries no
227 /// `build-finished` message — the marker that distinguishes a completed
228 /// cargo run from empty output, and therefore a clean tree from a run that
229 /// never happened.
230 pub fn parse(
231 native: &[u8],
232 ctx: &NativeContext<'_>,
233 ) -> Result<(NormalizedReport, Summary), ExecError> {
234 let text = String::from_utf8_lossy(native);
235 let mut summary = Summary::default();
236 let mut finished = false;
237 let mut findings: Vec<ReportFinding> = Vec::new();
238
239 for line in text.lines().filter(|l| !l.trim().is_empty()) {
240 // A line this build cannot read is not a reason to lose the run:
241 // cargo adds message kinds between releases, and every one of them
242 // carries its own `reason`. Unknown shapes are skipped, and the
243 // `build-finished` requirement below is what stops that leniency
244 // from turning junk into a clean report.
245 let Ok(message) = serde_json::from_str::<CargoMessage>(line) else {
246 continue;
247 };
248 match message.reason.as_str() {
249 "build-finished" => {
250 finished = true;
251 summary.build_succeeded = message.success.unwrap_or(false);
252 }
253 "compiler-message" => {
254 summary.compiler_messages += 1;
255 if let Some(diagnostic) = message.message {
256 convert(&diagnostic, ctx, &mut summary, &mut findings);
257 }
258 }
259 _ => {}
260 }
261 }
262
263 if !finished {
264 return Err(ExecError::MalformedReport(
265 "not a cargo --message-format=json stream: no `build-finished` message, so this \
266 is not a completed run and its emptiness means nothing"
267 .to_owned(),
268 ));
269 }
270
271 findings.sort_by(|a, b| a.identity.cmp(&b.identity));
272 let before = findings.len();
273 findings.dedup_by(|a, b| a.identity == b.identity);
274 summary.duplicates_collapsed = before - findings.len();
275
276 Ok((
277 NormalizedReport {
278 schema: REPORT_SCHEMA.to_owned(),
279 analyzer: ANALYZER.to_owned(),
280 analyzer_version: ctx.version_or(None),
281 started_at: ctx.started_at.clone(),
282 ended_at: ctx.ended_at.clone(),
283 exit_status: ctx.exit_status,
284 // There is no rule set to digest. The rules **are** the
285 // toolchain plus the repository's own `[workspace.lints]`, and
286 // neither is a pinned asset — which is precisely why this
287 // analyzer's output is reported rather than stored.
288 rules_digest: None,
289 image_digest: None,
290 // A linter consults no advisory database. Claiming one would put
291 // a staleness label on a result that has no such axis.
292 advisory_db: None,
293 source: ctx.source.clone(),
294 findings,
295 },
296 summary,
297 ))
298 }
299}
300
301impl Adapter for Clippy {
302 fn analyzer(&self) -> &'static str {
303 ANALYZER
304 }
305
306 fn summary(&self) -> &'static str {
307 "Rust lints from the toolchain's own linter, reported and never stored"
308 }
309
310 fn languages(&self) -> &'static [&'static str] {
311 &["rust"]
312 }
313
314 fn asset_ids(&self) -> &'static [&'static str] {
315 // None, and not because it happens to need nothing: a linter's rule set
316 // is the toolchain it ships with, so there is no asset to pin and no
317 // digest to record. That is the difference this whole adapter turns on.
318 &[]
319 }
320
321 fn host_programs(&self) -> &'static [&'static str] {
322 // The same two-part shape as `cargo-audit`, for the same reason: `cargo
323 // clippy` dispatches to a `cargo-clippy` binary on `PATH`. Unlike the
324 // others this is a toolchain component rather than a separate install —
325 // `rustup component add clippy` — but the *check* is identical, and this
326 // adapter is not in `ADAPTERS`, so `roteiro security status` never reports
327 // it. Declared because the trait requires an answer and a wrong one here
328 // would be waiting for whoever wires a lint status later.
329 &["cargo", "cargo-clippy"]
330 }
331
332 fn command(&self, _assets: &AssetPaths<'_>) -> Invocation {
333 Self::invocation(&FeatureSet::Defaults)
334 }
335
336 fn normalize(
337 &self,
338 native: &[u8],
339 ctx: &NativeContext<'_>,
340 ) -> Result<NormalizedReport, ExecError> {
341 Self::parse(native, ctx).map(|(report, _)| report)
342 }
343}
344
345/// Convert one rustc diagnostic, or account for why it produced no finding.
346fn convert(
347 diagnostic: &Diagnostic,
348 ctx: &NativeContext<'_>,
349 summary: &mut Summary,
350 findings: &mut Vec<ReportFinding>,
351) {
352 let Some(span) = primary_span(diagnostic) else {
353 summary.without_location += 1;
354 return;
355 };
356 let Some(path) = worktree_relative(&span.file_name, ctx.worktree) else {
357 summary.outside_worktree += 1;
358 return;
359 };
360 let start = u32::try_from(span.byte_start).unwrap_or(u32::MAX);
361 let end = u32::try_from(span.byte_end).unwrap_or(u32::MAX).max(start);
362 let message = diagnostic.message.trim();
363 let rule = diagnostic
364 .code
365 .as_ref()
366 .map(|c| c.code.trim())
367 .filter(|c| !c.is_empty())
368 .unwrap_or(UNCODED_RULE)
369 .to_owned();
370
371 findings.push(ReportFinding {
372 // The recipe semgrep uses — rule, path, start byte, snippet hash — for
373 // the same reason it uses it, minus the durability claim: here it orders
374 // the report and collapses the same lint reported once per target. It is
375 // never a stored key, because there is no store to put it in.
376 identity: vec![
377 rule.clone(),
378 path.clone(),
379 start.to_string(),
380 snippet_hash_at(ctx.snippets, &path, start, end),
381 ],
382 rule,
383 severity: severity(&diagnostic.level),
384 title: title_from(message, &span.file_name),
385 message: message.to_owned(),
386 path: Some(path),
387 span: Some(Span::new(start, end)),
388 meta: serde_json::json!({
389 "line": span.line_start,
390 "column": span.column_start,
391 "end_line": span.line_end,
392 "rustc_level": diagnostic.level,
393 }),
394 });
395}
396
397/// The span a diagnostic is *about*: its primary one, else its first.
398fn primary_span(diagnostic: &Diagnostic) -> Option<&DiagnosticSpan> {
399 diagnostic
400 .spans
401 .iter()
402 .find(|s| s.is_primary)
403 .or_else(|| diagnostic.spans.first())
404}
405
406/// Place a reported file inside the analyzed worktree, or refuse it.
407///
408/// Cargo reports workspace-relative paths for the crates it is building and
409/// absolute ones for anything else, so both shapes arrive. An absolute path
410/// under the worktree is relativised; one outside it — a dependency's source in
411/// the cargo registry — is not a claim about this repository and is dropped.
412/// A relative path that climbs out is refused by the same check the stored path
413/// uses, so the two agree on what "inside the tree" means.
414fn worktree_relative(file: &str, worktree: Option<&Path>) -> Option<String> {
415 let path = Path::new(file);
416 let relative = if path.is_absolute() {
417 path.strip_prefix(worktree?).ok()?
418 } else {
419 path.strip_prefix("./").unwrap_or(path)
420 };
421 let text = relative.to_string_lossy().into_owned();
422 check_reported_path(&text).ok()?;
423 Some(text)
424}
425
426/// The first line of `message`, falling back to the file name when a diagnostic
427/// somehow carries no text at all — a titleless finding is refused downstream,
428/// and anything is more use than a blank.
429fn title_from(message: &str, file: &str) -> String {
430 let first = message.lines().next().unwrap_or("").trim();
431 if first.is_empty() {
432 file.to_owned()
433 } else {
434 first.to_owned()
435 }
436}
437
438/// Map rustc's diagnostic levels onto [`Severity`].
439///
440/// A lint's level is the level the *repository* configured — `[workspace.lints]`
441/// is what makes `clippy::all` an error here — so this is a faithful record of
442/// how the toolchain was told to treat it, not a judgement of how bad it is.
443fn severity(level: &str) -> Severity {
444 match level.trim().to_ascii_lowercase().as_str() {
445 "error" | "error: internal compiler error" => Severity::High,
446 "warning" => Severity::Medium,
447 "note" | "help" | "failure-note" => Severity::Info,
448 other => Severity::from_token(other),
449 }
450}
451
452/// One line of `cargo --message-format=json`, narrowed to what is needed.
453///
454/// `package_id` is **not** deserialized, and that is load-bearing rather than
455/// economical: it is the one field that could be turned into the
456/// `meta.package` / `meta.version` pair [`crate::crossref`] joins on, and a lint
457/// must never enter that join.
458#[derive(Debug, Deserialize)]
459struct CargoMessage {
460 reason: String,
461 #[serde(default)]
462 message: Option<Diagnostic>,
463 /// `build-finished` only.
464 #[serde(default)]
465 success: Option<bool>,
466}
467
468/// A rustc diagnostic, as cargo forwards it.
469#[derive(Debug, Deserialize)]
470struct Diagnostic {
471 #[serde(default)]
472 message: String,
473 #[serde(default)]
474 level: String,
475 #[serde(default)]
476 code: Option<DiagnosticCode>,
477 #[serde(default)]
478 spans: Vec<DiagnosticSpan>,
479}
480
481#[derive(Debug, Deserialize)]
482struct DiagnosticCode {
483 #[serde(default)]
484 code: String,
485}
486
487#[derive(Debug, Deserialize)]
488struct DiagnosticSpan {
489 #[serde(default)]
490 file_name: String,
491 #[serde(default)]
492 byte_start: u64,
493 #[serde(default)]
494 byte_end: u64,
495 #[serde(default)]
496 line_start: u64,
497 #[serde(default)]
498 line_end: u64,
499 #[serde(default)]
500 column_start: u64,
501 #[serde(default)]
502 is_primary: bool,
503}
504
505#[cfg(test)]
506mod tests {
507 use super::{ANALYZER, Clippy, FeatureSet, UNCODED_RULE, severity};
508 use crate::adapter::{Adapter, AssetPaths, NativeContext, adapter_for, known_analyzers};
509 use crate::runner::ExecError;
510 use rto_graph::{Severity, SourceIdentity};
511
512 fn ctx() -> NativeContext<'static> {
513 static SOURCE: std::sync::LazyLock<SourceIdentity> =
514 std::sync::LazyLock::new(SourceIdentity::default);
515 NativeContext {
516 started_at: "2026-08-18T09:00:00Z".to_owned(),
517 ended_at: "2026-08-18T09:04:00Z".to_owned(),
518 analyzer_version: Some("0.1.94".to_owned()),
519 exit_status: 101,
520 source: &SOURCE,
521 rules_digest: None,
522 advisory_db: None,
523 worktree: Some(std::path::Path::new("/checkout")),
524 snippets: &crate::snippet::NoSnippets,
525 }
526 }
527
528 /// A stream in the shape cargo emits: one clippy lint, the same lint again
529 /// from a second target, a coded rustc error, a location-less summary, a
530 /// diagnostic about a dependency's source, and the terminator.
531 const STREAM: &str = r#"
532{"reason":"compiler-artifact","target":{"name":"rto-exec"},"fresh":false}
533{"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}]}}
534{"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}]}}
535{"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}]}}
536{"reason":"compiler-message","message":{"message":"aborting due to 1 previous error","level":"error","spans":[]}}
537{"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}]}}
538{"reason":"build-finished","success":false}
539"#;
540
541 #[test]
542 fn normalizes_a_cargo_message_stream() {
543 let (report, summary) = Clippy::parse(STREAM.as_bytes(), &ctx()).expect("parse");
544 assert_eq!(report.analyzer, ANALYZER);
545 assert_eq!(report.analyzer_version, "0.1.94");
546 // A linter pins nothing: its rule set is the toolchain, and there is no
547 // advisory database in the picture at all.
548 assert!(report.rules_digest.is_none());
549 assert!(report.advisory_db.is_none());
550
551 assert_eq!(summary.compiler_messages, 5);
552 assert!(!summary.build_succeeded, "the stream said `success: false`");
553 assert_eq!(summary.without_location, 1, "the `aborting due to` summary");
554 assert_eq!(summary.outside_worktree, 1, "the dependency's own source");
555 assert_eq!(summary.duplicates_collapsed, 1, "lib and test targets");
556
557 let rules: Vec<&str> = report.findings.iter().map(|f| f.rule.as_str()).collect();
558 assert_eq!(rules, vec!["E0308", "clippy::needless_borrow"]);
559
560 let lint = &report.findings[1];
561 assert_eq!(lint.severity, Severity::Medium);
562 assert_eq!(lint.path.as_deref(), Some("crates/rto-exec/src/lib.rs"));
563 assert_eq!(lint.span.map(|s| (s.start, s.end)), Some((120, 132)));
564 // The title is one line; the whole diagnostic survives in `message`.
565 assert_eq!(
566 lint.title,
567 "this expression creates a reference which is immediately dereferenced by the compiler"
568 );
569 assert!(lint.message.contains("change this to remove the borrow"));
570 }
571
572 /// An absolute path inside the checkout is relativised, so the report reads
573 /// the same on two machines and carries nobody's home directory.
574 #[test]
575 fn relativises_an_absolute_path_inside_the_worktree() {
576 let (report, _) = Clippy::parse(STREAM.as_bytes(), &ctx()).expect("parse");
577 let error = &report.findings[0];
578 assert_eq!(error.path.as_deref(), Some("crates/roteiro/src/main.rs"));
579 assert_eq!(error.severity, Severity::High);
580 }
581
582 /// The whole point of requiring `build-finished`: empty output from a cargo
583 /// that never ran must not read as a tree with nothing wrong in it.
584 #[test]
585 fn refuses_a_stream_that_never_finished() {
586 for native in [
587 &b""[..],
588 &b"\n"[..],
589 &br#"{"reason":"compiler-artifact","fresh":true}"#[..],
590 &b"error: no such command: `clippy`"[..],
591 ] {
592 let err = Clippy::parse(native, &ctx()).expect_err("must be refused");
593 assert!(matches!(err, ExecError::MalformedReport(_)));
594 assert!(
595 err.to_string().contains("build-finished"),
596 "the refusal must name what was missing: {err}"
597 );
598 }
599 }
600
601 #[test]
602 fn a_completed_clean_build_is_a_valid_empty_report() {
603 let (report, summary) =
604 Clippy::parse(br#"{"reason":"build-finished","success":true}"#, &ctx()).expect("parse");
605 assert!(report.findings.is_empty());
606 assert!(summary.build_succeeded);
607 assert_eq!(summary.compiler_messages, 0);
608 }
609
610 /// A diagnostic with no lint code still has a location, and losing it would
611 /// hide a build failure behind an empty result.
612 #[test]
613 fn a_diagnostic_with_no_lint_code_is_reported_under_a_name() {
614 // One cargo message per line, as cargo emits them: this parser reads a
615 // stream, not a document, and a message split across lines is not one.
616 let native = concat!(
617 r#"{"reason":"compiler-message","message":{"message":"expected a semicolon","#,
618 r#""level":"error","spans":[{"file_name":"src/a.rs","byte_start":4,"byte_end":5,"#,
619 r#""line_start":1,"line_end":1,"column_start":5,"is_primary":true}]}}"#,
620 "\n",
621 r#"{"reason":"build-finished","success":false}"#
622 );
623 let (report, _) = Clippy::parse(native.as_bytes(), &ctx()).expect("parse");
624 assert_eq!(report.findings.len(), 1);
625 assert_eq!(report.findings[0].rule, UNCODED_RULE);
626 }
627
628 /// The join in [`crate::crossref`] requires a `package` **and** a `version`
629 /// in `meta`. A lint carries neither, so it cannot take part — and the cargo
630 /// message's `package_id` must never be turned into them.
631 #[test]
632 fn carries_no_package_or_version_so_it_cannot_enter_the_dependency_join() {
633 let (report, _) = Clippy::parse(STREAM.as_bytes(), &ctx()).expect("parse");
634 for finding in &report.findings {
635 assert!(finding.meta.get("package").is_none(), "{:?}", finding.meta);
636 assert!(finding.meta.get("version").is_none(), "{:?}", finding.meta);
637 }
638 }
639
640 /// The structural half of "a lint is never stored": `ingest` resolves an
641 /// analyzer through the registry, and clippy is not in it. Adding it there
642 /// would make `roteiro security ingest --analyzer clippy` file a layer.
643 #[test]
644 fn is_absent_from_the_registry_that_ingest_can_store() {
645 assert!(
646 adapter_for(ANALYZER).is_none(),
647 "clippy must not be resolvable as a storable analyzer"
648 );
649 assert!(
650 !known_analyzers().contains(&ANALYZER),
651 "clippy must not be offered by `ingest`"
652 );
653 }
654
655 #[test]
656 fn maps_rustc_levels_and_keeps_an_unknown_one_verbatim() {
657 for (raw, want) in [
658 ("error", Severity::High),
659 ("warning", Severity::Medium),
660 ("note", Severity::Info),
661 ("help", Severity::Info),
662 ("failure-note", Severity::Info),
663 ] {
664 assert_eq!(severity(raw), want, "{raw}");
665 }
666 assert_eq!(severity("lint"), Severity::Other("lint".to_owned()));
667 }
668
669 #[test]
670 fn the_invocation_mirrors_the_repository_gate_and_states_its_features() {
671 let default = Clippy::invocation(&FeatureSet::Defaults);
672 assert_eq!(default.program, "cargo");
673 assert_eq!(default.args[0], "clippy");
674 assert!(default.args.contains(&"--workspace".to_owned()));
675 assert!(default.args.contains(&"--all-targets".to_owned()));
676 assert!(default.args.contains(&"--message-format=json".to_owned()));
677 // Reporting, not gating: the levels the repository declares are part of
678 // what is being reported, so they are not overridden.
679 assert!(!default.args.iter().any(|a| a == "-D" || a == "warnings"));
680 // 101 is what cargo exits with when a denied lint fired, which is the
681 // run that matters most.
682 assert_eq!(default.success_statuses, vec![0, 101]);
683
684 let all = Clippy::invocation(&FeatureSet::All);
685 assert!(all.args.contains(&"--all-features".to_owned()));
686
687 let some = Clippy::invocation(&FeatureSet::Explicit(vec![
688 "serve".to_owned(),
689 "mcp".to_owned(),
690 ]));
691 let at = some
692 .args
693 .iter()
694 .position(|a| a == "--features")
695 .expect("--features");
696 assert_eq!(some.args[at + 1], "serve,mcp");
697 }
698
699 #[test]
700 fn every_feature_set_labels_itself() {
701 assert!(FeatureSet::Defaults.label().contains("default"));
702 assert!(FeatureSet::All.label().contains("--all-features"));
703 assert_eq!(
704 FeatureSet::Explicit(vec!["a".to_owned(), "b".to_owned()]).label(),
705 "a, b"
706 );
707 }
708
709 /// The rule set is the toolchain, so there is nothing to provision and
710 /// nothing to digest — the fact the whole decision turns on.
711 #[test]
712 fn declares_no_pinned_assets() {
713 assert!(Clippy.asset_ids().is_empty());
714 assert_eq!(Clippy.languages(), &["rust"]);
715 assert!(!Clippy.summary().is_empty());
716 assert_eq!(
717 Clippy.command(&AssetPaths::default()),
718 Clippy::invocation(&FeatureSet::Defaults)
719 );
720 }
721}