1use 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 #[serde(skip_serializing_if = "Option::is_none")]
140 pub corpus_noise_basis_points: Option<u16>,
141 #[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 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 #[serde(skip_serializing_if = "Option::is_none")]
311 pub context: Option<DiagnosticContext>,
312 pub path: Option<String>,
313 pub span: Option<DiagnosticSpan>,
314 #[serde(skip_serializing_if = "Vec::is_empty")]
322 pub related: Vec<RelatedLocation>,
323 #[serde(skip_serializing_if = "Option::is_none")]
330 pub similarity_basis_points: Option<u16>,
331 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
350pub struct ComplexityFigures {
351 pub cyclomatic: u32,
352 pub cognitive: u32,
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
360#[serde(rename_all = "kebab-case")]
361pub enum DiagnosticContext {
362 Tests,
364 Benchmark,
366 Example,
368 BuildScript,
370}
371
372impl DiagnosticContext {
373 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 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 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#[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 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;