Skip to main content

dev_deps/
lib.rs

1//! # dev-deps
2//!
3//! Dependency health checking for Rust. Detects unused, outdated, and
4//! policy-violating dependencies. Part of the `dev-*` verification suite.
5//!
6//! Wraps `cargo-udeps` (unused dependencies) and `cargo-outdated`
7//! (out-of-date versions). Emits findings as `dev-report::Report`.
8//!
9//! ## What it checks
10//!
11//! - **Unused dependencies**: declared in `Cargo.toml` but never imported.
12//! - **Outdated versions**: a newer version exists on crates.io.
13//! - **Major-version lag**: how many major versions behind we are.
14//!
15//! ## Quick example
16//!
17//! ```no_run
18//! use dev_deps::{DepCheck, DepScope};
19//!
20//! let check = DepCheck::new("my-crate", "0.1.0").scope(DepScope::All);
21//! let result = check.execute().unwrap();
22//! let report = result.into_report();
23//! ```
24//!
25//! ## Status
26//!
27//! Pre-1.0. API shape defined; subprocess integration lands in `0.9.1`.
28
29#![cfg_attr(docsrs, feature(doc_cfg))]
30#![warn(missing_docs)]
31#![warn(rust_2018_idioms)]
32
33use dev_report::{CheckResult, Report, Severity};
34
35/// Scope of a dependency check.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DepScope {
38    /// Run only the unused-dependency scanner (cargo-udeps).
39    Unused,
40    /// Run only the outdated-version scanner (cargo-outdated).
41    Outdated,
42    /// Run both.
43    All,
44}
45
46/// Configuration for a dependency check.
47#[derive(Debug, Clone)]
48pub struct DepCheck {
49    name: String,
50    version: String,
51    scope: DepScope,
52}
53
54impl DepCheck {
55    /// Begin a new dependency check.
56    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
57        Self {
58            name: name.into(),
59            version: version.into(),
60            scope: DepScope::All,
61        }
62    }
63
64    /// Set the check scope.
65    pub fn scope(mut self, scope: DepScope) -> Self {
66        self.scope = scope;
67        self
68    }
69
70    /// Selected scope.
71    pub fn dep_scope(&self) -> DepScope {
72        self.scope
73    }
74
75    /// Execute the check.
76    ///
77    /// In `0.9.0` this is a stub; subprocess integration lands in `0.9.1`.
78    pub fn execute(&self) -> Result<DepResult, DepError> {
79        Ok(DepResult {
80            name: self.name.clone(),
81            version: self.version.clone(),
82            scope: self.scope,
83            unused: Vec::new(),
84            outdated: Vec::new(),
85        })
86    }
87}
88
89/// An unused dependency finding.
90#[derive(Debug, Clone)]
91pub struct UnusedDep {
92    /// Crate name as declared in `Cargo.toml`.
93    pub crate_name: String,
94    /// Dependency kind: "dependencies", "dev-dependencies", "build-dependencies".
95    pub kind: String,
96}
97
98/// An outdated dependency finding.
99#[derive(Debug, Clone)]
100pub struct OutdatedDep {
101    /// Crate name.
102    pub crate_name: String,
103    /// Current version pinned in `Cargo.toml`.
104    pub current: String,
105    /// Latest available version on the registry.
106    pub latest: String,
107    /// How many major versions behind we are.
108    pub major_behind: u32,
109}
110
111/// Result of a dependency check.
112#[derive(Debug, Clone)]
113pub struct DepResult {
114    /// Crate name.
115    pub name: String,
116    /// Crate version.
117    pub version: String,
118    /// Scope that produced this result.
119    pub scope: DepScope,
120    /// Unused dependencies found.
121    pub unused: Vec<UnusedDep>,
122    /// Outdated dependencies found.
123    pub outdated: Vec<OutdatedDep>,
124}
125
126impl DepResult {
127    /// Total findings across all categories.
128    pub fn total_findings(&self) -> usize {
129        self.unused.len() + self.outdated.len()
130    }
131
132    /// Convert this result into a `dev-report::Report`.
133    pub fn into_report(self) -> Report {
134        let mut report = Report::new(&self.name, &self.version).with_producer("dev-deps");
135        if self.total_findings() == 0 {
136            report.push(CheckResult::pass("deps::health"));
137        } else {
138            for u in &self.unused {
139                report.push(
140                    CheckResult::warn(format!("deps::unused::{}", u.crate_name), Severity::Warning)
141                        .with_detail(format!("unused in {}", u.kind)),
142                );
143            }
144            for o in &self.outdated {
145                let sev = if o.major_behind >= 2 {
146                    Severity::Warning
147                } else {
148                    Severity::Info
149                };
150                report.push(
151                    CheckResult::warn(format!("deps::outdated::{}", o.crate_name), sev)
152                        .with_detail(format!(
153                            "{} -> {} ({} major behind)",
154                            o.current, o.latest, o.major_behind
155                        )),
156                );
157            }
158        }
159        report.finish();
160        report
161    }
162}
163
164/// Errors that can arise during a dependency check.
165#[derive(Debug)]
166pub enum DepError {
167    /// `cargo-udeps` is not installed.
168    UdepsToolNotInstalled,
169    /// `cargo-outdated` is not installed.
170    OutdatedToolNotInstalled,
171    /// Subprocess failure.
172    SubprocessFailed(String),
173    /// Output parsing failure.
174    ParseError(String),
175}
176
177impl std::fmt::Display for DepError {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        match self {
180            Self::UdepsToolNotInstalled => write!(f, "cargo-udeps is not installed"),
181            Self::OutdatedToolNotInstalled => write!(f, "cargo-outdated is not installed"),
182            Self::SubprocessFailed(s) => write!(f, "subprocess failed: {s}"),
183            Self::ParseError(s) => write!(f, "parse error: {s}"),
184        }
185    }
186}
187
188impl std::error::Error for DepError {}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn check_builds() {
196        let c = DepCheck::new("x", "0.1.0").scope(DepScope::Unused);
197        assert_eq!(c.dep_scope(), DepScope::Unused);
198    }
199
200    #[test]
201    fn empty_result_passes() {
202        let r = DepResult {
203            name: "x".into(),
204            version: "0.1.0".into(),
205            scope: DepScope::All,
206            unused: Vec::new(),
207            outdated: Vec::new(),
208        };
209        assert_eq!(r.total_findings(), 0);
210        let report = r.into_report();
211        assert!(report.passed());
212    }
213
214    #[test]
215    fn findings_produce_warnings() {
216        let r = DepResult {
217            name: "x".into(),
218            version: "0.1.0".into(),
219            scope: DepScope::All,
220            unused: vec![UnusedDep {
221                crate_name: "foo".into(),
222                kind: "dependencies".into(),
223            }],
224            outdated: vec![OutdatedDep {
225                crate_name: "bar".into(),
226                current: "1.0.0".into(),
227                latest: "3.0.0".into(),
228                major_behind: 2,
229            }],
230        };
231        let report = r.into_report();
232        // Both are Warn verdicts, so overall is Warn.
233        assert!(report.warned());
234    }
235}