Skip to main content

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