use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccurateDeadCodeReport {
pub files_with_dead_code: Vec<FileDeadCode>,
pub total_dead_items: usize,
pub dead_code_percentage: f64,
pub total_lines: usize,
pub dead_lines: usize,
pub dead_by_type: HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDeadCode {
pub file_path: PathBuf,
pub dead_items: Vec<DeadItem>,
pub file_dead_percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeadItem {
pub name: String,
pub kind: DeadCodeKind,
pub line: usize,
pub column: usize,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum DeadCodeKind {
Function,
Method,
Struct,
Enum,
Variant,
Field,
Constant,
Static,
Module,
Trait,
TypeAlias,
Other(String),
}
pub struct CargoDeadCodeAnalyzer {
project_path: PathBuf,
exclude_tests: bool,
exclude_examples: bool,
exclude_benches: bool,
}
impl CargoDeadCodeAnalyzer {
pub fn new(project_path: impl AsRef<Path>) -> Self {
Self {
project_path: project_path.as_ref().to_path_buf(),
exclude_tests: true,
exclude_examples: true,
exclude_benches: true,
}
}
#[must_use]
pub fn include_tests(mut self) -> Self {
self.exclude_tests = false;
self
}
#[must_use]
pub fn include_examples(mut self) -> Self {
self.exclude_examples = false;
self
}
#[must_use]
pub fn include_benches(mut self) -> Self {
self.exclude_benches = false;
self
}
pub async fn analyze(&self) -> Result<AccurateDeadCodeReport> {
let cargo_output = self.run_cargo_check()?;
let dead_items = self.parse_cargo_warnings(&cargo_output)?;
let files_with_dead_code = self.group_by_file(dead_items);
let report = self.calculate_metrics(files_with_dead_code).await?;
Ok(report)
}
fn run_cargo_check(&self) -> Result<String> {
let mut cmd = Command::new("cargo");
cmd.current_dir(&self.project_path)
.arg("check")
.arg("--message-format=json")
.arg("--all-targets");
if self.exclude_tests {
cmd.arg("--lib").arg("--bins");
}
let output = cmd.output().context("Failed to run cargo check")?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.to_string())
}
fn parse_cargo_warnings(&self, output: &str) -> Result<Vec<(PathBuf, DeadItem)>> {
let mut dead_items = Vec::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
let json: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue, };
if json["reason"] != "compiler-message" {
continue;
}
let message = &json["message"];
if let Some(code) = message["code"]["code"].as_str() {
if code == "dead_code" {
if let Some(item) = self.extract_dead_item(message) {
dead_items.push(item);
}
}
}
}
Ok(dead_items)
}
fn extract_dead_item(&self, message: &Value) -> Option<(PathBuf, DeadItem)> {
let spans = message["spans"].as_array()?;
let primary_span = spans
.iter()
.find(|s| s["is_primary"].as_bool() == Some(true))?;
let file_path = PathBuf::from(primary_span["file_name"].as_str()?);
let line = primary_span["line_start"].as_u64()? as usize;
let column = primary_span["column_start"].as_u64()? as usize;
let message_text = message["message"].as_str()?;
let (name, kind) = self.parse_message(message_text)?;
Some((
file_path.clone(),
DeadItem {
name,
kind,
line,
column,
message: message_text.to_string(),
},
))
}
fn parse_message(&self, message: &str) -> Option<(String, DeadCodeKind)> {
let patterns = [
("function `", "` is never used", DeadCodeKind::Function),
("method `", "` is never used", DeadCodeKind::Method),
("struct `", "` is never constructed", DeadCodeKind::Struct),
("enum `", "` is never used", DeadCodeKind::Enum),
("variant `", "` is never constructed", DeadCodeKind::Variant),
("field `", "` is never read", DeadCodeKind::Field),
("constant `", "` is never used", DeadCodeKind::Constant),
("static `", "` is never used", DeadCodeKind::Static),
("module `", "` is never used", DeadCodeKind::Module),
("trait `", "` is never used", DeadCodeKind::Trait),
("type alias `", "` is never used", DeadCodeKind::TypeAlias),
];
for (prefix, suffix, kind) in &patterns {
if let Some(start) = message.find(prefix) {
let name_start = start + prefix.len();
if let Some(end) = message[name_start..].find(suffix) {
let name = message[name_start..name_start + end].to_string();
return Some((name, kind.clone()));
}
}
}
if message.contains("is never") || message.contains("never used") {
if let Some(start) = message.find('`') {
if let Some(end) = message[start + 1..].find('`') {
let name = message[start + 1..start + 1 + end].to_string();
return Some((name, DeadCodeKind::Other("unknown".to_string())));
}
}
}
None
}
fn group_by_file(&self, items: Vec<(PathBuf, DeadItem)>) -> Vec<FileDeadCode> {
let mut file_map: HashMap<PathBuf, Vec<DeadItem>> = HashMap::new();
for (path, item) in items {
file_map.entry(path).or_default().push(item);
}
file_map
.into_iter()
.map(|(file_path, dead_items)| {
let file_dead_percentage = self
.calculate_file_percentage(&file_path, &dead_items)
.unwrap_or(0.0);
FileDeadCode {
file_path,
dead_items,
file_dead_percentage,
}
})
.collect()
}
fn calculate_file_percentage(&self, file_path: &Path, dead_items: &[DeadItem]) -> Result<f64> {
let full_path = if file_path.is_absolute() {
file_path.to_path_buf()
} else {
self.project_path.join(file_path)
};
if !full_path.exists() {
return Ok(0.0);
}
let content =
std::fs::read_to_string(&full_path).context("Failed to read file for line counting")?;
let total_lines = content.lines().count();
if total_lines == 0 {
return Ok(0.0);
}
let estimated_dead_lines = dead_items.len() * 4;
let percentage = (estimated_dead_lines as f64 / total_lines as f64) * 100.0;
Ok(percentage.min(100.0))
}
async fn calculate_metrics(&self, files: Vec<FileDeadCode>) -> Result<AccurateDeadCodeReport> {
let mut total_lines = 0;
let mut dead_lines = 0;
let mut dead_by_type = HashMap::new();
let total_dead_items = files.iter().map(|f| f.dead_items.len()).sum();
for entry in walkdir::WalkDir::new(&self.project_path)
.into_iter()
.filter_map(std::result::Result::ok)
{
let path = entry.path();
if path.starts_with(self.project_path.join("target")) {
continue;
}
if path.extension().and_then(|s| s.to_str()) == Some("rs") {
if let Ok(content) = std::fs::read_to_string(path) {
total_lines += content.lines().count();
}
}
}
for file in &files {
for item in &file.dead_items {
let kind_str = match &item.kind {
DeadCodeKind::Function => "function",
DeadCodeKind::Method => "method",
DeadCodeKind::Struct => "struct",
DeadCodeKind::Enum => "enum",
DeadCodeKind::Variant => "variant",
DeadCodeKind::Field => "field",
DeadCodeKind::Constant => "constant",
DeadCodeKind::Static => "static",
DeadCodeKind::Module => "module",
DeadCodeKind::Trait => "trait",
DeadCodeKind::TypeAlias => "type_alias",
DeadCodeKind::Other(s) => s,
};
*dead_by_type.entry(kind_str.to_string()).or_insert(0) += 1;
let lines = match item.kind {
DeadCodeKind::Function | DeadCodeKind::Method => 5,
DeadCodeKind::Struct | DeadCodeKind::Enum => 3,
_ => 2,
};
dead_lines += lines;
}
}
let dead_code_percentage = if total_lines > 0 {
(dead_lines as f64 / total_lines as f64) * 100.0
} else {
0.0
};
Ok(AccurateDeadCodeReport {
files_with_dead_code: files,
total_dead_items,
dead_code_percentage,
total_lines,
dead_lines,
dead_by_type,
})
}
}
pub async fn analyze_dead_code(project_path: impl AsRef<Path>) -> Result<AccurateDeadCodeReport> {
let analyzer = CargoDeadCodeAnalyzer::new(project_path);
analyzer.analyze().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_function_message() {
let analyzer = CargoDeadCodeAnalyzer::new(".");
let (name, kind) = analyzer
.parse_message("function `unused_func` is never used")
.unwrap();
assert_eq!(name, "unused_func");
assert_eq!(kind, DeadCodeKind::Function);
}
#[test]
fn test_parse_struct_message() {
let analyzer = CargoDeadCodeAnalyzer::new(".");
let (name, kind) = analyzer
.parse_message("struct `UnusedStruct` is never constructed")
.unwrap();
assert_eq!(name, "UnusedStruct");
assert_eq!(kind, DeadCodeKind::Struct);
}
#[test]
fn test_parse_field_message() {
let analyzer = CargoDeadCodeAnalyzer::new(".");
let (name, kind) = analyzer
.parse_message("field `data` is never read")
.unwrap();
assert_eq!(name, "data");
assert_eq!(kind, DeadCodeKind::Field);
}
}
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}