1use crate::{cargo::Lockfile, Category, Location, Severity};
2use dyn_iter::{DynIter, IntoDynIterator as _};
3use eyre::{Context as _, Result};
4use md5::Digest;
5use std::collections::{BTreeMap, BTreeSet};
6
7const UDEPS_ENGINE: &str = "udeps";
8
9#[derive(Debug, Clone, Copy, strum::Display)]
10#[non_exhaustive]
11pub enum DependencyType {
12 #[strum(serialize = "normal")]
13 Normal,
14 #[strum(serialize = "development")]
15 Development,
16 #[strum(serialize = "build")]
17 Build,
18}
19
20#[derive(Debug, serde::Deserialize)]
21#[allow(dead_code)]
23struct OutcomeUnusedDeps {
24 manifest_path: String,
25 normal: BTreeSet<String>,
26 development: BTreeSet<String>,
27 build: BTreeSet<String>,
28}
29
30impl OutcomeUnusedDeps {
31 fn dependencies(self) -> impl Iterator<Item = (DependencyType, DependencyName)> {
32 self.normal
33 .into_iter()
34 .map(|normal_dep| (DependencyType::Normal, normal_dep))
35 .chain(
36 self.development
37 .into_iter()
38 .map(|development_dep| (DependencyType::Development, development_dep)),
39 )
40 .chain(
41 self.build
42 .into_iter()
43 .map(|build_dep| (DependencyType::Build, build_dep)),
44 )
45 }
46}
47
48#[derive(Debug, serde::Deserialize)]
49#[allow(dead_code)]
51struct Outcome {
52 success: bool,
53 unused_deps: BTreeMap<String, OutcomeUnusedDeps>,
54 note: Option<String>,
55}
56
57pub struct Udeps<'lock> {
58 issues: DynIter<'lock, Issue<'lock>>,
59}
60
61impl<'lock> Iterator for Udeps<'lock> {
62 type Item = Issue<'lock>;
63
64 #[inline]
65 fn next(&mut self) -> Option<Self::Item> {
66 self.issues.next()
67 }
68}
69
70impl<'lock> Udeps<'lock> {
71 #[inline]
76 pub fn try_new<R>(json_read: R, lockfile: &'lock Lockfile) -> Result<Self>
77 where
78 R: std::io::Read + 'static,
79 {
80 let outcome = serde_json::from_reader::<_, Outcome>(json_read).with_context(|| {
81 format!(
82 "failed to be parsed as a '{}'",
83 std::any::type_name::<Outcome>(),
84 )
85 })?;
86 let issues = outcome
87 .unused_deps
88 .into_iter()
89 .filter_map(move |(package_id, outcome_unused)| {
90 package_id
91 .split_ascii_whitespace()
92 .next()
93 .map(str::to_owned)
94 .map(move |package_name| {
95 outcome_unused.dependencies().map(move |(dep_type, dep)| {
96 (lockfile, package_name.clone(), dep_type, dep.clone())
97 })
98 })
99 })
100 .flatten()
101 .into_dyn_iter();
102 let udeps = Self { issues };
103 Ok(udeps)
104 }
105}
106
107pub type PackageName = String;
108pub type DependencyName = String;
109pub type Issue<'lock> = (&'lock Lockfile, PackageName, DependencyType, DependencyName);
110
111impl crate::Issue for Issue<'_> {
112 #[inline]
113 fn analyzer_id(&self) -> String {
114 UDEPS_ENGINE.to_owned()
115 }
116 #[inline]
117 fn issue_id(&self) -> String {
118 format!("{}::{}", self.2, self.3)
119 }
120 #[inline]
121 fn fingerprint(&self) -> Digest {
122 md5::compute(format!("{}::{}::{}", self.1, self.2, self.3))
123 }
124 #[inline]
125 fn category(&self) -> Category {
126 Category::Security
127 }
128 #[inline]
129 fn severity(&self) -> Severity {
130 Severity::Minor
131 }
132 #[inline]
133 fn location(&self) -> Option<Location> {
134 let message = format!(
135 "Dependency '{}' is unused as a {} dependency in package '{}'",
136 self.3, self.2, self.1,
137 );
138 let path = self.0.lockfile_path.clone();
139 let range = self.0.dependency_range(&self.3);
140 let location = Location {
141 path,
142 range,
143 message,
144 };
145 Some(location)
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use crate::{udeps::Udeps, Category, Issue as _, Severity};
152 use std::io::Write as _;
153 use test_log::test;
154
155 #[test]
156 fn single_issue() {
157 let json = r#"{
158 "success": false,
159 "unused_deps": {
160 "useless 0.1.0 (path+file:///tmp/useless)": {
161 "manifest_path": "/tmp/useless/Cargo.toml",
162 "normal": [
163 "if_chain"
164 ],
165 "development": [],
166 "build": []
167 }
168 },
169 "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
170 To ignore some dependencies, write `package.metadata.cargo-udeps.ignore` in Cargo.toml.\n"
171 }"#;
172 let json = json.to_owned().replace('\n', "");
173 let mut udeps_json = tempfile::NamedTempFile::new().unwrap();
174 write!(udeps_json, "{}", json).unwrap();
175 let udeps_json = udeps_json.reopen().unwrap();
176
177 let cargo_lock = r#"
178 # This file is automatically @generated by Cargo.
179 # It is not intended for manual editing.
180 version = 3
181
182 [[package]]
183 name = "useless"
184 version = "0.1.0"
185 dependencies = [
186 "if_chain",
187 ]
188
189 [[package]]
190 name = "if_chain"
191 version = "1.0.0"
192 source = "registry+https://github.com/rust-lang/crates.io-index"
193 checksum = "c3360c7b59e5ffa2653671fb74b4741a5d343c03f331c0a4aeda42b5c2b0ec7d"
194 "#;
195 let mut cargo_lock_toml = tempfile::NamedTempFile::new().unwrap();
196 write!(cargo_lock_toml, "{}", cargo_lock).unwrap();
197
198 let lockfile = crate::cargo::Lockfile::try_from(cargo_lock_toml.path()).unwrap();
199
200 let mut udeps = Udeps::try_new(udeps_json, &lockfile).unwrap();
201 let issue = udeps.next().unwrap();
202 assert_eq!(issue.analyzer_id(), "udeps");
203 assert_eq!(issue.issue_uid(), "udeps::normal::if_chain");
204 assert!(matches!(issue.severity(), Severity::Minor));
205 assert!(matches!(issue.category(), Category::Security));
206 let location = issue.location().unwrap();
207 assert_eq!(location.path, cargo_lock_toml.path());
208 assert_eq!(
209 location.message,
210 "Dependency 'if_chain' is unused as a normal dependency in package 'useless'"
211 );
212 assert_eq!(location.range.start.line, 1);
213 assert_eq!(location.range.end.line, 1);
214 assert_eq!(location.range.start.column, 0);
215 assert_eq!(location.range.end.column, 0);
216 }
217}