1#![cfg_attr(docsrs, feature(doc_cfg))]
37#![warn(missing_docs)]
38#![warn(rust_2018_idioms)]
39
40use std::path::PathBuf;
41
42use dev_report::{CheckResult, Evidence, Report, Severity};
43use serde::{Deserialize, Serialize};
44
45mod outdated;
46mod producer;
47mod udeps;
48
49pub use producer::DepProducer;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum DepScope {
59 Unused,
61 Outdated,
63 All,
65}
66
67impl DepScope {
68 fn runs_unused(self) -> bool {
69 matches!(self, Self::Unused | Self::All)
70 }
71
72 fn runs_outdated(self) -> bool {
73 matches!(self, Self::Outdated | Self::All)
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum DepKind {
85 Normal,
87 Development,
89 Build,
91}
92
93impl DepKind {
94 pub fn as_str(self) -> &'static str {
96 match self {
97 Self::Normal => "dependencies",
98 Self::Development => "dev-dependencies",
99 Self::Build => "build-dependencies",
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
126pub struct DepCheck {
127 name: String,
128 version: String,
129 scope: DepScope,
130 workdir: Option<PathBuf>,
131 workspace: bool,
132 excludes: Vec<String>,
133 allow_list: Vec<String>,
134 severity_threshold: Option<Severity>,
135 escalate_at_majors: Option<u32>,
136}
137
138impl DepCheck {
139 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
144 Self {
145 name: name.into(),
146 version: version.into(),
147 scope: DepScope::All,
148 workdir: None,
149 workspace: false,
150 excludes: Vec::new(),
151 allow_list: Vec::new(),
152 severity_threshold: None,
153 escalate_at_majors: None,
154 }
155 }
156
157 pub fn scope(mut self, scope: DepScope) -> Self {
159 self.scope = scope;
160 self
161 }
162
163 pub fn dep_scope(&self) -> DepScope {
165 self.scope
166 }
167
168 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
170 self.workdir = Some(dir.into());
171 self
172 }
173
174 pub fn workspace(mut self) -> Self {
176 self.workspace = true;
177 self
178 }
179
180 pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
182 self.excludes.push(pattern.into());
183 self
184 }
185
186 pub fn allow(mut self, crate_name: impl Into<String>) -> Self {
189 self.allow_list.push(crate_name.into());
190 self
191 }
192
193 pub fn allow_all<I, S>(mut self, names: I) -> Self
195 where
196 I: IntoIterator<Item = S>,
197 S: Into<String>,
198 {
199 self.allow_list.extend(names.into_iter().map(Into::into));
200 self
201 }
202
203 pub fn severity_threshold(mut self, threshold: Severity) -> Self {
206 self.severity_threshold = Some(threshold);
207 self
208 }
209
210 pub fn escalate_at_majors(mut self, n: u32) -> Self {
214 self.escalate_at_majors = Some(n);
215 self
216 }
217
218 pub fn subject(&self) -> &str {
220 &self.name
221 }
222
223 pub fn subject_version(&self) -> &str {
225 &self.version
226 }
227
228 pub fn execute(&self) -> Result<DepResult, DepError> {
234 let mut unused: Vec<UnusedDep> = Vec::new();
235 let mut outdated: Vec<OutdatedDep> = Vec::new();
236
237 if self.scope.runs_unused() {
238 unused = udeps::run(self.workdir.as_deref(), self.workspace)?;
239 }
240 if self.scope.runs_outdated() {
241 outdated = outdated::run(self.workdir.as_deref(), self.workspace, &self.excludes)?;
242 }
243
244 if !self.allow_list.is_empty() {
245 unused.retain(|u| !self.allow_list.iter().any(|n| n == &u.crate_name));
246 outdated.retain(|o| !self.allow_list.iter().any(|n| n == &o.crate_name));
247 }
248 if !self.excludes.is_empty() {
249 unused.retain(|u| !self.excludes.iter().any(|n| n == &u.crate_name));
250 }
251
252 if let Some(threshold) = self.severity_threshold {
253 let t = severity_ord(threshold);
254 unused.retain(|u| severity_ord(u.severity()) >= t);
255 outdated.retain(|o| severity_ord(o.severity(self.escalate_at_majors)) >= t);
256 }
257
258 unused.sort_by(|a, b| a.crate_name.cmp(&b.crate_name).then(a.kind.cmp(&b.kind)));
259 outdated.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
260 unused.dedup_by(|a, b| a.crate_name == b.crate_name && a.kind == b.kind);
261 outdated.dedup_by(|a, b| a.crate_name == b.crate_name);
262
263 Ok(DepResult {
264 name: self.name.clone(),
265 version: self.version.clone(),
266 scope: self.scope,
267 unused,
268 outdated,
269 escalate_at_majors: self.escalate_at_majors,
270 })
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct UnusedDep {
281 pub crate_name: String,
283 pub kind: DepKind,
285}
286
287impl UnusedDep {
288 pub fn severity(&self) -> Severity {
290 Severity::Warning
291 }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct OutdatedDep {
297 pub crate_name: String,
299 pub current: String,
301 pub latest: String,
303 pub major_behind: u32,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub kind: Option<DepKind>,
308}
309
310impl OutdatedDep {
311 pub fn severity(&self, escalate_at: Option<u32>) -> Severity {
318 if let Some(n) = escalate_at {
319 if self.major_behind >= n {
320 return Severity::Error;
321 }
322 }
323 if self.major_behind >= 2 {
324 Severity::Warning
325 } else {
326 Severity::Info
327 }
328 }
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct DepResult {
338 pub name: String,
340 pub version: String,
342 pub scope: DepScope,
344 pub unused: Vec<UnusedDep>,
346 pub outdated: Vec<OutdatedDep>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub escalate_at_majors: Option<u32>,
353}
354
355impl DepResult {
356 pub fn total_findings(&self) -> usize {
358 self.unused.len() + self.outdated.len()
359 }
360
361 pub fn unused_count(&self) -> usize {
363 self.unused.len()
364 }
365
366 pub fn outdated_count(&self) -> usize {
368 self.outdated.len()
369 }
370
371 pub fn worst_severity(&self) -> Option<Severity> {
373 let mut worst: Option<Severity> = None;
374 let mut bump = |s: Severity| {
375 worst = Some(match worst {
376 None => s,
377 Some(prev) if severity_ord(s) > severity_ord(prev) => s,
378 Some(prev) => prev,
379 });
380 };
381 for u in &self.unused {
382 bump(u.severity());
383 }
384 for o in &self.outdated {
385 bump(o.severity(self.escalate_at_majors));
386 }
387 worst
388 }
389
390 pub fn into_report(self) -> Report {
398 let mut report = Report::new(&self.name, &self.version).with_producer("dev-deps");
399 if self.total_findings() == 0 {
400 report.push(
401 CheckResult::pass("deps::health")
402 .with_tag("deps")
403 .with_detail(format!("{} scope: no findings", scope_label(self.scope))),
404 );
405 } else {
406 for u in &self.unused {
407 let check =
408 CheckResult::warn(format!("deps::unused::{}", u.crate_name), u.severity())
409 .with_detail(format!("unused in {}", u.kind.as_str()))
410 .with_tag("deps")
411 .with_tag("unused")
412 .with_evidence(Evidence::kv(
413 "finding",
414 [("crate", u.crate_name.as_str()), ("kind", u.kind.as_str())],
415 ));
416 report.push(check);
417 }
418 for o in &self.outdated {
419 let sev = o.severity(self.escalate_at_majors);
420 let kind = if sev == Severity::Error {
421 CheckResult::fail(format!("deps::outdated::{}", o.crate_name), sev)
423 } else {
424 CheckResult::warn(format!("deps::outdated::{}", o.crate_name), sev)
425 };
426 let mut check = kind
427 .with_detail(format!(
428 "{} -> {} ({} major behind)",
429 o.current, o.latest, o.major_behind
430 ))
431 .with_tag("deps")
432 .with_tag("outdated")
433 .with_evidence(Evidence::numeric_int("major_behind", o.major_behind as i64));
434 let mut kv: Vec<(String, String)> = vec![
435 ("crate".into(), o.crate_name.clone()),
436 ("current".into(), o.current.clone()),
437 ("latest".into(), o.latest.clone()),
438 ];
439 if let Some(k) = o.kind {
440 kv.push(("kind".into(), k.as_str().into()));
441 }
442 check = check.with_evidence(Evidence::kv("finding", kv));
443 report.push(check);
444 }
445 }
446 report.finish();
447 report
448 }
449}
450
451fn scope_label(s: DepScope) -> &'static str {
452 match s {
453 DepScope::Unused => "unused",
454 DepScope::Outdated => "outdated",
455 DepScope::All => "all",
456 }
457}
458
459pub(crate) fn severity_ord(s: Severity) -> u8 {
460 match s {
461 Severity::Info => 0,
462 Severity::Warning => 1,
463 Severity::Error => 2,
464 Severity::Critical => 3,
465 }
466}
467
468#[derive(Debug)]
474pub enum DepError {
475 UdepsToolNotInstalled,
477 OutdatedToolNotInstalled,
479 SubprocessFailed(String),
481 ParseError(String),
483}
484
485impl std::fmt::Display for DepError {
486 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487 match self {
488 Self::UdepsToolNotInstalled => write!(
489 f,
490 "cargo-udeps is not installed (or nightly toolchain missing); run `cargo install cargo-udeps` and `rustup toolchain install nightly`"
491 ),
492 Self::OutdatedToolNotInstalled => write!(
493 f,
494 "cargo-outdated is not installed; run `cargo install cargo-outdated`"
495 ),
496 Self::SubprocessFailed(s) => write!(f, "dependency check subprocess failed: {s}"),
497 Self::ParseError(s) => write!(f, "could not parse subprocess output: {s}"),
498 }
499 }
500}
501
502impl std::error::Error for DepError {}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use dev_report::Verdict;
508
509 fn unused(name: &str, kind: DepKind) -> UnusedDep {
510 UnusedDep {
511 crate_name: name.into(),
512 kind,
513 }
514 }
515
516 fn outdated(name: &str, cur: &str, latest: &str, major_behind: u32) -> OutdatedDep {
517 OutdatedDep {
518 crate_name: name.into(),
519 current: cur.into(),
520 latest: latest.into(),
521 major_behind,
522 kind: Some(DepKind::Normal),
523 }
524 }
525
526 fn make_result(unused_: Vec<UnusedDep>, outdated_: Vec<OutdatedDep>) -> DepResult {
527 DepResult {
528 name: "x".into(),
529 version: "0.1.0".into(),
530 scope: DepScope::All,
531 unused: unused_,
532 outdated: outdated_,
533 escalate_at_majors: None,
534 }
535 }
536
537 #[test]
538 fn empty_findings_produces_passing_report() {
539 let r = make_result(Vec::new(), Vec::new());
540 let report = r.into_report();
541 assert!(report.passed());
542 }
543
544 #[test]
545 fn unused_findings_produce_warn_verdict() {
546 let r = make_result(vec![unused("legacy", DepKind::Normal)], Vec::new());
547 let report = r.into_report();
548 assert!(report.warned());
549 assert_eq!(report.checks.len(), 1);
550 let c = &report.checks[0];
551 assert!(c.has_tag("deps") && c.has_tag("unused"));
552 assert_eq!(c.name, "deps::unused::legacy");
553 }
554
555 #[test]
556 fn outdated_one_major_is_info() {
557 let r = make_result(Vec::new(), vec![outdated("foo", "1.0.0", "2.0.0", 1)]);
558 let sev = r.outdated[0].severity(None);
559 assert_eq!(sev, Severity::Info);
560 }
561
562 #[test]
563 fn outdated_two_or_more_majors_is_warning() {
564 let r = make_result(Vec::new(), vec![outdated("foo", "1.0.0", "3.0.0", 2)]);
565 assert_eq!(r.outdated[0].severity(None), Severity::Warning);
566 }
567
568 #[test]
569 fn escalate_at_majors_bumps_to_error_and_fail() {
570 let mut r = make_result(Vec::new(), vec![outdated("foo", "1.0.0", "5.0.0", 4)]);
571 r.escalate_at_majors = Some(3);
572 assert_eq!(r.outdated[0].severity(Some(3)), Severity::Error);
573 let report = r.into_report();
574 assert!(report.failed());
576 let c = &report.checks[0];
577 assert_eq!(c.verdict, Verdict::Fail);
578 }
579
580 #[test]
581 fn escalate_does_not_fire_below_threshold() {
582 let mut r = make_result(Vec::new(), vec![outdated("foo", "1.0.0", "2.0.0", 1)]);
583 r.escalate_at_majors = Some(3);
584 assert_eq!(r.outdated[0].severity(Some(3)), Severity::Info);
585 }
586
587 #[test]
588 fn total_findings_sums_both_categories() {
589 let r = make_result(
590 vec![
591 unused("a", DepKind::Normal),
592 unused("b", DepKind::Development),
593 ],
594 vec![outdated("c", "1.0.0", "2.0.0", 1)],
595 );
596 assert_eq!(r.total_findings(), 3);
597 assert_eq!(r.unused_count(), 2);
598 assert_eq!(r.outdated_count(), 1);
599 }
600
601 #[test]
602 fn worst_severity_picks_max_across_categories() {
603 let mut r = make_result(
604 vec![unused("a", DepKind::Normal)],
605 vec![outdated("c", "1.0.0", "3.0.0", 2)],
606 );
607 r.escalate_at_majors = Some(2);
608 assert_eq!(r.worst_severity(), Some(Severity::Error));
609 }
610
611 #[test]
612 fn result_round_trips_through_json() {
613 let r = make_result(
614 vec![unused("a", DepKind::Normal)],
615 vec![outdated("c", "1.0.0", "2.0.0", 1)],
616 );
617 let s = serde_json::to_string(&r).unwrap();
618 let back: DepResult = serde_json::from_str(&s).unwrap();
619 assert_eq!(back.unused.len(), 1);
620 assert_eq!(back.outdated.len(), 1);
621 }
622
623 #[test]
624 fn check_builder_chains() {
625 let c = DepCheck::new("x", "0.1.0")
626 .scope(DepScope::Outdated)
627 .workspace()
628 .exclude("ignored")
629 .allow("legacy-shim")
630 .allow_all(["a", "b"])
631 .severity_threshold(Severity::Warning)
632 .escalate_at_majors(3);
633 assert_eq!(c.dep_scope(), DepScope::Outdated);
634 assert_eq!(c.subject(), "x");
635 assert_eq!(c.subject_version(), "0.1.0");
636 }
637
638 #[test]
639 fn depkind_label_matches_cargo_toml() {
640 assert_eq!(DepKind::Normal.as_str(), "dependencies");
641 assert_eq!(DepKind::Development.as_str(), "dev-dependencies");
642 assert_eq!(DepKind::Build.as_str(), "build-dependencies");
643 }
644
645 #[test]
646 fn dep_scope_runs_helpers() {
647 assert!(DepScope::All.runs_unused());
648 assert!(DepScope::All.runs_outdated());
649 assert!(DepScope::Unused.runs_unused());
650 assert!(!DepScope::Unused.runs_outdated());
651 assert!(DepScope::Outdated.runs_outdated());
652 assert!(!DepScope::Outdated.runs_unused());
653 }
654}