Skip to main content

dev_deps/
lib.rs

1//! # dev-deps
2//!
3//! Dependency health checking for Rust. Detects unused and outdated
4//! dependencies. Part of the `dev-*` verification suite.
5//!
6//! Wraps [`cargo-udeps`][cargo-udeps] (unused dependencies) and
7//! [`cargo-outdated`][cargo-outdated] (out-of-date versions). Emits
8//! findings as [`dev_report::Report`].
9//!
10//! ## What it checks
11//!
12//! - **Unused dependencies** — declared in `Cargo.toml` but never imported.
13//! - **Outdated versions** — a newer version exists on crates.io.
14//! - **Major-version lag** — how many major versions behind the current pin is.
15//!
16//! ## Quick example
17//!
18//! ```no_run
19//! use dev_deps::{DepCheck, DepScope};
20//!
21//! let check = DepCheck::new("my-crate", "0.1.0").scope(DepScope::All);
22//! let result = check.execute().unwrap();
23//! let report = result.into_report();
24//! ```
25//!
26//! ## Requirements
27//!
28//! ```text
29//! cargo install cargo-udeps cargo-outdated
30//! rustup toolchain install nightly      # cargo-udeps requires nightly
31//! ```
32//!
33//! [cargo-udeps]: https://crates.io/crates/cargo-udeps
34//! [cargo-outdated]: https://crates.io/crates/cargo-outdated
35
36#![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// ---------------------------------------------------------------------------
52// DepScope
53// ---------------------------------------------------------------------------
54
55/// Scope of a dependency check.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum DepScope {
59    /// Run only the unused-dependency scanner (`cargo udeps`).
60    Unused,
61    /// Run only the outdated-version scanner (`cargo outdated`).
62    Outdated,
63    /// Run both.
64    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// ---------------------------------------------------------------------------
78// DepKind
79// ---------------------------------------------------------------------------
80
81/// Where a dependency is declared in `Cargo.toml`.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum DepKind {
85    /// `[dependencies]`.
86    Normal,
87    /// `[dev-dependencies]`.
88    Development,
89    /// `[build-dependencies]`.
90    Build,
91}
92
93impl DepKind {
94    /// Human-readable label, matching the `Cargo.toml` section name.
95    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// ---------------------------------------------------------------------------
105// DepCheck
106// ---------------------------------------------------------------------------
107
108/// Configuration for a dependency check.
109///
110/// # Example
111///
112/// ```no_run
113/// use dev_deps::{DepCheck, DepScope};
114/// use dev_report::Severity;
115///
116/// let check = DepCheck::new("my-crate", "0.1.0")
117///     .scope(DepScope::All)
118///     .workspace()
119///     .allow("legacy-shim")
120///     .severity_threshold(Severity::Warning)
121///     .escalate_at_majors(3);
122///
123/// let _result = check.execute().unwrap();
124/// ```
125#[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    /// Begin a new dependency check.
140    ///
141    /// `name` and `version` are descriptive — they identify the subject
142    /// in the produced `Report`.
143    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    /// Pick which checks to run. Defaults to [`DepScope::All`].
158    pub fn scope(mut self, scope: DepScope) -> Self {
159        self.scope = scope;
160        self
161    }
162
163    /// Selected scope.
164    pub fn dep_scope(&self) -> DepScope {
165        self.scope
166    }
167
168    /// Run the subprocesses from `dir` instead of the current directory.
169    pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
170        self.workdir = Some(dir.into());
171        self
172    }
173
174    /// Pass `--workspace` so every workspace member is checked.
175    pub fn workspace(mut self) -> Self {
176        self.workspace = true;
177        self
178    }
179
180    /// Exclude a crate from both scanners. May be called multiple times.
181    pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
182        self.excludes.push(pattern.into());
183        self
184    }
185
186    /// Suppress findings against a specific crate name. Matches both
187    /// unused and outdated findings on `crate_name`.
188    pub fn allow(mut self, crate_name: impl Into<String>) -> Self {
189        self.allow_list.push(crate_name.into());
190        self
191    }
192
193    /// Bulk version of [`allow`](Self::allow).
194    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    /// Discard findings whose severity is *below* `threshold`. Findings
204    /// at or above the threshold are kept.
205    pub fn severity_threshold(mut self, threshold: Severity) -> Self {
206        self.severity_threshold = Some(threshold);
207        self
208    }
209
210    /// Escalate outdated findings to a *failing* check when the crate
211    /// is at least `n` major versions behind. By default, outdated
212    /// findings produce only `Warn`-verdict checks.
213    pub fn escalate_at_majors(mut self, n: u32) -> Self {
214        self.escalate_at_majors = Some(n);
215        self
216    }
217
218    /// Subject name passed in via [`new`](Self::new).
219    pub fn subject(&self) -> &str {
220        &self.name
221    }
222
223    /// Subject version passed in via [`new`](Self::new).
224    pub fn subject_version(&self) -> &str {
225        &self.version
226    }
227
228    /// Execute the check.
229    ///
230    /// Each enabled tool is invoked as a subprocess. Findings are
231    /// filtered through the allow-list and severity threshold, then
232    /// sorted by `crate_name` for determinism.
233    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// ---------------------------------------------------------------------------
275// UnusedDep + OutdatedDep
276// ---------------------------------------------------------------------------
277
278/// An unused-dependency finding.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct UnusedDep {
281    /// Crate name as declared in `Cargo.toml`.
282    pub crate_name: String,
283    /// Where the dependency is declared.
284    pub kind: DepKind,
285}
286
287impl UnusedDep {
288    /// Severity assigned to this finding (always `Warning`).
289    pub fn severity(&self) -> Severity {
290        Severity::Warning
291    }
292}
293
294/// An outdated-dependency finding.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct OutdatedDep {
297    /// Crate name.
298    pub crate_name: String,
299    /// Current version pinned in `Cargo.toml`.
300    pub current: String,
301    /// Latest available version on the registry.
302    pub latest: String,
303    /// How many major versions behind the current pin is.
304    pub major_behind: u32,
305    /// Where the dependency is declared, when the underlying tool exposed it.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub kind: Option<DepKind>,
308}
309
310impl OutdatedDep {
311    /// Severity assigned to this finding.
312    ///
313    /// `Info` for 0–1 major behind; `Warning` for 2+ majors behind.
314    /// When `escalate_at` is `Some(n)` and the crate is at least `n`
315    /// majors behind, the severity is bumped to `Error` so the produced
316    /// `CheckResult` becomes a failing verdict.
317    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// ---------------------------------------------------------------------------
332// DepResult
333// ---------------------------------------------------------------------------
334
335/// Result of a dependency check.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct DepResult {
338    /// Subject name.
339    pub name: String,
340    /// Subject version.
341    pub version: String,
342    /// Scope that produced this result.
343    pub scope: DepScope,
344    /// Unused dependencies.
345    pub unused: Vec<UnusedDep>,
346    /// Outdated dependencies.
347    pub outdated: Vec<OutdatedDep>,
348    /// Major-version escalation threshold that was active when the
349    /// result was produced. Carried so `into_report` can re-derive the
350    /// severity each finding would have had at execution time.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub escalate_at_majors: Option<u32>,
353}
354
355impl DepResult {
356    /// Total findings across both categories.
357    pub fn total_findings(&self) -> usize {
358        self.unused.len() + self.outdated.len()
359    }
360
361    /// Number of unused-dependency findings.
362    pub fn unused_count(&self) -> usize {
363        self.unused.len()
364    }
365
366    /// Number of outdated-dependency findings.
367    pub fn outdated_count(&self) -> usize {
368        self.outdated.len()
369    }
370
371    /// Highest severity present across all findings, if any.
372    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    /// Convert this result into a [`dev_report::Report`].
391    ///
392    /// No findings → one `CheckResult::pass("deps::health")`. Otherwise
393    /// one `CheckResult` per finding, named `deps::unused::<crate>` or
394    /// `deps::outdated::<crate>`, tagged `deps` plus a kind-specific
395    /// tag (`unused` or `outdated`). Outdated findings carry numeric
396    /// evidence for `major_behind`.
397    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                    // Escalated; produce a failing verdict.
422                    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// ---------------------------------------------------------------------------
469// DepError
470// ---------------------------------------------------------------------------
471
472/// Errors that can arise during a dependency check.
473#[derive(Debug)]
474pub enum DepError {
475    /// `cargo-udeps` (or the nightly toolchain it needs) is not installed.
476    UdepsToolNotInstalled,
477    /// `cargo-outdated` is not installed.
478    OutdatedToolNotInstalled,
479    /// Subprocess failure with the captured stderr.
480    SubprocessFailed(String),
481    /// Output parsing failure.
482    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        // Error severity on outdated means CheckResult::fail; overall failed.
575        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}