use anyhow::{Context, Result};
use cargo_metadata::{MetadataCommand, PackageId};
use cargo_perspective::analysis::DependencyAnalyzer;
use cargo_perspective::ui;
use clap::Parser;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::Command;
use walkdir::WalkDir;
#[derive(Parser, Debug)]
#[command(name = "cargo-perspective")]
#[command(about = "Analyze Rust project dependencies and their usage", long_about = None)]
struct Args {
#[arg(long)]
skip_build: bool,
#[arg(long, default_value = "false")]
gui: bool,
#[arg(long, default_value = "false")]
text: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
tracing_subscriber::fmt::init();
let mode = if args.gui {
Mode::Gui
} else if args.text {
Mode::Text
} else {
Mode::Gui
};
if !args.skip_build {
println!("Building project (debug)...");
let status = Command::new("cargo")
.arg("build")
.status()
.context("Failed to run cargo build")?;
if !status.success() {
anyhow::bail!("Build failed");
}
}
match mode {
Mode::Gui => run_gui_mode()?,
Mode::Text => run_text_mode()?,
}
Ok(())
}
enum Mode {
Gui,
Text,
}
fn run_gui_mode() -> Result<()> {
println!("Analyzing project dependencies...");
let analyzer = DependencyAnalyzer::new()?;
let analysis = analyzer.analyze()?;
println!("Found {} crates with {} total calls", analysis.crates.len(), analysis.total_calls);
println!("Launching GUI...");
ui::launch(analysis);
Ok(())
}
fn run_text_mode() -> Result<()> {
println!("== Crate summary for current project ==");
print_tree_based_counts()?;
let metadata = MetadataCommand::new()
.exec()
.context("Failed to execute cargo metadata")?;
print_metadata_based_counts(&metadata)?;
if Path::new("src").exists() {
print_crate_usage_distribution(&metadata)?;
print_transitive_usage_distribution(&metadata)?;
} else {
println!("\nNo src/ directory found, skipping usage analysis.");
}
Ok(())
}
fn print_tree_based_counts() -> Result<()> {
println!("\n=== Tree-based counts (cargo tree) ===");
let tree_output = Command::new("cargo")
.args(["tree", "--edges", "normal", "--prefix", "none"])
.output()
.context("Failed to run cargo tree")?;
let tree_str = String::from_utf8_lossy(&tree_output.stdout);
let total_tree = tree_str.lines().filter(|l| !l.trim().is_empty()).count();
let unique_lines: HashSet<_> = tree_str
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
let unique_tree = unique_lines.len();
println!("Total entries in tree : {}", total_tree);
println!("Unique lines in tree : {}", unique_tree);
Ok(())
}
fn print_metadata_based_counts(metadata: &cargo_metadata::Metadata) -> Result<()> {
println!("\n=== Metadata-based counts (cargo metadata) ===");
let resolve = metadata
.resolve
.as_ref()
.context("No resolve information in metadata")?;
let root_id = resolve
.root
.as_ref()
.context("No root package in resolve")?;
let total_crates = resolve.nodes.len();
let root_pkg = metadata
.packages
.iter()
.find(|p| &p.id == root_id)
.context("Root package not found")?;
let root_node = resolve
.nodes
.iter()
.find(|n| &n.id == root_id)
.context("Root node not found")?;
let direct_deps = root_node.dependencies.len();
let transitive_deps = total_crates.saturating_sub(1).saturating_sub(direct_deps);
println!("Root crate : {}", root_pkg.name);
println!("Total crates in graph : {} (including root)", total_crates);
println!("Direct dependencies : {}", direct_deps);
println!("Transitive deps : {}", transitive_deps);
println!("\nSummary:");
println!(" Unique crates total : {}", total_crates);
println!(" Direct / transitive : {} / {}", direct_deps, transitive_deps);
Ok(())
}
fn print_crate_usage_distribution(metadata: &cargo_metadata::Metadata) -> Result<()> {
println!("\n=== Crate usage distribution in src/ ===");
let resolve = metadata
.resolve
.as_ref()
.context("No resolve information in metadata")?;
let root_id = resolve
.root
.as_ref()
.context("No root package in resolve")?;
let direct_dep_names = get_direct_dependency_names(metadata, root_id)?;
if direct_dep_names.is_empty() {
println!("No direct dependencies found in metadata; skipping usage analysis.");
return Ok(());
}
let usage_counts = count_crate_usages(&direct_dep_names)?;
let total_usage: usize = usage_counts.values().sum();
if total_usage == 0 {
println!("No direct crate usages of the form 'crate_name::' found in src/.");
println!("This can happen if crates are mostly used via derives/macros or re-exports.");
return Ok(());
}
print_usage_table(&usage_counts, total_usage);
Ok(())
}
fn print_transitive_usage_distribution(metadata: &cargo_metadata::Metadata) -> Result<()> {
println!("\n=== Transitive crate usage in src/ ===");
let resolve = metadata
.resolve
.as_ref()
.context("No resolve information in metadata")?;
let root_id = resolve
.root
.as_ref()
.context("No root package in resolve")?;
let transitive_dep_names = get_transitive_dependency_names(metadata, root_id)?;
if transitive_dep_names.is_empty() {
println!("No transitive dependencies found.");
return Ok(());
}
println!(
"Scanning for usage of {} transitive dependencies...",
transitive_dep_names.len()
);
let usage_counts = count_crate_usages(&transitive_dep_names)?;
if usage_counts.is_empty() {
println!("No transitive crate usages of the form 'crate_name::' found in src/.");
println!("All crate usage is through direct dependencies or re-exports.");
return Ok(());
}
let total_usage: usize = usage_counts.values().sum();
println!("\nFound {} usages of transitive dependencies!", total_usage);
println!("Consider adding these as direct dependencies if heavily used:");
println!();
print_usage_table(&usage_counts, total_usage);
Ok(())
}
fn get_direct_dependency_names(
metadata: &cargo_metadata::Metadata,
root_id: &PackageId,
) -> Result<Vec<String>> {
let root_pkg = metadata
.packages
.iter()
.find(|p| &p.id == root_id)
.context("Root package not found")?;
let mut names: Vec<String> = root_pkg
.dependencies
.iter()
.map(|d| d.name.clone())
.collect();
names.sort();
names.dedup();
Ok(names)
}
fn get_transitive_dependency_names(
metadata: &cargo_metadata::Metadata,
root_id: &PackageId,
) -> Result<Vec<String>> {
let resolve = metadata
.resolve
.as_ref()
.context("No resolve information")?;
let all_dep_ids: HashSet<&PackageId> = resolve
.nodes
.iter()
.filter(|n| &n.id != root_id)
.map(|n| &n.id)
.collect();
let root_node = resolve
.nodes
.iter()
.find(|n| &n.id == root_id)
.context("Root node not found")?;
let direct_dep_ids: HashSet<&PackageId> =
root_node.dependencies.iter().collect();
let transitive_ids: HashSet<&PackageId> = all_dep_ids
.difference(&direct_dep_ids)
.copied()
.collect();
let mut names: Vec<String> = metadata
.packages
.iter()
.filter(|p| transitive_ids.contains(&p.id))
.map(|p| p.name.clone())
.collect();
names.sort();
names.dedup();
Ok(names)
}
fn count_crate_usages(crate_names: &[String]) -> Result<HashMap<String, usize>> {
let mut usage_counts = HashMap::new();
for crate_name in crate_names {
let pattern = format!(r"[^A-Za-z0-9_]{}::", regex::escape(crate_name));
let re = Regex::new(&pattern)?;
let mut count = 0;
for entry in WalkDir::new("src")
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
{
let content = std::fs::read_to_string(entry.path())?;
count += re.find_iter(&content).count();
}
if count > 0 {
usage_counts.insert(crate_name.clone(), count);
}
}
Ok(usage_counts)
}
fn print_usage_table(usage_counts: &HashMap<String, usize>, total_usage: usize) {
let mut sorted: Vec<_> = usage_counts.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("{:<20} {:>10} {:>10} {:>12}", "Crate", "Calls", "Percent", "Category");
println!("{:<20} {:>10} {:>10} {:>12}", "-----", "-----", "-------", "--------");
for (crate_name, calls) in sorted {
let pct = (*calls as f64 * 100.0) / total_usage as f64;
let category = if pct >= 20.0 {
"Heavy"
} else if pct >= 5.0 {
"Medium"
} else {
"Light"
};
println!(
"{:<20} {:>10} {:>9.2}% {:>12}",
crate_name, calls, pct, category
);
}
}