cargo-sonar 1.6.0

Helper to transform reports from Rust tooling for code quality, into valid Sonar report
Documentation
use crate::{cargo::Lockfile, Category, Location, Severity};
use dyn_iter::{DynIter, IntoDynIterator as _};
use eyre::{Context as _, Result};
use md5::Digest;
use std::collections::{BTreeMap, BTreeSet};

const UDEPS_ENGINE: &str = "udeps";

#[derive(Debug, Clone, Copy, strum::Display)]
#[non_exhaustive]
pub enum DependencyType {
    #[strum(serialize = "normal")]
    Normal,
    #[strum(serialize = "development")]
    Development,
    #[strum(serialize = "build")]
    Build,
}

#[derive(Debug, serde::Deserialize)]
// ALLOW: Unused fields are part of the deserialized schema from 'cargo-udeps'
#[allow(dead_code)]
struct OutcomeUnusedDeps {
    manifest_path: String,
    normal: BTreeSet<String>,
    development: BTreeSet<String>,
    build: BTreeSet<String>,
}

impl OutcomeUnusedDeps {
    fn dependencies(self) -> impl Iterator<Item = (DependencyType, DependencyName)> {
        self.normal
            .into_iter()
            .map(|normal_dep| (DependencyType::Normal, normal_dep))
            .chain(
                self.development
                    .into_iter()
                    .map(|development_dep| (DependencyType::Development, development_dep)),
            )
            .chain(
                self.build
                    .into_iter()
                    .map(|build_dep| (DependencyType::Build, build_dep)),
            )
    }
}

#[derive(Debug, serde::Deserialize)]
// ALLOW: Unused fields are part of the deserialized schema from 'cargo-udeps'
#[allow(dead_code)]
struct Outcome {
    success: bool,
    unused_deps: BTreeMap<String, OutcomeUnusedDeps>,
    note: Option<String>,
}

pub struct Udeps<'lock> {
    issues: DynIter<'lock, Issue<'lock>>,
}

impl<'lock> Iterator for Udeps<'lock> {
    type Item = Issue<'lock>;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        self.issues.next()
    }
}

impl<'lock> Udeps<'lock> {
    /// Create a Udeps parser for issues
    ///
    /// # Errors
    /// May fail reading and parsing the file (IO errors).
    #[inline]
    pub fn try_new<R>(json_read: R, lockfile: &'lock Lockfile) -> Result<Self>
    where
        R: std::io::Read + 'static,
    {
        let outcome = serde_json::from_reader::<_, Outcome>(json_read).with_context(|| {
            format!(
                "failed to be parsed as a '{}'",
                std::any::type_name::<Outcome>(),
            )
        })?;
        let issues = outcome
            .unused_deps
            .into_iter()
            .filter_map(move |(package_id, outcome_unused)| {
                package_id
                    .split_ascii_whitespace()
                    .next()
                    .map(str::to_owned)
                    .map(move |package_name| {
                        outcome_unused.dependencies().map(move |(dep_type, dep)| {
                            (lockfile, package_name.clone(), dep_type, dep.clone())
                        })
                    })
            })
            .flatten()
            .into_dyn_iter();
        let udeps = Self { issues };
        Ok(udeps)
    }
}

pub type PackageName = String;
pub type DependencyName = String;
pub type Issue<'lock> = (&'lock Lockfile, PackageName, DependencyType, DependencyName);

impl crate::Issue for Issue<'_> {
    #[inline]
    fn analyzer_id(&self) -> String {
        UDEPS_ENGINE.to_owned()
    }
    #[inline]
    fn issue_id(&self) -> String {
        format!("{}::{}", self.2, self.3)
    }
    #[inline]
    fn fingerprint(&self) -> Digest {
        md5::compute(format!("{}::{}::{}", self.1, self.2, self.3))
    }
    #[inline]
    fn category(&self) -> Category {
        Category::Security
    }
    #[inline]
    fn severity(&self) -> Severity {
        Severity::Minor
    }
    #[inline]
    fn location(&self) -> Option<Location> {
        let message = format!(
            "Dependency '{}' is unused as a {} dependency in package '{}'",
            self.3, self.2, self.1,
        );
        let path = self.0.lockfile_path.clone();
        let range = self.0.dependency_range(&self.3);
        let location = Location {
            path,
            range,
            message,
        };
        Some(location)
    }
}

#[cfg(test)]
mod tests {
    use crate::{udeps::Udeps, Category, Issue as _, Severity};
    use std::io::Write as _;
    use test_log::test;

    #[test]
    fn single_issue() {
        let json = r#"{
          "success": false,
          "unused_deps": {
            "useless 0.1.0 (path+file:///tmp/useless)": {
              "manifest_path": "/tmp/useless/Cargo.toml",
              "normal": [
                "if_chain"
              ],
              "development": [],
              "build": []
            }
          },
          "note": "Note: They might be false-positive.\n      For example, `cargo-udeps` cannot detect usage of crates that are only used in doc-tests.\n
              To ignore some dependencies, write `package.metadata.cargo-udeps.ignore` in Cargo.toml.\n"
        }"#;
        let json = json.to_owned().replace('\n', "");
        let mut udeps_json = tempfile::NamedTempFile::new().unwrap();
        write!(udeps_json, "{}", json).unwrap();
        let udeps_json = udeps_json.reopen().unwrap();

        let cargo_lock = r#"
            # This file is automatically @generated by Cargo.
            # It is not intended for manual editing.
            version = 3

            [[package]]
            name = "useless"
            version = "0.1.0"
            dependencies = [
             "if_chain",
            ]

            [[package]]
            name = "if_chain"
            version = "1.0.0"
            source = "registry+https://github.com/rust-lang/crates.io-index"
            checksum = "c3360c7b59e5ffa2653671fb74b4741a5d343c03f331c0a4aeda42b5c2b0ec7d"
        "#;
        let mut cargo_lock_toml = tempfile::NamedTempFile::new().unwrap();
        write!(cargo_lock_toml, "{}", cargo_lock).unwrap();

        let lockfile = crate::cargo::Lockfile::try_from(cargo_lock_toml.path()).unwrap();

        let mut udeps = Udeps::try_new(udeps_json, &lockfile).unwrap();
        let issue = udeps.next().unwrap();
        assert_eq!(issue.analyzer_id(), "udeps");
        assert_eq!(issue.issue_uid(), "udeps::normal::if_chain");
        assert!(matches!(issue.severity(), Severity::Minor));
        assert!(matches!(issue.category(), Category::Security));
        let location = issue.location().unwrap();
        assert_eq!(location.path, cargo_lock_toml.path());
        assert_eq!(
            location.message,
            "Dependency 'if_chain' is unused as a normal dependency in package 'useless'"
        );
        assert_eq!(location.range.start.line, 1);
        assert_eq!(location.range.end.line, 1);
        assert_eq!(location.range.start.column, 0);
        assert_eq!(location.range.end.column, 0);
    }
}