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},
fs::File,
path::Path,
};
const UDEPS_ENGINE: &str = "udeps";
#[derive(Debug, Clone, Copy, strum::Display)]
pub enum DependencyType {
#[strum(serialize = "normal")]
Normal,
#[strum(serialize = "development")]
Development,
#[strum(serialize = "build")]
Build,
}
#[derive(Debug, serde::Deserialize)]
#[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(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>;
fn next(&mut self) -> Option<Self::Item> {
self.issues.next()
}
}
impl<'lock> Udeps<'lock> {
pub fn try_new<P>(json: P, lockfile: &'lock Lockfile) -> Result<Self>
where
P: AsRef<Path>,
{
let file = File::open(json.as_ref()).with_context(|| {
format!(
"failed to open 'cargo-udeps' report from '{:?}' file",
json.as_ref()
)
})?;
let outcome = serde_json::from_reader::<_, Outcome>(file).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<'lock> crate::Issue for Issue<'lock> {
fn analyzer_id(&self) -> String {
UDEPS_ENGINE.to_string()
}
fn issue_id(&self) -> String {
format!("{}::{}", self.2, self.3)
}
fn fingerprint(&self) -> Digest {
md5::compute(format!("{}::{}::{}", self.1, self.2, self.3))
}
fn category(&self) -> Category {
Category::Security
}
fn severity(&self) -> Severity {
Severity::Minor
}
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 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.path(), &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, 1);
}
}