cargo-whys 0.1.0

A cargo subcommand that explains why dependencies are in your tree
Documentation
use anyhow::{anyhow, Context, Result};
use cargo_metadata::MetadataCommand;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

use crate::dependency_graph::DependencyGraph;
use crate::scanner::{CodeScanner, UsageLocation};

#[derive(Debug, Serialize, Deserialize)]
pub struct AnalysisResult {
    pub dependency_name: String,
    pub found: bool,
    pub versions: Vec<VersionInfo>,
    pub total_usage_count: usize,
    pub usage_locations: Option<Vec<UsageLocation>>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct VersionInfo {
    pub version: String,
    pub used_by: Vec<DependentInfo>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct DependentInfo {
    pub name: String,
    pub version: String,
    pub is_workspace_member: bool,
}

impl AnalysisResult {
    pub fn print(&self) {
        if !self.found {
            println!(
                "{}",
                format!(
                    "Dependency '{}' not found in the dependency tree",
                    self.dependency_name
                )
                .red()
            );
            return;
        }

        println!(
            "\n{} {}",
            "Analyzing dependency:".bold(),
            self.dependency_name.cyan().bold()
        );

        for version_info in &self.versions {
            println!(
                "\n  {} {}",
                "Version:".bold(),
                version_info.version.yellow()
            );

            if version_info.used_by.is_empty() {
                println!("    {}", "No direct dependents found".dimmed());
            } else {
                println!("    {}:", "Used by".bold());
                for dep in &version_info.used_by {
                    let marker = if dep.is_workspace_member {
                        "[workspace]".green()
                    } else {
                        "[external]".blue()
                    };
                    println!(
                        "      {} {} {} {}",
                        "".cyan(),
                        dep.name.white(),
                        dep.version.dimmed(),
                        marker
                    );
                }
            }
        }

        if self.total_usage_count > 0 {
            println!(
                "\n  {} {} {}",
                "Total imports found:".bold(),
                self.total_usage_count.to_string().green().bold(),
                "in codebase".dimmed()
            );
        }

        if let Some(locations) = &self.usage_locations {
            println!("\n  {}:", "Usage locations".bold());

            let mut sorted_locations = locations.clone();
            sorted_locations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));

            for loc in sorted_locations {
                println!(
                    "    {} {}:{}",
                    "".cyan(),
                    loc.file.white(),
                    loc.line.to_string().yellow()
                );
                println!("      {}", loc.content.dimmed());
            }
        } else if self.total_usage_count > 0 {
            println!(
                "\n  {} Run with {} to see where it's used",
                "Tip:".bold().yellow(),
                "--where-used".cyan()
            );
        }

        println!();
    }
}

pub struct DependencyAnalyzer {
    graph: DependencyGraph,
    project_path: PathBuf,
}

impl DependencyAnalyzer {
    pub fn new(manifest_path: PathBuf) -> Result<Self> {
        let metadata = MetadataCommand::new()
            .manifest_path(manifest_path.join("Cargo.toml"))
            .exec()
            .context("Failed to execute cargo metadata")?;

        let graph =
            DependencyGraph::from_metadata(metadata).context("Failed to build dependency graph")?;

        Ok(Self {
            graph,
            project_path: manifest_path,
        })
    }

    pub fn analyze(
        &self,
        dependency_name: &str,
        scan_usage: bool,
        all_versions: bool,
    ) -> Result<AnalysisResult> {
        let packages = self.graph.find_packages_by_name(dependency_name);

        if packages.is_empty() {
            return Ok(AnalysisResult {
                dependency_name: dependency_name.to_string(),
                found: false,
                versions: Vec::new(),
                total_usage_count: 0,
                usage_locations: None,
            });
        }

        let packages_to_analyze = if all_versions {
            packages
        } else {
            vec![*packages
                .first()
                .ok_or_else(|| anyhow!("No packages found for dependency '{}'", dependency_name))?]
        };

        let mut versions = Vec::new();

        for package in packages_to_analyze {
            let dependents = self.graph.find_reverse_dependencies(&package.id);

            let mut dependent_infos: Vec<DependentInfo> = dependents
                .into_iter()
                .map(|dep| DependentInfo {
                    name: dep.name.clone(),
                    version: dep.version.to_string(),
                    is_workspace_member: self.graph.is_workspace_member(&dep.id),
                })
                .collect();

            dependent_infos.sort_by(|a, b| {
                b.is_workspace_member
                    .cmp(&a.is_workspace_member)
                    .then(a.name.cmp(&b.name))
            });

            versions.push(VersionInfo {
                version: package.version.to_string(),
                used_by: dependent_infos,
            });
        }

        let (total_usage_count, usage_locations) = if scan_usage {
            let locations = CodeScanner::scan_for_usage(&self.project_path, dependency_name)
                .context("Failed to scan codebase for usage")?;
            let count = locations.len();
            (count, Some(locations))
        } else {
            let locations = CodeScanner::scan_for_usage(&self.project_path, dependency_name)
                .unwrap_or_default();
            (locations.len(), None)
        };

        Ok(AnalysisResult {
            dependency_name: dependency_name.to_string(),
            found: true,
            versions,
            total_usage_count,
            usage_locations,
        })
    }
}