cargo-shed 0.1.0

A Cargo subcommand that finds dependency bloat, risky features, duplicate crate versions, and safe cleanup opportunities in Rust projects.
Documentation
use std::env;
use std::fs;

use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use toml_edit::{DocumentMut, Item};

use crate::error::{ShedError, utf8_path};
use crate::lockfile::Lockfile;
use crate::manifest::Manifest;
use crate::scan::SourceIndex;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProjectKind {
    SingleCrate,
    WorkspaceRoot,
    WorkspaceMember,
}

#[derive(Debug, Clone)]
pub struct Project {
    pub root: Utf8PathBuf,
    pub manifest_path: Utf8PathBuf,
    pub manifest: Manifest,
    pub lockfile: Option<Lockfile>,
    pub source_index: SourceIndex,
    pub kind: ProjectKind,
}

impl Project {
    pub fn load(manifest_path: Option<&Utf8PathBuf>) -> Result<Self, ShedError> {
        let manifest_path = resolve_manifest_path(manifest_path)?;

        if !manifest_path.exists() {
            return Err(ShedError::ManifestNotFound {
                path: manifest_path,
            });
        }

        let root = manifest_path
            .parent()
            .map(Utf8Path::to_path_buf)
            .unwrap_or_else(|| Utf8PathBuf::from("."));
        let manifest = Manifest::load(&manifest_path)?;
        let lockfile_path = root.join("Cargo.lock");
        let lockfile = if lockfile_path.exists() {
            Some(Lockfile::load(&lockfile_path)?)
        } else {
            None
        };
        let source_index = SourceIndex::scan(&root)?;
        let kind = project_kind(&root, &manifest);

        Ok(Self {
            root,
            manifest_path,
            manifest,
            lockfile,
            source_index,
            kind,
        })
    }

    pub fn skipped_checks(&self) -> Vec<String> {
        if self.lockfile.is_none() {
            vec!["Cargo.lock was not found, so lockfile-based rules were skipped".to_owned()]
        } else {
            Vec::new()
        }
    }
}

fn project_kind(root: &Utf8Path, manifest: &Manifest) -> ProjectKind {
    if manifest.is_workspace_root() {
        return ProjectKind::WorkspaceRoot;
    }

    if has_parent_workspace(root) {
        ProjectKind::WorkspaceMember
    } else {
        ProjectKind::SingleCrate
    }
}

fn has_parent_workspace(root: &Utf8Path) -> bool {
    let mut current = root.parent();

    while let Some(parent) = current {
        let manifest_path = parent.join("Cargo.toml");

        if manifest_path.exists()
            && read_to_string(&manifest_path)
                .ok()
                .and_then(|raw| raw.parse::<DocumentMut>().ok())
                .and_then(|document| document.get("workspace").map(Item::is_table_like))
                .unwrap_or(false)
        {
            return true;
        }

        current = parent.parent();
    }

    false
}

fn resolve_manifest_path(path: Option<&Utf8PathBuf>) -> Result<Utf8PathBuf, ShedError> {
    match path {
        Some(path) if path.is_dir() => Ok(path.join("Cargo.toml")),
        Some(path) => Ok(path.to_owned()),
        None => {
            let cwd = utf8_path(env::current_dir().map_err(|source| ShedError::Read {
                path: Utf8PathBuf::from("."),
                source,
            })?)?;
            Ok(cwd.join("Cargo.toml"))
        }
    }
}

pub(crate) fn read_to_string(path: &Utf8Path) -> Result<String, ShedError> {
    fs::read_to_string(path).map_err(|source| ShedError::Read {
        path: path.to_path_buf(),
        source,
    })
}