cargo-perspective 0.1.0

A utility that graphically shows the crate dependency distribution within a project.
Documentation
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 {
    /// Skip the build step
    #[arg(long)]
    skip_build: bool,

    /// Launch the GUI (default mode)
    #[arg(long, default_value = "false")]
    gui: bool,

    /// Show text-based report (legacy mode from deppy.sh)
    #[arg(long, default_value = "false")]
    text: bool,
}

fn main() -> Result<()> {
    let args = Args::parse();

    // Initialize tracing
    tracing_subscriber::fmt::init();

    // Determine mode
    let mode = if args.gui {
        Mode::Gui
    } else if args.text {
        Mode::Text
    } else {
        // Default: GUI if possible, otherwise text
        Mode::Gui
    };

    // Optional build
    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 ==");

    // Tree-based counts
    print_tree_based_counts()?;

    // Metadata-based counts
    let metadata = MetadataCommand::new()
        .exec()
        .context("Failed to execute cargo metadata")?;

    print_metadata_based_counts(&metadata)?;

    // Crate usage distribution
    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) ===");

    // Total entries in 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();

    // Unique lines in tree
    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();

    // Find root package
    let root_pkg = metadata
        .packages
        .iter()
        .find(|p| &p.id == root_id)
        .context("Root package not found")?;

    // Find root node
    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")?;

    // Get direct dependencies
    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(());
    }

    // Count usages
    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")?;

    // Get transitive dependencies
    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()
    );

    // Count usages
    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")?;

    // Get all dependency IDs
    let all_dep_ids: HashSet<&PackageId> = resolve
        .nodes
        .iter()
        .filter(|n| &n.id != root_id)
        .map(|n| &n.id)
        .collect();

    // Get direct dependency IDs
    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();

    // Transitive = all - direct
    let transitive_ids: HashSet<&PackageId> = all_dep_ids
        .difference(&direct_dep_ids)
        .copied()
        .collect();

    // Map IDs to names
    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 {
        // Pattern: [^A-Za-z0-9_]crate_name::
        let pattern = format!(r"[^A-Za-z0-9_]{}::", regex::escape(crate_name));
        let re = Regex::new(&pattern)?;

        let mut count = 0;

        // Walk through src/ directory
        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) {
    // Sort by count descending
    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
        );
    }
}