cargo-sonar 0.14.1

Helper to transform reports from Rust tooling for code quality, into valid Sonar report
use crate::sonar;
use cargo_metadata::{
    diagnostic::{DiagnosticLevel, DiagnosticSpan},
    Message,
};
use eyre::Context as _;
use skip_error::SkipError as _;
use std::{
    fs::File,
    io::{BufRead as _, BufReader},
    path::PathBuf,
};
use tracing::{error, info};

const CLIPPY_ENGINE: &str = "clippy";

#[derive(Debug)]
pub struct Clippy {
    json: PathBuf,
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("no span defined to report an error")]
    NoSpan,
    #[error("the message of type '{0}' are not handled")]
    InvalidMessage(&'static str),
}

impl From<&DiagnosticLevel> for sonar::Severity {
    fn from(level: &DiagnosticLevel) -> Self {
        match *level {
            DiagnosticLevel::Ice => sonar::Severity::Blocker,
            DiagnosticLevel::Error => sonar::Severity::Critical,
            DiagnosticLevel::Warning => sonar::Severity::Major,
            DiagnosticLevel::FailureNote => sonar::Severity::Minor,
            _ => sonar::Severity::Info,
        }
    }
}

impl From<&DiagnosticSpan> for sonar::TextRange {
    fn from(span: &DiagnosticSpan) -> Self {
        Self {
            start_line: span.line_start,
            end_line: span.line_end,
            // FIXME: we should actually check if spans have a column bound on
            // an line end and remove 1 if this is the case.
            // clippy does consider the end-of-line character
            // but sonar-scanner does not and check for validity
            start_column: span.column_start.saturating_sub(1),
            end_column: span.column_end.saturating_sub(1),
        }
    }
}

// ALLOW: 'CompilerMessage' has already been ruled out above,
// so this will never panic
#[allow(clippy::panic_in_result_fn)]
impl TryFrom<Message> for sonar::Issue {
    type Error = Error;
    fn try_from(message: Message) -> Result<Self, Self::Error> {
        if let Message::CompilerMessage(message) = message {
            let rule_id = message.message.code.as_ref().map_or_else(
                || String::from("clippy"),
                |diagnostic_code| diagnostic_code.code.clone(),
            );
            let severity = sonar::Severity::from(&message.message.level);
            let r#type = sonar::Type::CodeSmell;
            let (primary_location, secondary_locations) = match message.message.spans.len() {
                0 => return Err(Error::NoSpan),
                n => {
                    let span = message.message.spans.get(0).ok_or(Error::NoSpan)?;
                    let primary_location = sonar::Location {
                        message: message.message.message.clone(),
                        file_path: span.file_name.clone(),
                        text_range: sonar::TextRange::from(span),
                    };
                    let secondary_locations = (1..n)
                        .filter_map(|idx| message.message.spans.get(idx))
                        .map(|span| sonar::Location {
                            message: message.message.message.clone(),
                            file_path: span.file_name.clone(),
                            text_range: sonar::TextRange::from(span),
                        })
                        .collect();
                    (primary_location, secondary_locations)
                }
            };
            let issue = Self {
                engine_id: CLIPPY_ENGINE.to_owned(),
                rule_id,
                severity,
                r#type,
                primary_location,
                secondary_locations,
            };
            Ok(issue)
        } else {
            let kind = match message {
                Message::CompilerArtifact(_) => "compiler-artifact",
                Message::CompilerMessage(_) => {
                    unreachable!("'CompilerMessage' has already been parsed above")
                }
                Message::BuildScriptExecuted(_) => "build-script-executed",
                Message::BuildFinished(_) => "build-finished",
                Message::TextLine(_) => "text-line",
                _ => "unknown",
            };
            Err(Error::InvalidMessage(kind))
        }
    }
}

impl Clippy {
    pub fn new<P>(json: P) -> Self
    where
        P: Into<PathBuf>,
    {
        Self { json: json.into() }
    }
}

impl std::convert::TryInto<sonar::Issues> for Clippy {
    type Error = eyre::Error;

    fn try_into(self) -> Result<sonar::Issues, Self::Error> {
        let file = File::open(&self.json).with_context(|| {
            format!(
                "failed to open 'cargo-clippy' report from '{:?}' file",
                self.json
            )
        })?;
        let reader = BufReader::new(file);
        let issues: sonar::Issues = reader
            .lines()
            .flatten()
            .flat_map(|line| serde_json::from_str::<cargo_metadata::Message>(&line))
            .map(sonar::Issue::try_from)
            .skip_error_and_debug()
            .collect();
        info!("{} sonar issues created", issues.len());
        Ok(issues)
    }
}