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::fs;

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

use crate::error::ShedError;
use crate::project::read_to_string;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceFile {
    pub path: Utf8PathBuf,
    pub text: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceIndex {
    pub files: Vec<SourceFile>,
}

impl SourceIndex {
    pub fn scan(root: &Utf8Path) -> Result<Self, ShedError> {
        let mut files = Vec::new();

        scan_dir(&root.join("src"), &mut files)?;
        scan_dir(&root.join("tests"), &mut files)?;

        files.sort_by(|left, right| left.path.cmp(&right.path));

        Ok(Self { files })
    }

    pub fn contains_token(&self, token: &str) -> bool {
        self.files.iter().any(|file| file.text.contains(token))
    }

    pub fn crate_appears_used(&self, crate_name: &str) -> bool {
        self.files
            .iter()
            .any(|file| source_mentions_crate(file.text.as_str(), crate_name))
    }

    pub fn has_ambiguous_generation(&self) -> bool {
        self.contains_token("include!(")
            || self.contains_token("macro_rules!")
            || self.contains_token("proc_macro")
    }
}

fn scan_dir(path: &Utf8Path, files: &mut Vec<SourceFile>) -> Result<(), ShedError> {
    if !path.exists() {
        return Ok(());
    }

    let entries = fs::read_dir(path).map_err(|source| ShedError::Read {
        path: path.to_path_buf(),
        source,
    })?;

    for entry in entries {
        let entry = entry.map_err(|source| ShedError::Read {
            path: path.to_path_buf(),
            source,
        })?;
        let path = Utf8PathBuf::from_path_buf(entry.path())
            .map_err(|path| ShedError::NonUtf8Path { path })?;

        if path.is_dir() {
            scan_dir(&path, files)?;
        } else if path.extension() == Some("rs") {
            let text = read_to_string(&path)?;
            files.push(SourceFile { path, text });
        }
    }

    Ok(())
}

fn source_mentions_crate(text: &str, crate_name: &str) -> bool {
    contains_crate_suffix(text, crate_name, "::")
        || contains_crate_suffix(text, crate_name, "!")
        || contains_crate_suffix(text, crate_name, ";")
        || contains_extern_crate(text, crate_name)
        || contains_dynamic(text, format!("use {crate_name} as "))
        || contains_dynamic(text, format!("#[{crate_name}]"))
        || contains_dynamic(text, format!("#[{crate_name}("))
}

fn contains_extern_crate(text: &str, crate_name: &str) -> bool {
    let needle = format!("extern crate {crate_name}");
    let mut offset = 0;

    while let Some(index) = text[offset..].find(needle.as_str()) {
        let end = offset + index + needle.len();

        if text
            .as_bytes()
            .get(end)
            .is_none_or(|byte| !is_ident_byte(*byte))
        {
            return true;
        }

        offset = end;
    }

    false
}

fn contains_crate_suffix(text: &str, crate_name: &str, suffix: &str) -> bool {
    let needle = format!("{crate_name}{suffix}");
    let mut offset = 0;

    while let Some(index) = text[offset..].find(needle.as_str()) {
        let absolute = offset + index;

        if absolute == 0 || !is_ident_byte(text.as_bytes()[absolute - 1]) {
            return true;
        }

        offset = absolute + crate_name.len();
    }

    false
}

fn is_ident_byte(byte: u8) -> bool {
    byte == b'_' || byte.is_ascii_alphanumeric()
}

fn contains_dynamic(text: &str, pattern: String) -> bool {
    text.contains(pattern.as_str())
}

#[cfg(test)]
mod tests {
    use super::source_mentions_crate;

    #[test]
    fn detects_crate_paths() {
        assert!(source_mentions_crate(
            "use serde_json::Value;",
            "serde_json"
        ));
    }

    #[test]
    fn ignores_longer_identifier_prefixes() {
        assert!(!source_mentions_crate("extern crate serde_json;", "serde"));
    }
}