rscheck-cli 0.1.0-alpha.3

CLI frontend for the rscheck policy engine.
Documentation
use crate::report::{
    Finding, FindingLabel, FindingLabelKind, FindingNote, FindingNoteKind, Fix, FixSafety,
    Severity, TextEdit,
};
use crate::rules::RuleBackend;
use crate::span::{Location, Span};
use cargo_metadata::{Message, diagnostic::DiagnosticLevel};
use std::io::{self, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};

#[derive(Debug, thiserror::Error)]
pub enum CargoError {
    #[error("failed to spawn cargo")]
    Spawn(#[source] io::Error),
    #[error("failed to read cargo output")]
    Read(#[source] io::Error),
    #[error("cargo exited with non-zero status: {0}")]
    Status(i32),
}

pub fn run_clippy(
    workspace_root: &PathBuf,
    toolchain: Option<&str>,
    extra_args: &[String],
) -> Result<Vec<Finding>, CargoError> {
    let mut cmd = Command::new("cargo");
    cmd.current_dir(workspace_root);
    if let Some(toolchain) = toolchain {
        cmd.arg(toolchain);
    }
    cmd.arg("clippy")
        .arg("--workspace")
        .arg("--message-format=json")
        .stdout(Stdio::piped())
        .stderr(Stdio::inherit());

    for arg in extra_args {
        cmd.arg(arg);
    }

    let mut child = cmd.spawn().map_err(CargoError::Spawn)?;
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| CargoError::Spawn(io::Error::other("cargo stdout missing")))?;

    let reader = BufReader::new(stdout);
    let mut findings = Vec::new();

    for message in Message::parse_stream(reader) {
        let message = message.map_err(CargoError::Read)?;
        if let Message::CompilerMessage(msg) = message {
            if let Some(finding) = diagnostic_to_finding(&msg.message) {
                findings.push(finding);
            }
        }
    }

    let status = child.wait().map_err(CargoError::Read)?;
    if !status.success() {
        let code = status.code().unwrap_or(1);
        return Err(CargoError::Status(code));
    }

    Ok(findings)
}

fn diagnostic_to_finding(diag: &cargo_metadata::diagnostic::Diagnostic) -> Option<Finding> {
    let severity = match diag.level {
        DiagnosticLevel::Error => Severity::Deny,
        DiagnosticLevel::Warning => Severity::Warn,
        DiagnosticLevel::Note | DiagnosticLevel::Help => Severity::Info,
        DiagnosticLevel::FailureNote => Severity::Warn,
        DiagnosticLevel::Ice => Severity::Deny,
        _ => Severity::Warn,
    };

    let primary = diag
        .spans
        .iter()
        .find(|s| s.is_primary)
        .map(span_to_report_span);
    let secondary: Vec<_> = diag
        .spans
        .iter()
        .filter(|span| !span.is_primary)
        .map(span_to_report_span)
        .collect();
    let labels = collect_labels(diag);
    let notes = collect_notes(diag);

    let mut fixes = Vec::new();
    for (idx, span) in diag.spans.iter().enumerate() {
        let Some(repl) = &span.suggested_replacement else {
            continue;
        };
        let safety = match span.suggestion_applicability {
            Some(cargo_metadata::diagnostic::Applicability::MachineApplicable) => FixSafety::Safe,
            _ => FixSafety::Unsafe,
        };
        fixes.push(Fix {
            id: format!(
                "{}::clippy_suggestion::{idx}",
                diag.code.as_ref().map_or("clippy", |c| c.code.as_str())
            ),
            safety,
            message: span
                .label
                .clone()
                .unwrap_or_else(|| "apply suggestion".to_string()),
            edits: vec![TextEdit {
                file: span.file_name.clone(),
                byte_start: span.byte_start,
                byte_end: span.byte_end,
                replacement: repl.clone(),
            }],
        });
    }

    Some(Finding {
        rule_id: diag
            .code
            .as_ref()
            .map_or_else(|| "clippy".to_string(), |c| c.code.clone()),
        family: None,
        engine: Some(RuleBackend::Adapter),
        severity,
        message: diag.message.clone(),
        primary,
        secondary,
        help: None,
        evidence: None,
        confidence: None,
        tags: vec!["clippy".to_string()],
        labels,
        notes,
        fixes,
    })
}

fn span_to_report_span(span: &cargo_metadata::diagnostic::DiagnosticSpan) -> Span {
    let file = PathBuf::from(&span.file_name);
    Span::new(
        &file,
        Location {
            line: span.line_start as u32,
            column: span.column_start as u32,
        },
        Location {
            line: span.line_end as u32,
            column: span.column_end as u32,
        },
    )
}

fn collect_labels(diag: &cargo_metadata::diagnostic::Diagnostic) -> Vec<FindingLabel> {
    let mut labels = Vec::new();
    for span in &diag.spans {
        labels.push(FindingLabel {
            kind: if span.is_primary {
                FindingLabelKind::Primary
            } else {
                FindingLabelKind::Secondary
            },
            span: span_to_report_span(span),
            message: span.label.clone(),
        });
    }
    for child in &diag.children {
        for span in &child.spans {
            labels.push(FindingLabel {
                kind: if span.is_primary {
                    FindingLabelKind::Primary
                } else {
                    FindingLabelKind::Secondary
                },
                span: span_to_report_span(span),
                message: span.label.clone().or_else(|| Some(child.message.clone())),
            });
        }
    }
    labels
}

fn collect_notes(diag: &cargo_metadata::diagnostic::Diagnostic) -> Vec<FindingNote> {
    let mut notes = Vec::new();
    for child in &diag.children {
        notes.push(FindingNote {
            kind: map_note_kind(child.level),
            message: child.message.clone(),
        });
    }
    notes
}

fn map_note_kind(level: DiagnosticLevel) -> FindingNoteKind {
    match level {
        DiagnosticLevel::Help => FindingNoteKind::Help,
        DiagnosticLevel::Note | DiagnosticLevel::FailureNote => FindingNoteKind::Note,
        _ => FindingNoteKind::Info,
    }
}