cargo-sonar 1.6.0

Helper to transform reports from Rust tooling for code quality, into valid Sonar report
Documentation
use crate::Issue as _;
use std::collections::HashMap;

#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
// ALLOW: Unused fields are part of the serialized schema for 'sonar-scanner'
#[allow(dead_code)]
#[non_exhaustive]
pub enum Severity {
    High,
    Medium,
    Low,
}

impl From<crate::Severity> for Severity {
    #[inline]
    fn from(severity: crate::Severity) -> Self {
        match severity {
            crate::Severity::Blocker | crate::Severity::Critical => Self::High,
            crate::Severity::Major => Self::Medium,
            crate::Severity::Minor | crate::Severity::Info => Self::Low,
        }
    }
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextRange {
    pub start_line: usize,
    pub end_line: usize,
    pub start_column: usize,
    pub end_column: usize,
}

impl TextRange {
    #[inline]
    #[must_use]
    pub fn new(
        (start_line, start_column): (usize, usize),
        (end_line, end_column): (usize, usize),
    ) -> Self {
        Self {
            start_line,
            end_line,
            start_column,
            // sonar-scanner needs a range from at least one character
            end_column: end_column.saturating_add(usize::from(
                start_line == end_line && start_column == end_column,
            )),
        }
    }
}

impl From<crate::TextRange> for TextRange {
    #[inline]
    fn from(range: crate::TextRange) -> Self {
        Self::new(
            (range.start.line, range.start.column),
            (range.end.line, range.end.column),
        )
    }
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Location {
    pub message: String,
    pub file_path: String,
    pub text_range: TextRange,
}

impl From<crate::Location> for Location {
    #[inline]
    fn from(location: crate::Location) -> Self {
        Self {
            message: location.message,
            file_path: location.path.to_string_lossy().into_owned(),
            text_range: TextRange::from(location.range),
        }
    }
}

// See https://docs.sonarcloud.io/improving/clean-code/
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
// ALLOW: Unused fields are part of the serialized schema for 'sonar-scanner'
#[allow(dead_code)]
#[non_exhaustive]
pub enum CleanCodeAttribute {
    // Consistent
    Formatted,
    Conventional,
    Identifiable,
    // Intentional
    Clear,
    Logical,
    Complete,
    Efficient,
    // Adaptable
    Focused,
    Distinct,
    Modular,
    Tested,
    // Responsible
    Lawful,
    Trustworthy,
    Respectful,
}

// See https://docs.sonarcloud.io/improving/clean-code/
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
// ALLOW: Unused fields are part of the serialized schema for 'sonar-scanner'
#[allow(dead_code)]
#[non_exhaustive]
pub enum SoftwareQuality {
    Maintainability,
    Reliability,
    Security,
}

impl From<crate::Category> for SoftwareQuality {
    #[inline]
    fn from(category: crate::Category) -> Self {
        match category {
            crate::Category::Bug | crate::Category::Performance => SoftwareQuality::Reliability,
            crate::Category::Complexity | crate::Category::Duplication | crate::Category::Style => {
                SoftwareQuality::Maintainability
            }
            crate::Category::Security => SoftwareQuality::Security,
        }
    }
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Impact {
    pub software_quality: SoftwareQuality,
    pub severity: Severity,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
    pub id: String,
    pub name: String,
    pub description: String,
    pub engine_id: String,
    pub clean_code_attribute: CleanCodeAttribute,
    pub impacts: Vec<Impact>,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Issue {
    pub rule_id: String,
    pub primary_location: Location,
    pub secondary_locations: Vec<Location>,
}

#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Issues {
    rules: Vec<Rule>,
    issues: Vec<Issue>,
}

impl Issues {
    /// Give the number of generated issues
    #[must_use]
    #[inline]
    pub fn len(&self) -> usize {
        self.issues.len()
    }

    /// Returns if there is no issues
    #[must_use]
    #[inline]
    pub fn is_empty(&self) -> bool {
        self.issues.is_empty()
    }
}

#[derive(Debug)]
pub struct Sonar;

impl<'c> crate::FromIssues<'c> for Sonar {
    type Report = Issues;

    fn from_issues(issues: impl IntoIterator<Item = crate::IssueType<'c>>) -> Self::Report {
        let (rules, issues) = issues.into_iter().fold(
            (HashMap::new(), Vec::new()),
            |(mut rules, mut issues), issue| {
                let Some(location) = issue.location() else {
                    return (rules, issues);
                };
                rules.entry(issue.issue_uid()).or_insert_with(|| Rule {
                    id: issue.issue_uid(),
                    name: issue.issue_id(),
                    description: location.message.clone(),
                    engine_id: issue.analyzer_id(),
                    clean_code_attribute: CleanCodeAttribute::Clear,
                    impacts: vec![Impact {
                        software_quality: SoftwareQuality::from(issue.category()),
                        severity: Severity::from(issue.severity()),
                    }],
                });
                issues.push(Issue {
                    rule_id: issue.issue_uid(),
                    primary_location: Location::from(location),
                    secondary_locations: issue
                        .other_locations()
                        .into_iter()
                        .map(Location::from)
                        .collect(),
                });
                (rules, issues)
            },
        );
        Self::Report {
            rules: rules.into_values().collect(),
            issues,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Severity, Sonar};
    use crate::{
        sonar::{CleanCodeAttribute, SoftwareQuality},
        test::Issue,
        FromIssues as _, IssueType,
    };
    use test_log::test;

    #[test]
    fn simple_issue() {
        let issues = Sonar::from_issues(vec![IssueType::Test(Issue)]);
        assert_eq!(issues.rules.len(), 1);
        let rule = &issues.rules.first().unwrap();
        assert_eq!(rule.id, "fake::fake_issue");
        assert_eq!(rule.engine_id, "fake");
        assert_eq!(rule.name, "fake_issue");
        assert_eq!(rule.description, "this issue is bad!");
        assert!(matches!(
            rule.clean_code_attribute,
            CleanCodeAttribute::Clear
        ));
        assert_eq!(rule.impacts.len(), 1);
        let impact = &rule.impacts.first().unwrap();
        assert!(matches!(
            impact.software_quality,
            SoftwareQuality::Maintainability
        ));
        assert!(matches!(impact.severity, Severity::Low));
        assert_eq!(issues.issues.len(), 1);
        let issue = &issues.issues.first().unwrap();
        assert_eq!(issue.rule_id, "fake::fake_issue");
        assert_eq!(issue.primary_location.message, "this issue is bad!");
        assert_eq!(issue.primary_location.file_path, "Cargo.lock");
        assert_eq!(issue.primary_location.text_range.start_line, 1);
        assert_eq!(issue.primary_location.text_range.end_line, 2);
        assert_eq!(issue.primary_location.text_range.start_column, 1);
        assert_eq!(issue.primary_location.text_range.end_column, 42);
        assert!(issue.secondary_locations.is_empty());
    }
}