Skip to main content

rust_doctor/
report.rs

1//! The shape a scan publishes, and the one place its version is declared.
2//!
3//! This file is the wire format and nothing else: the request that starts a
4//! scan, the report that comes back, and the closed vocabularies its members
5//! draw from. How a report is assembled lives in [`assembly`], how a
6//! producer's finding becomes one of its diagnostics in [`normalize`], and
7//! what is taken out of the text a scan produced in [`sanitize`].
8//!
9//! Any change to the shape below bumps [`SCHEMA_VERSION`], and the frozen v7
10//! archive keeps projecting from it.
11
12use std::collections::BTreeSet;
13use std::fmt;
14use std::path::{Component, Path, PathBuf};
15
16use serde::ser::{Error as _, SerializeStruct};
17use serde::{Serialize, Serializer};
18
19use crate::audit::{Audit, SeverityCounts};
20use crate::delta::DeltaReport;
21use crate::git_scope::{ScopeReport, ScopeRequest};
22use crate::policy::{
23    BlockingLevel, BlockingLevelSource, CategoryOverride, CorpusMeasurement, PolicyInput,
24    PolicyPlan, RuleLevel, RuleLevelSource, RuleOverride, RuleTier,
25};
26
27mod assembly;
28mod normalize;
29mod sanitize;
30
31pub(crate) use assembly::{
32    baseline_report_failure, from_baseline_execution, from_execution_scoped, policy_failure,
33    preparation_failure, scope_failure,
34};
35
36pub const SCHEMA_VERSION: u8 = 16;
37
38#[derive(Debug, Clone)]
39pub struct InspectRequest {
40    pub path: PathBuf,
41    policy: PolicyInput,
42    scope: ScopeRequest,
43}
44
45impl InspectRequest {
46    pub fn new(path: impl Into<PathBuf>) -> Self {
47        Self {
48            path: path.into(),
49            policy: PolicyInput::default(),
50            scope: ScopeRequest::Full,
51        }
52    }
53
54    pub fn with_rule_override(mut self, rule_override: RuleOverride) -> Self {
55        self.policy.push_rule(rule_override);
56        self
57    }
58
59    pub fn with_category_override(mut self, category_override: CategoryOverride) -> Self {
60        self.policy.push_category(category_override);
61        self
62    }
63
64    pub fn with_blocking(mut self, blocking: BlockingLevel) -> Self {
65        self.policy = self.policy.with_blocking(blocking);
66        self
67    }
68
69    pub fn with_files_scope(mut self, base: impl Into<String>) -> Self {
70        self.scope = ScopeRequest::Files { base: base.into() };
71        self
72    }
73
74    pub fn with_baseline_scope(mut self, base: impl Into<String>) -> Self {
75        self.scope = ScopeRequest::Baseline { base: base.into() };
76        self
77    }
78
79    pub(crate) const fn policy(&self) -> &PolicyInput {
80        &self.policy
81    }
82
83    pub(crate) const fn scope(&self) -> &ScopeRequest {
84        &self.scope
85    }
86}
87
88impl Default for InspectRequest {
89    fn default() -> Self {
90        Self::new(".")
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct InspectReport {
96    pub schema_version: u8,
97    pub audit: Audit,
98    pub status: Status,
99    pub complete: bool,
100    pub policy: Option<PolicyReport>,
101    pub scope: Option<ScopeReport>,
102    pub project: Option<ProjectReport>,
103    pub toolchain: ToolchainReport,
104    pub scan: ScanReport,
105    pub diagnostics: Vec<Diagnostic>,
106    pub delta: Option<DeltaReport>,
107    pub errors: Vec<ReportError>,
108    pub summary: Summary,
109    pub gate: GateReport,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct PolicyReport {
114    pub config_file: Option<String>,
115    pub blocking: PolicyBlockingReport,
116    pub rules: Vec<PolicyRuleReport>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120pub struct PolicyBlockingReport {
121    pub level: BlockingLevel,
122    pub source: BlockingLevelSource,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
126pub struct PolicyRuleReport {
127    pub id: String,
128    pub category: String,
129    pub tier: RuleTier,
130    pub level: RuleLevel,
131    pub source: RuleLevelSource,
132    /// Smoothed false-positive rate of this rule on the pinned corpus, in
133    /// basis points, absent when the corpus never adjudicated it.
134    ///
135    /// It is published because it ranks: the report tells the user what to fix
136    /// first by discounting each rule's cost by this rate, so a rule with many
137    /// findings can be left out of that list, and without the number the
138    /// omission reads as a defect of the tool rather than a measurement.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub corpus_noise_basis_points: Option<u16>,
141    /// How many sites the rate above rests on.
142    ///
143    /// A rate published alone is a rate the reader cannot weigh: one adjudicated
144    /// site and forty adjudicated sites are two different claims, and the
145    /// smoothing that separates them is invisible in the rate it produces. The
146    /// two members move together, so a rule carrying neither is a rule the
147    /// corpus never adjudicated rather than a rule measured at zero.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub corpus_reviewed_sites: Option<u64>,
150}
151
152impl PolicyReport {
153    fn from_plan(plan: &PolicyPlan) -> Self {
154        Self {
155            config_file: plan.config_file().map(str::to_owned),
156            blocking: PolicyBlockingReport {
157                level: plan.blocking(),
158                source: plan.blocking_source(),
159            },
160            rules: plan
161                .effective_rules()
162                .map(|(definition, level, source)| {
163                    let measurement = crate::policy::corpus_measurement(definition.id);
164                    PolicyRuleReport {
165                        id: definition.id.to_owned(),
166                        category: definition.category.to_owned(),
167                        tier: definition.tier,
168                        level,
169                        source,
170                        corpus_noise_basis_points: measurement
171                            .map(CorpusMeasurement::noise_basis_points),
172                        corpus_reviewed_sites: measurement.map(CorpusMeasurement::reviewed),
173                    }
174                })
175                .collect(),
176        }
177    }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
181#[serde(rename_all = "lowercase")]
182pub enum Status {
183    Complete,
184    Incomplete,
185    Failed,
186}
187
188impl Status {
189    pub const fn as_str(self) -> &'static str {
190        match self {
191            Self::Complete => "complete",
192            Self::Incomplete => "incomplete",
193            Self::Failed => "failed",
194        }
195    }
196}
197
198impl InspectReport {
199    /// A report is publishable only when its counts agree.
200    ///
201    /// `summary` describes the whole set of diagnostics of the report. `audit`
202    /// describes the score scope: the complete report, or the introduced
203    /// diagnostics alone when a delta is present. Both quantities, distinct
204    /// diagnostics and occurrences, are checked separately.
205    pub fn is_valid(&self) -> bool {
206        if self.schema_version != SCHEMA_VERSION || !self.audit.is_valid() {
207            return false;
208        }
209        if self.summary != Summary::from_diagnostics(&self.diagnostics) {
210            return false;
211        }
212        let Some(delta) = &self.delta else {
213            let (distinct, occurrences) = self.audit.totals();
214            return self.audit == self.audit.rebuild_for_scope(self.status, &self.diagnostics)
215                && distinct == self.summary.distinct
216                && occurrences == self.summary.occurrences;
217        };
218        let introduced: BTreeSet<_> = delta.introduced.iter().map(String::as_str).collect();
219        let scoped: Vec<_> = self
220            .diagnostics
221            .iter()
222            .filter(|diagnostic| introduced.contains(diagnostic.id.as_str()))
223            .cloned()
224            .collect();
225        self.audit == self.audit.rebuild_for_scope(self.status, &scoped)
226    }
227}
228
229impl Serialize for InspectReport {
230    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
231    where
232        S: Serializer,
233    {
234        if !self.is_valid() {
235            return Err(S::Error::custom("invalid report state"));
236        }
237        let mut state = serializer.serialize_struct("InspectReport", 14)?;
238        state.serialize_field("schema_version", &self.schema_version)?;
239        state.serialize_field("audit", &self.audit)?;
240        state.serialize_field("status", &self.status)?;
241        state.serialize_field("complete", &self.complete)?;
242        state.serialize_field("policy", &self.policy)?;
243        state.serialize_field("scope", &self.scope)?;
244        state.serialize_field("project", &self.project)?;
245        state.serialize_field("toolchain", &self.toolchain)?;
246        state.serialize_field("scan", &self.scan)?;
247        state.serialize_field("diagnostics", &self.diagnostics)?;
248        state.serialize_field("delta", &self.delta)?;
249        state.serialize_field("errors", &self.errors)?;
250        state.serialize_field("summary", &self.summary)?;
251        state.serialize_field("gate", &self.gate)?;
252        state.end()
253    }
254}
255
256impl fmt::Display for Status {
257    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
258        formatter.write_str(self.as_str())
259    }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263pub struct ProjectReport {
264    pub workspace_root: String,
265    pub manifest_path: String,
266    pub packages: Vec<PackageReport>,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
270pub struct PackageReport {
271    pub name: String,
272    pub manifest_path: Option<String>,
273    pub targets: Vec<String>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
277pub struct ToolchainReport {
278    pub rustc: Option<String>,
279    pub cargo: Option<String>,
280    pub clippy: Option<String>,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
284pub struct ScanReport {
285    pub command: Option<Vec<String>>,
286    pub exit_code: Option<i32>,
287    pub build_finished: Option<bool>,
288    pub noise_lines: Option<usize>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
292pub struct Diagnostic {
293    pub id: String,
294    pub source: DiagnosticSource,
295    pub code: Option<String>,
296    pub base_severity: Severity,
297    pub severity: Severity,
298    pub category: Option<String>,
299    pub message: String,
300    pub help: Option<String>,
301    pub package: Option<String>,
302    pub target: Option<String>,
303    /// Non-production target the diagnostic comes from, absent otherwise.
304    ///
305    /// Shipped code is not marked: its lack of a mark is what designates it,
306    /// exactly like react-doctor's `fileContext`, which stamps only `test` and
307    /// `story`. A marked diagnostic stays published and counted, but stops
308    /// weighing on the score and stops blocking: a `println!` in `build.rs` is
309    /// the channel Cargo imposes, not a defect of the shipped codebase.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub context: Option<DiagnosticContext>,
312    pub path: Option<String>,
313    pub span: Option<DiagnosticSpan>,
314    /// Every other site the finding spans, workspace-relative.
315    ///
316    /// A structural finding is a family: reporting one member per diagnostic
317    /// would turn a helper cloned six times into six unrelated spans. The key
318    /// is absent rather than empty when a finding names a single site, so a
319    /// per-site diagnostic serializes exactly as it did before this field
320    /// existed.
321    #[serde(skip_serializing_if = "Vec::is_empty")]
322    pub related: Vec<RelatedLocation>,
323    /// How alike the sites of the finding are, in basis points, when the rule
324    /// grouped them on a similarity rather than on an equality.
325    ///
326    /// A finding whose members are exactly equal does not carry it: publishing
327    /// 10000 on every exact family would say nothing, and absence is what
328    /// distinguishes "these are the same" from "these are 87 % the same".
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub similarity_basis_points: Option<u16>,
331    /// Cyclomatic and cognitive complexity of the reported function, when the
332    /// rule measured them. Absent on every other diagnostic, so a report that
333    /// carries no hotspot serializes exactly as it did before this field
334    /// existed.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub complexity: Option<ComplexityFigures>,
337    pub occurrences: usize,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
341pub struct RelatedLocation {
342    pub path: String,
343    pub span: DiagnosticSpan,
344}
345
346/// Both complexity figures of one function, published together because each
347/// answers what the other cannot: cyclomatic counts the paths a test suite has
348/// to cover, cognitive weights the nesting a reader has to hold.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
350pub struct ComplexityFigures {
351    pub cyclomatic: u32,
352    pub cognitive: u32,
353}
354
355/// Non-production target a diagnostic comes from, derived from the target kind
356/// Cargo declares. A library or a binary are not represented: they are the
357/// production, and a diagnostic coming from them simply does not carry this
358/// field.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
360#[serde(rename_all = "kebab-case")]
361pub enum DiagnosticContext {
362    /// Integration test target, under `tests/`.
363    Tests,
364    /// Measurement bench, under `benches/`.
365    Benchmark,
366    /// Demonstration, under `examples/`.
367    Example,
368    /// Build script executed by Cargo.
369    BuildScript,
370}
371
372impl DiagnosticContext {
373    /// Closed reading of a Cargo target kind. A library, a binary and an
374    /// unknown value are not marked: when in doubt, the diagnostic counts,
375    /// because silencing a defect of the shipped code is the only mistake this
376    /// field can make expensive.
377    pub(crate) fn from_target_kinds(kinds: &[String]) -> Option<Self> {
378        kinds.iter().find_map(|kind| match kind.as_str() {
379            "test" => Some(Self::Tests),
380            "bench" => Some(Self::Benchmark),
381            "example" => Some(Self::Example),
382            "custom-build" => Some(Self::BuildScript),
383            _ => None,
384        })
385    }
386
387    /// Closed reading of a workspace-relative path, for a file no Cargo target
388    /// and no module declaration speaks for.
389    ///
390    /// This is the last evidence there is, and the only producer that needs it
391    /// is the orphan walk: a file compiled by nothing is reached by nothing, so
392    /// neither the target kind above nor the `cfg(test)` gate the source walk
393    /// propagates has anything to say about it. The convention is Cargo's own,
394    /// `tests/`, `benches/` and `examples/`, read on the outermost directory
395    /// that matches, so `benches/tests.rs` is a bench and not a test. A file
396    /// named `tests.rs` is the module spelling of the same convention and is
397    /// read last, since a directory above it is the stronger claim.
398    ///
399    /// Anything else is not marked, for the reason `from_target_kinds` gives:
400    /// silencing a defect of the shipped code is the only mistake this field
401    /// can make expensive.
402    pub(crate) fn from_conventional_path(path: &str) -> Option<Self> {
403        let path = Path::new(path);
404        path.parent()
405            .into_iter()
406            .flat_map(Path::components)
407            .find_map(|component| match component {
408                Component::Normal(name) if name == "tests" => Some(Self::Tests),
409                Component::Normal(name) if name == "benches" => Some(Self::Benchmark),
410                Component::Normal(name) if name == "examples" => Some(Self::Example),
411                _ => None,
412            })
413            .or_else(|| (path.file_name()? == "tests.rs").then_some(Self::Tests))
414    }
415
416    /// Does a diagnostic weigh on the score and on the gate?
417    ///
418    /// This is the decision react-doctor makes in `filterForSurface`: a
419    /// diagnostic stamped with a non-production context leaves the `score` and
420    /// `ciFailure` surfaces, and stays in `cli`. It is not removed, it stops
421    /// costing.
422    pub(crate) const fn weighs(diagnostic: &Diagnostic) -> bool {
423        diagnostic.context.is_none()
424    }
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
428#[serde(rename_all = "lowercase")]
429pub enum DiagnosticSource {
430    Rustc,
431    Clippy,
432    #[serde(rename = "rust-doctor")]
433    RustDoctor,
434}
435
436impl DiagnosticSource {
437    pub(crate) const fn as_str(self) -> &'static str {
438        match self {
439            Self::Rustc => "rustc",
440            Self::Clippy => "clippy",
441            Self::RustDoctor => "rust-doctor",
442        }
443    }
444}
445
446impl fmt::Display for DiagnosticSource {
447    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
448        formatter.write_str(self.as_str())
449    }
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
453#[serde(rename_all = "lowercase")]
454pub enum Severity {
455    Error,
456    Warning,
457    Info,
458    Unknown,
459}
460
461impl Severity {
462    pub(crate) const fn rank(self) -> u8 {
463        match self {
464            Self::Error => 0,
465            Self::Warning => 1,
466            Self::Info => 2,
467            Self::Unknown => 3,
468        }
469    }
470
471    const fn as_str(self) -> &'static str {
472        match self {
473            Self::Error => "error",
474            Self::Warning => "warning",
475            Self::Info => "info",
476            Self::Unknown => "unknown",
477        }
478    }
479}
480
481impl fmt::Display for Severity {
482    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
483        formatter.write_str(self.as_str())
484    }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488pub struct DiagnosticSpan {
489    pub line_start: usize,
490    pub column_start: usize,
491    pub line_end: usize,
492    pub column_end: usize,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
496pub struct ReportError {
497    pub stage: String,
498    pub code: String,
499    pub message: String,
500}
501
502/// Counts of the report, published under two explicit quantities.
503///
504/// The five flat fields are the historical alias of `distinct`: a diagnostic
505/// reported by two compilation targets counts as one distinct diagnostic and
506/// two occurrences.
507#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
508pub struct Summary {
509    pub errors: usize,
510    pub warnings: usize,
511    pub info: usize,
512    pub unknown: usize,
513    pub total: usize,
514    pub distinct: SeverityCounts,
515    pub occurrences: SeverityCounts,
516}
517
518impl Summary {
519    /// The only admitted derivation of the counts: a report whose `summary`
520    /// departs from this function is refused at serialization.
521    pub fn from_diagnostics(diagnostics: &[Diagnostic]) -> Self {
522        let mut distinct = SeverityCounts::default();
523        let mut occurrences = SeverityCounts::default();
524        for diagnostic in diagnostics {
525            distinct.add(diagnostic.severity, 1);
526            occurrences.add(diagnostic.severity, diagnostic.occurrences);
527        }
528        Self {
529            errors: distinct.errors,
530            warnings: distinct.warnings,
531            info: distinct.info,
532            unknown: distinct.unknown,
533            total: distinct.total,
534            distinct,
535            occurrences,
536        }
537    }
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
541#[serde(rename_all = "kebab-case")]
542pub enum GateStatus {
543    Passed,
544    Failed,
545    NotEvaluated,
546}
547
548impl GateStatus {
549    pub const fn as_str(self) -> &'static str {
550        match self {
551            Self::Passed => "passed",
552            Self::Failed => "failed",
553            Self::NotEvaluated => "not-evaluated",
554        }
555    }
556}
557
558impl fmt::Display for GateStatus {
559    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
560        formatter.write_str(self.as_str())
561    }
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
565pub struct GateReport {
566    pub blocking: BlockingLevel,
567    pub status: GateStatus,
568    pub blocking_diagnostics: Option<usize>,
569}
570
571impl InspectReport {
572    pub const fn exit_code(&self) -> u8 {
573        match (self.status, self.gate.status) {
574            (Status::Complete, GateStatus::Passed) => 0,
575            (Status::Complete, GateStatus::Failed | GateStatus::NotEvaluated)
576            | (Status::Incomplete, _) => 1,
577            (Status::Failed, _) => 2,
578        }
579    }
580}
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638#[cfg(test)]
639mod tests;