Skip to main content

cargo_shear/
lib.rs

1//! # cargo-shear
2//!
3//! A tool for detecting and removing unused dependencies from Rust projects.
4//!
5//! ## Overview
6//!
7//! `cargo-shear` analyzes your Rust codebase to identify dependencies that are declared
8//! in `Cargo.toml` but never actually used in the code. It can automatically remove
9//! these unused dependencies with the `--fix` flag.
10//!
11//! ## Architecture
12//!
13//! The codebase is organized into several focused modules:
14//!
15//! - `cargo_toml_editor` - Handles modifications to Cargo.toml files
16//! - `package_analyzer` - Analyzes packages to find issues
17//! - `package_processor` - Processes packages and detects unused dependencies
18//! - `source_parser` - Parses Rust source to extract data
19//!
20//! ## Usage
21//!
22//! ```no_run
23//! use cargo_shear::{CargoShear, CargoShearOptions};
24//!
25//! let options = CargoShearOptions::new(std::path::PathBuf::from("."));
26//! let exit_code = CargoShear::new(std::io::stdout(), options).run();
27//! ```
28
29mod cargo_toml_editor;
30mod context;
31mod diagnostics;
32mod manifest;
33mod output;
34mod package_analyzer;
35mod package_processor;
36mod source_parser;
37#[cfg(test)]
38mod tests;
39pub mod util;
40
41use std::{
42    env, fs,
43    io::Write,
44    path::{Path, PathBuf},
45    process::ExitCode,
46    str::FromStr,
47};
48
49use anyhow::Result;
50use bpaf::Bpaf;
51use cargo_metadata::{CargoOpt, Metadata, MetadataCommand, Package};
52use owo_colors::OwoColorize;
53use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
54use rustc_hash::FxHashSet;
55use toml_edit::DocumentMut;
56
57pub use crate::output::{ColorMode, OutputFormat};
58use crate::{
59    cargo_toml_editor::CargoTomlEditor,
60    context::{PackageContext, WorkspaceContext},
61    diagnostics::ShearAnalysis,
62    output::Renderer,
63    package_processor::{PackageAnalysis, PackageProcessor, WorkspaceAnalysis},
64    util::read_to_string,
65};
66
67const VERSION: &str = match option_env!("SHEAR_VERSION") {
68    Some(v) => v,
69    None => "dev",
70};
71
72/// Command-line options for cargo-shear.
73///
74/// This struct is parsed from command-line arguments using `bpaf`.
75/// The "batteries" feature strips the binary name using `bpaf::cargo_helper`.
76///
77/// See <https://docs.rs/bpaf/latest/bpaf/batteries/fn.cargo_helper.html>
78#[derive(Debug, Clone, Bpaf)]
79#[bpaf(options("shear"), version(VERSION))]
80pub struct CargoShearOptions {
81    /// Remove unused dependencies.
82    ///
83    /// When set, cargo-shear will automatically remove detected unused
84    /// dependencies from Cargo.toml files.
85    #[bpaf(long)]
86    fix: bool,
87
88    /// Uses `cargo expand` to expand macros, which requires nightly and is significantly slower.
89    ///
90    /// This option provides more accurate detection by expanding proc macros
91    /// and attribute macros, but requires a nightly Rust toolchain.
92    #[bpaf(long)]
93    expand: bool,
94
95    /// Treat warnings as errors.
96    ///
97    /// When set, warnings will cause cargo-shear to exit with a failure code.
98    #[bpaf(long("deny-warnings"))]
99    deny_warnings: bool,
100
101    /// Assert that `Cargo.lock` will remain unchanged.
102    locked: bool,
103
104    /// Run without accessing the network
105    offline: bool,
106
107    /// Equivalent to specifying both --locked and --offline
108    frozen: bool,
109
110    /// Package(s) to check.
111    ///
112    /// If not specified, all packages in the workspace are checked.
113    /// Can be specified multiple times to check specific packages.
114    #[bpaf(long, short, argument("SPEC"))]
115    package: Vec<String>,
116
117    /// Exclude packages from the check.
118    ///
119    /// Can be specified multiple times to exclude multiple packages.
120    exclude: Vec<String>,
121
122    /// Output format: auto, json, github
123    #[bpaf(long, fallback(OutputFormat::Auto))]
124    format: OutputFormat,
125
126    /// Color usage for output: auto, always, never
127    #[bpaf(long, fallback(ColorMode::Auto))]
128    color: ColorMode,
129
130    /// Path to the project directory.
131    ///
132    /// Defaults to the current directory if not specified.
133    #[bpaf(positional("PATH"), fallback_with(default_path))]
134    path: PathBuf,
135}
136
137impl CargoShearOptions {
138    /// Create new options with the given path.
139    #[must_use]
140    pub fn new(path: PathBuf) -> Self {
141        Self {
142            path,
143            fix: false,
144            expand: false,
145            deny_warnings: false,
146            locked: false,
147            offline: false,
148            frozen: false,
149            package: vec![],
150            exclude: vec![],
151            format: OutputFormat::default(),
152            color: ColorMode::default(),
153        }
154    }
155
156    /// Enable fix mode.
157    #[must_use]
158    pub const fn with_fix(mut self) -> Self {
159        self.fix = true;
160        self
161    }
162
163    /// Enable macro expansion.
164    #[must_use]
165    pub const fn with_expand(mut self) -> Self {
166        self.expand = true;
167        self
168    }
169
170    /// Enable deny warnings mode.
171    #[must_use]
172    pub const fn with_deny_warnings(mut self) -> Self {
173        self.deny_warnings = true;
174        self
175    }
176
177    /// Enable locked mode.
178    #[must_use]
179    pub const fn with_locked(mut self) -> Self {
180        self.locked = true;
181        self
182    }
183
184    /// Enable offline mode.
185    #[must_use]
186    pub const fn with_offline(mut self) -> Self {
187        self.offline = true;
188        self
189    }
190
191    /// Enable frozen mode.
192    #[must_use]
193    pub const fn with_frozen(mut self) -> Self {
194        self.frozen = true;
195        self
196    }
197
198    /// Set packages to check.
199    #[must_use]
200    pub fn with_packages(mut self, packages: Vec<String>) -> Self {
201        self.package = packages;
202        self
203    }
204
205    /// Set packages to exclude.
206    #[must_use]
207    pub fn with_excludes(mut self, excludes: Vec<String>) -> Self {
208        self.exclude = excludes;
209        self
210    }
211
212    /// Set output format.
213    #[must_use]
214    pub const fn with_format(mut self, format: OutputFormat) -> Self {
215        self.format = format;
216        self
217    }
218
219    /// Set color mode.
220    #[must_use]
221    pub const fn with_color(mut self, color: ColorMode) -> Self {
222        self.color = color;
223        self
224    }
225
226    /// Resolve auto-detected options based on environment.
227    ///
228    /// This should be called after CLI parsing to resolve `Auto` format
229    /// to a concrete format based on environment (e.g., GitHub Actions).
230    #[must_use]
231    pub fn resolve(mut self) -> Self {
232        self.format = self.format.resolve();
233        self
234    }
235}
236
237pub(crate) fn default_path() -> Result<PathBuf> {
238    Ok(env::current_dir()?)
239}
240
241/// The main struct that orchestrates the dependency analysis and removal process.
242///
243/// `CargoShear` coordinates the analysis of a Rust project to find unused dependencies
244/// and optionally removes them from Cargo.toml files.
245pub struct CargoShear<W> {
246    /// Writer for output
247    writer: W,
248
249    /// Configuration options for the analysis
250    options: CargoShearOptions,
251
252    /// Result of the analysis.
253    analysis: ShearAnalysis,
254}
255
256impl<W: Write> CargoShear<W> {
257    /// Create a new `CargoShear` instance with the given options.
258    ///
259    /// # Arguments
260    ///
261    /// * `writer` - Output writer
262    /// * `options` - Configuration options for the analysis
263    ///
264    /// # Example
265    ///
266    /// ```
267    /// use cargo_shear::{CargoShear, CargoShearOptions};
268    /// use std::path::PathBuf;
269    ///
270    /// let options = CargoShearOptions::new(PathBuf::from("."));
271    /// let shear = CargoShear::new(std::io::stdout(), options);
272    /// ```
273    #[must_use]
274    pub fn new(writer: W, options: CargoShearOptions) -> Self {
275        let analysis = ShearAnalysis::new(options.clone());
276        Self { writer, options, analysis }
277    }
278
279    /// Run the dependency analysis and optionally fix unused dependencies.
280    ///
281    /// This method performs the complete analysis workflow:
282    /// 1. Analyzes all packages in the workspace
283    /// 2. Detects unused dependencies
284    /// 3. Optionally removes them if `--fix` is enabled
285    /// 4. Reports results to the writer
286    ///
287    /// # Returns
288    ///
289    /// Returns an `ExitCode` indicating success or failure:
290    /// - `0` if no issues were found or all issues were fixed
291    /// - `1` if unused dependencies were found (without `--fix`)
292    /// - `2` if an error occurred
293    #[must_use]
294    pub fn run(mut self) -> ExitCode {
295        match self.shear() {
296            Ok(()) => {
297                let color = self.options.color.enabled();
298                let mut renderer = Renderer::new(&mut self.writer, self.options.format, color);
299
300                if let Err(err) = renderer.render(&self.analysis) {
301                    let _ = writeln!(self.writer, "error rendering report: {err:?}");
302                    return ExitCode::from(2);
303                }
304
305                self.determine_exit_code()
306            }
307            Err(err) => {
308                let _ = writeln!(self.writer, "error: {err:?}");
309                ExitCode::from(2)
310            }
311        }
312    }
313
314    /// Determine the exit code based on analysis results and options.
315    const fn determine_exit_code(&self) -> ExitCode {
316        // If we fixed issues successfully and there are no remaining errors, exit with success
317        if self.options.fix && self.analysis.fixed > 0 && self.analysis.errors == 0 {
318            return ExitCode::SUCCESS;
319        }
320
321        // Exit with failure if there are errors or warnings (when --deny-warnings is set)
322        let has_errors = self.analysis.errors > 0;
323        let has_warnings = self.options.deny_warnings && self.analysis.warnings > 0;
324
325        if has_errors || has_warnings { ExitCode::FAILURE } else { ExitCode::SUCCESS }
326    }
327
328    fn shear(&mut self) -> Result<()> {
329        let mut extra_opts = Vec::new();
330        if self.options.locked {
331            extra_opts.push("--locked".to_owned());
332        }
333        if self.options.offline {
334            extra_opts.push("--offline".to_owned());
335        }
336        if self.options.frozen {
337            extra_opts.push("--frozen".to_owned());
338        }
339
340        let metadata = MetadataCommand::new()
341            .features(CargoOpt::AllFeatures)
342            .current_dir(&self.options.path)
343            .other_options(extra_opts)
344            .verbose(true)
345            .exec()
346            .map_err(|e| anyhow::anyhow!("Metadata error: {e}"))?;
347
348        let processor = PackageProcessor::new(self.options.expand);
349        let workspace_ctx = WorkspaceContext::new(&metadata)?;
350
351        let packages = metadata.workspace_packages();
352        let packages: Vec<_> = packages
353            .into_iter()
354            .filter(|package| {
355                // Skip if package is in the exclude list
356                if self.options.exclude.iter().any(|name| name == package.name.as_str()) {
357                    return false;
358                }
359
360                // Skip if specific packages are specified and this package is not in the list
361                if !self.options.package.is_empty()
362                    && !self.options.package.iter().any(|name| name == package.name.as_str())
363                {
364                    return false;
365                }
366
367                true
368            })
369            .collect();
370
371        let total = packages.len();
372        let results: Vec<_> = if self.options.expand {
373            // Process packages sequentially, since expand needs to invoke `cargo build`.
374            packages
375                .iter()
376                .enumerate()
377                .map(|(index, package)| {
378                    eprintln!(
379                        "{:>12} {} [{}/{}]",
380                        "Expanding".bright_cyan().bold(),
381                        package.name,
382                        index + 1,
383                        total
384                    );
385
386                    Self::process_package(&processor, &workspace_ctx, package, &metadata)
387                })
388                .collect::<Result<Vec<_>>>()?
389        } else {
390            // Process packages in parallel
391            packages
392                .par_iter()
393                .map(|package| {
394                    Self::process_package(&processor, &workspace_ctx, package, &metadata)
395                })
396                .collect::<Result<Vec<_>>>()?
397        };
398
399        let mut used_workspace_ignore_paths: FxHashSet<String> = FxHashSet::default();
400        for (ctx, result) in results {
401            let fixed = self.fix_package_issues(&ctx.manifest_path, &result)?;
402            used_workspace_ignore_paths.extend(result.used_workspace_ignore_paths.iter().cloned());
403            self.analysis.add_package_result(&ctx, &result, fixed);
404        }
405
406        // Only analyze workspace if we're targeting all packages.
407        if self.options.package.is_empty() && self.options.exclude.is_empty() {
408            let workspace_result = PackageProcessor::process_workspace(
409                &workspace_ctx,
410                &self.analysis.packages,
411                &used_workspace_ignore_paths,
412            );
413
414            let fixed =
415                self.fix_workspace_issues(&workspace_ctx.manifest_path, &workspace_result)?;
416            self.analysis.add_workspace_result(&workspace_ctx, &workspace_result, fixed);
417        }
418
419        Ok(())
420    }
421
422    fn process_package<'a>(
423        processor: &PackageProcessor,
424        workspace_ctx: &'a WorkspaceContext,
425        package: &Package,
426        metadata: &'a Metadata,
427    ) -> Result<(PackageContext<'a>, PackageAnalysis)> {
428        let ctx = PackageContext::new(workspace_ctx, package, metadata)?;
429        let result = processor.process_package(&ctx)?;
430        Ok((ctx, result))
431    }
432
433    fn fix_package_issues(&self, manifest_path: &Path, result: &PackageAnalysis) -> Result<usize> {
434        if !self.options.fix {
435            return Ok(0);
436        }
437
438        if !result.has_fixable_issues() {
439            return Ok(0);
440        }
441
442        let content = read_to_string(manifest_path)?;
443        let mut manifest = DocumentMut::from_str(&content)?;
444
445        let fixed_unused =
446            CargoTomlEditor::remove_dependencies(&mut manifest, &result.unused_dependencies);
447        let fixed_misplaced = CargoTomlEditor::move_to_dev_dependencies(
448            &mut manifest,
449            &result.misplaced_dependencies,
450        );
451        let mut flag_fixes = 0usize;
452        if !result.test_disabled_with_tests.is_empty() {
453            flag_fixes += usize::from(CargoTomlEditor::remove_lib_flag(&mut manifest, "test"));
454        }
455        if !result.test_enabled_without_tests.is_empty() {
456            CargoTomlEditor::set_lib_flag_false(&mut manifest, "test");
457            flag_fixes += 1;
458        }
459        if !result.doctest_disabled_with_doctests.is_empty() {
460            flag_fixes += usize::from(CargoTomlEditor::remove_lib_flag(&mut manifest, "doctest"));
461        }
462        if !result.doctest_enabled_without_doctests.is_empty() {
463            CargoTomlEditor::set_lib_flag_false(&mut manifest, "doctest");
464            flag_fixes += 1;
465        }
466
467        fs::write(manifest_path, manifest.to_string())?;
468        Ok(fixed_unused + fixed_misplaced + flag_fixes)
469    }
470
471    fn fix_workspace_issues(
472        &self,
473        manifest_path: &Path,
474        result: &WorkspaceAnalysis,
475    ) -> Result<usize> {
476        if !self.options.fix {
477            return Ok(0);
478        }
479
480        if result.unused_dependencies.is_empty() {
481            return Ok(0);
482        }
483
484        let content = read_to_string(manifest_path)?;
485        let mut manifest = DocumentMut::from_str(&content)?;
486
487        let fixed =
488            CargoTomlEditor::remove_workspace_deps(&mut manifest, &result.unused_dependencies);
489
490        fs::write(manifest_path, manifest.to_string())?;
491        Ok(fixed)
492    }
493}