cargo-perspective 0.1.0

A utility that graphically shows the crate dependency distribution within a project.
Documentation
//! AST-based analysis to detect and count dependency method calls.

use crate::types::{CallSite, ProjectAnalysis};
use anyhow::{Context, Result};
use cargo_metadata::{MetadataCommand, PackageId};
use std::collections::HashSet;
use std::path::Path;
use syn::visit::{self, Visit};
use walkdir::WalkDir;

/// Analyzes a Rust project and counts dependency method calls.
pub struct DependencyAnalyzer {
    /// Cargo metadata
    metadata: cargo_metadata::Metadata,
    /// Set of direct dependency names
    direct_deps: HashSet<String>,
    /// Set of all dependency names (direct + transitive)
    all_deps: HashSet<String>,
}

impl DependencyAnalyzer {
    /// Create a new analyzer for the current project.
    pub fn new() -> Result<Self> {
        let metadata = MetadataCommand::new()
            .exec()
            .context("Failed to execute 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")?;

        // Get direct dependencies
        let direct_deps = Self::get_direct_dependency_names(&metadata, root_id)?;

        // Get all dependencies
        let all_deps = Self::get_all_dependency_names(&metadata, root_id)?;

        Ok(Self {
            metadata,
            direct_deps,
            all_deps,
        })
    }

    /// Analyze the project and return call statistics.
    pub fn analyze(&self) -> Result<ProjectAnalysis> {
        let resolve = self
            .metadata
            .resolve
            .as_ref()
            .context("No resolve information")?;

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

        let mut analysis = ProjectAnalysis::new(root_pkg.name.clone());

        // Scan all .rs files in src/
        if !Path::new("src").exists() {
            return Ok(analysis);
        }

        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 file_path = entry.path();
            self.analyze_file(file_path, &mut analysis)?;
        }

        analysis.recalculate_totals();
        Ok(analysis)
    }

    /// Analyze a single Rust file for dependency calls.
    fn analyze_file(&self, file_path: &Path, analysis: &mut ProjectAnalysis) -> Result<()> {
        let content = std::fs::read_to_string(file_path)
            .with_context(|| format!("Failed to read file: {:?}", file_path))?;

        let syntax_tree = syn::parse_file(&content).with_context(|| {
            format!("Failed to parse Rust file: {:?}", file_path)
        })?;

        let mut visitor = CallVisitor {
            file_path: file_path.to_string_lossy().to_string(),
            direct_deps: &self.direct_deps,
            all_deps: &self.all_deps,
            analysis,
        };

        visitor.visit_file(&syntax_tree);
        Ok(())
    }

    fn get_direct_dependency_names(
        metadata: &cargo_metadata::Metadata,
        root_id: &PackageId,
    ) -> Result<HashSet<String>> {
        let root_pkg = metadata
            .packages
            .iter()
            .find(|p| &p.id == root_id)
            .context("Root package not found")?;

        Ok(root_pkg
            .dependencies
            .iter()
            .map(|d| d.name.clone())
            .collect())
    }

    fn get_all_dependency_names(
        metadata: &cargo_metadata::Metadata,
        root_id: &PackageId,
    ) -> Result<HashSet<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();

        Ok(metadata
            .packages
            .iter()
            .filter(|p| all_dep_ids.contains(&p.id))
            .map(|p| p.name.clone())
            .collect())
    }
}

/// AST visitor to find and count method calls.
struct CallVisitor<'a> {
    file_path: String,
    direct_deps: &'a HashSet<String>,
    all_deps: &'a HashSet<String>,
    analysis: &'a mut ProjectAnalysis,
}

impl<'a> CallVisitor<'a> {
    /// Check if a path segment represents a dependency call.
    fn extract_crate_and_method(&self, path: &syn::Path) -> Option<(String, String)> {
        if path.segments.is_empty() {
            return None;
        }

        // First segment is usually the crate name
        let first_segment = path.segments.first()?.ident.to_string();

        // Check if this is a known dependency
        if !self.all_deps.contains(&first_segment) {
            return None;
        }

        // Build the full path
        let full_path: Vec<String> = path
            .segments
            .iter()
            .map(|seg| seg.ident.to_string())
            .collect();

        Some((first_segment, full_path.join("::")))
    }

    fn record_call(&mut self, crate_name: String, method_path: String, line: usize, column: usize) {
        let is_direct = self.direct_deps.contains(&crate_name);
        let crate_usage = self.analysis.get_or_create_crate(crate_name, is_direct);

        let call_site = CallSite {
            file_path: self.file_path.clone(),
            line,
            column,
        };

        crate_usage.add_call(method_path, call_site);
    }
}

impl<'a> Visit<'a> for CallVisitor<'a> {
    fn visit_expr_call(&mut self, node: &'a syn::ExprCall) {
        // Handle function calls like tokio::spawn(...)
        if let syn::Expr::Path(expr_path) = &*node.func {
            if let Some((crate_name, method_path)) = self.extract_crate_and_method(&expr_path.path)
            {
                // Note: Extracting precise line/column from syn requires additional work
                // For MVP, we'll use 0,0 as placeholder
                self.record_call(crate_name, method_path, 0, 0);
            }
        }

        // Continue visiting child nodes
        visit::visit_expr_call(self, node);
    }

    fn visit_expr_method_call(&mut self, node: &'a syn::ExprMethodCall) {
        // Handle method calls - try to infer the type
        // This is a simplified heuristic; proper type inference would require running rustc
        let method_name = node.method.to_string();

        // Try to extract crate from receiver if it's a path
        if let syn::Expr::Path(expr_path) = &*node.receiver {
            if let Some((crate_name, base_path)) = self.extract_crate_and_method(&expr_path.path)
            {
                let full_method_path = format!("{}::{}", base_path, method_name);
                self.record_call(crate_name, full_method_path, 0, 0);
            }
        }

        // Continue visiting child nodes
        visit::visit_expr_method_call(self, node);
    }

    fn visit_macro(&mut self, node: &'a syn::Macro) {
        // Handle macro calls like println!, tokio::select!, etc.
        if let Some((crate_name, method_path)) = self.extract_crate_and_method(&node.path) {
            self.record_call(crate_name, method_path, 0, 0);
        }

        // Continue visiting
        visit::visit_macro(self, node);
    }

    fn visit_use_tree(&mut self, node: &'a syn::UseTree) {
        // Track use statements for better analysis (future enhancement)
        visit::visit_use_tree(self, node);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_analyzer_creation() {
        // This test will only pass in a cargo project
        // In a real test suite, we'd use fixtures
        let result = DependencyAnalyzer::new();
        assert!(result.is_ok() || result.is_err()); // Just check it doesn't panic
    }
}