use thiserror::Error;
#[derive(Error, Debug)]
pub enum FeatureAnalysisError {
#[error("Failed to run cargo metadata: {0}")]
MetadataCommand(#[from] cargo_metadata::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("UTF-8 error: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("cargo tree failed")]
CargoTreeFailed,
}
use crate::infra::{CommandExecutor, RealCommandExecutor};
use cargo_metadata::MetadataCommand;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;
pub struct FeatureAnalyzer<CE: CommandExecutor = RealCommandExecutor> {
project_root: std::path::PathBuf,
cmd_executor: CE,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnusedFeature {
pub package: String,
pub feature: String,
pub enabled_by: String,
pub estimated_impact_kb: u64,
pub confidence: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FeatureAnalysisResults {
pub total_features: usize,
pub unused_features: Vec<UnusedFeature>,
pub estimated_savings_kb: u64,
pub recommendations: Vec<String>,
}
impl FeatureAnalyzer<RealCommandExecutor> {
pub fn new(project_root: impl AsRef<Path>) -> Self {
Self::with_executor(project_root, RealCommandExecutor)
}
}
impl<CE: CommandExecutor> FeatureAnalyzer<CE> {
pub fn with_executor(project_root: impl AsRef<Path>, cmd_executor: CE) -> Self {
Self {
project_root: project_root.as_ref().to_path_buf(),
cmd_executor,
}
}
#[must_use = "Analysis results should be used or printed"]
pub fn analyze(&self) -> Result<FeatureAnalysisResults, FeatureAnalysisError> {
let metadata = MetadataCommand::new()
.current_dir(&self.project_root)
.exec()?;
let feature_tree = self.get_feature_tree()?;
let mut unused_features = Vec::new();
let mut total_features = 0;
for package in &metadata.packages {
if metadata.workspace_members.contains(&package.id) {
continue;
}
let enabled_features = feature_tree
.get(&package.name.to_string())
.cloned()
.unwrap_or_default();
total_features += enabled_features.len();
if enabled_features.contains("default") && !package.features.is_empty() {
let default_features = package.features.get("default").cloned().unwrap_or_default();
if !default_features.is_empty() {
unused_features.push(UnusedFeature {
package: package.name.to_string(),
feature: "default".to_string(),
enabled_by: "implicit".to_string(),
estimated_impact_kb: 50, confidence: "Medium".to_string(),
});
}
}
for feature in &enabled_features {
if self.is_commonly_unused_feature(&package.name, feature) {
unused_features.push(UnusedFeature {
package: package.name.to_string(),
feature: feature.clone(),
enabled_by: "explicit".to_string(),
estimated_impact_kb: self.estimate_feature_impact(&package.name, feature),
confidence: self.get_confidence_level(&package.name, feature),
});
}
}
}
let estimated_savings_kb = unused_features.iter().map(|f| f.estimated_impact_kb).sum();
let recommendations = self.generate_recommendations(&unused_features);
Ok(FeatureAnalysisResults {
total_features,
unused_features,
estimated_savings_kb,
recommendations,
})
}
fn get_feature_tree(&self) -> Result<HashMap<String, HashSet<String>>, FeatureAnalysisError> {
let output = self.cmd_executor.execute(
|cmd| {
cmd.arg("tree")
.arg("--format")
.arg("{p} {f}")
.arg("--edges")
.arg("normal")
.current_dir(&self.project_root)
},
"cargo",
)?;
if !output.status.success() {
return Err(FeatureAnalysisError::CargoTreeFailed);
}
let stdout = String::from_utf8(output.stdout)?;
let mut feature_map = HashMap::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let package_name = parts[0];
let features_parts: Vec<&str> = parts.iter().skip(1).copied().collect();
let features_str = features_parts.join(" ");
let features: HashSet<String> = features_str
.split(',')
.filter(|s| !s.is_empty() && !s.starts_with('v'))
.map(|s| String::from(s.trim()))
.collect();
if !features.is_empty() {
feature_map
.entry(String::from(package_name))
.or_insert_with(HashSet::new)
.extend(features);
}
}
}
Ok(feature_map)
}
fn is_commonly_unused_feature(&self, package: &str, feature: &str) -> bool {
match (package, feature) {
("serde", "rc") => true,
("serde", "unstable") => true,
("tokio", "fs") => true, ("tokio", "io-std") => true,
("tokio", "process") => true,
("tokio", "signal") => true,
("regex", "unicode") => true, ("regex", "unicode-perl") => true,
("chrono", "clock") => true, ("chrono", "std") => false,
(_, "std") if feature == "std" => false, (_, f) if f.contains("test") || f.contains("bench") => true,
(_, f) if f.contains("unstable") || f.contains("nightly") => true,
_ => false,
}
}
fn estimate_feature_impact(&self, package: &str, feature: &str) -> u64 {
match (package, feature) {
("regex", "unicode") => 300, ("tokio", _) => 100,
("serde", _) => 20,
("chrono", _) => 50,
(_, "default") => 50,
_ => 30, }
}
fn get_confidence_level(&self, package: &str, feature: &str) -> String {
match (package, feature) {
("regex", "unicode") => "High".to_string(),
("tokio", "fs") | ("tokio", "process") => "High".to_string(),
(_, "default") => "Medium".to_string(),
(_, f) if f.contains("test") => "High".to_string(),
_ => "Low".to_string(),
}
}
fn generate_recommendations(&self, unused_features: &[UnusedFeature]) -> Vec<String> {
let mut recommendations = Vec::new();
if unused_features.is_empty() {
recommendations.push("✅ No obvious unused features detected".to_string());
return recommendations;
}
let mut by_package: HashMap<String, Vec<&UnusedFeature>> = HashMap::new();
for feature in unused_features {
by_package
.entry(feature.package.clone())
.or_default()
.push(feature);
}
for (package, features) in by_package {
if features.iter().any(|f| f.feature == "default") {
recommendations.push(format!(
"Add 'default-features = false' to '{}' in Cargo.toml",
package
));
}
let explicit_features: Vec<_> =
features.iter().filter(|f| f.feature != "default").collect();
if !explicit_features.is_empty() {
let feature_names: Vec<_> = explicit_features
.iter()
.map(|f| f.feature.as_str())
.collect();
recommendations.push(format!(
"Consider removing features {:?} from '{}' if not used",
feature_names, package
));
}
}
recommendations
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_is_commonly_unused_feature_identifies_known_features() {
let analyzer = FeatureAnalyzer::new(".");
assert!(analyzer.is_commonly_unused_feature("regex", "unicode"));
assert!(analyzer.is_commonly_unused_feature("tokio", "fs"));
assert!(!analyzer.is_commonly_unused_feature("serde", "derive"));
}
#[test]
fn test_estimate_feature_impact_returns_size_estimate() {
let analyzer = FeatureAnalyzer::new(".");
let impact = analyzer.estimate_feature_impact("regex", "unicode");
assert!(impact > 100);
let default_impact = analyzer.estimate_feature_impact("unknown", "default");
assert!(default_impact > 0);
}
#[test]
fn test_get_confidence_level_returns_correct_level() {
let analyzer = FeatureAnalyzer::new(".");
assert_eq!(analyzer.get_confidence_level("regex", "unicode"), "High");
assert_eq!(
analyzer.get_confidence_level("unknown", "default"),
"Medium"
);
}
#[test]
fn test_generate_recommendations_creates_actionable_suggestions() {
let analyzer = FeatureAnalyzer::new(".");
let unused = vec![UnusedFeature {
package: "serde".to_string(),
feature: "default".to_string(),
enabled_by: "implicit".to_string(),
estimated_impact_kb: 50,
confidence: "Medium".to_string(),
}];
let recs = analyzer.generate_recommendations(&unused);
assert!(!recs.is_empty());
assert!(recs[0].contains("default-features = false"));
}
#[test]
fn test_unused_feature_creation() {
let feature = UnusedFeature {
package: "test-pkg".to_string(),
feature: "test-feature".to_string(),
enabled_by: "default".to_string(),
estimated_impact_kb: 100,
confidence: "High".to_string(),
};
assert_eq!(feature.package, "test-pkg");
assert_eq!(feature.feature, "test-feature");
assert_eq!(feature.estimated_impact_kb, 100);
}
#[test]
fn test_feature_analysis_results_creation() {
let results = FeatureAnalysisResults {
total_features: 10,
unused_features: vec![],
estimated_savings_kb: 0,
recommendations: vec!["test".to_string()],
};
assert_eq!(results.total_features, 10);
assert_eq!(results.unused_features.len(), 0);
assert_eq!(results.recommendations.len(), 1);
}
#[test]
fn test_is_commonly_unused_feature_with_various_packages() {
let analyzer = FeatureAnalyzer::new(".");
let _result1 = analyzer.is_commonly_unused_feature("tokio", "fs");
let _result2 = analyzer.is_commonly_unused_feature("regex", "unicode");
let _result3 = analyzer.is_commonly_unused_feature("unknown", "feature");
}
#[test]
fn test_estimate_feature_impact_for_known_packages() {
let analyzer = FeatureAnalyzer::new(".");
let unicode_impact = analyzer.estimate_feature_impact("regex", "unicode");
let small_impact = analyzer.estimate_feature_impact("unknown", "unknown");
assert!(unicode_impact > small_impact);
}
#[test]
fn test_get_confidence_level_for_different_packages() {
let analyzer = FeatureAnalyzer::new(".");
let level1 = analyzer.get_confidence_level("regex", "unicode");
let level2 = analyzer.get_confidence_level("tokio", "fs");
let level3 = analyzer.get_confidence_level("unknown", "unknown");
assert!(["High", "Medium", "Low"].contains(&level1.as_str()));
assert!(["High", "Medium", "Low"].contains(&level2.as_str()));
assert!(["High", "Medium", "Low"].contains(&level3.as_str()));
}
#[test]
fn test_generate_recommendations_with_multiple_unused_features() {
let analyzer = FeatureAnalyzer::new(".");
let unused = vec![
UnusedFeature {
package: "tokio".to_string(),
feature: "fs".to_string(),
enabled_by: "default".to_string(),
estimated_impact_kb: 100,
confidence: "High".to_string(),
},
UnusedFeature {
package: "regex".to_string(),
feature: "unicode".to_string(),
enabled_by: "default".to_string(),
estimated_impact_kb: 200,
confidence: "High".to_string(),
},
];
let recs = analyzer.generate_recommendations(&unused);
assert!(!recs.is_empty());
}
#[test]
fn test_generate_recommendations_with_empty_list() {
let analyzer = FeatureAnalyzer::new(".");
let recs = analyzer.generate_recommendations(&[]);
assert!(!recs.is_empty());
}
#[test]
fn test_feature_analyzer_new_with_different_paths() {
let analyzer1 = FeatureAnalyzer::new(".");
let analyzer2 = FeatureAnalyzer::new("/tmp");
let analyzer3 = FeatureAnalyzer::new("relative/path");
assert_eq!(analyzer1.project_root, PathBuf::from("."));
assert_eq!(analyzer2.project_root, PathBuf::from("/tmp"));
assert_eq!(analyzer3.project_root, PathBuf::from("relative/path"));
}
}