repotoire 0.3.47

Graph-powered code analysis CLI. 81 detectors for security, architecture, and code quality.
//! Unused imports detector - fast graph-based alternative to pylint.
//!
//! Detects imports that are never referenced in the codebase, indicating
//! dead code that should be cleaned up.

use std::collections::HashSet;

use crate::detectors::base::{Detector, DetectorConfig, DetectorResult};
use crate::graph::GraphClient;
use crate::models::{Finding, Severity};

/// Unused imports detector
///
/// An unused import is one that appears in an IMPORTS relationship
/// but is never referenced via:
/// - Function calls (CALLS)
/// - Class inheritance (INHERITS)
/// - Attribute access
/// - Type annotations
pub struct UnusedImportsDetector {
    config: DetectorConfig,
    /// Patterns to ignore
    ignore_patterns: Vec<String>,
    /// Maximum findings to report
    max_findings: usize,
}

impl UnusedImportsDetector {
    /// Create a new unused imports detector
    pub fn new() -> Self {
        Self {
            config: DetectorConfig::default(),
            ignore_patterns: vec![
                "__future__".to_string(),
                "typing".to_string(),
                "typing_extensions".to_string(),
                "__init__".to_string(),
                "annotations".to_string(),
            ],
            max_findings: 100,
        }
    }

    /// Add patterns to ignore
    pub fn with_ignore_patterns(mut self, patterns: Vec<String>) -> Self {
        self.ignore_patterns.extend(patterns);
        self
    }

    /// Set max findings
    pub fn with_max_findings(mut self, max: usize) -> Self {
        self.max_findings = max;
        self
    }

    /// Check if import should be ignored
    fn should_ignore(&self, name: &str) -> bool {
        if name.is_empty() {
            return true;
        }

        let name_lower = name.to_lowercase();
        self.ignore_patterns
            .iter()
            .any(|p| name_lower.contains(&p.to_lowercase()))
    }

    /// Check if an imported module is referenced
    fn is_referenced(imported: &str, referenced: &HashSet<String>) -> bool {
        // Exact match
        if referenced.contains(imported) {
            return true;
        }

        // Check if any referenced entity starts with this import
        // (e.g., import foo, use foo.bar)
        let prefix = format!("{}.", imported);
        for r in referenced {
            if r.starts_with(&prefix) {
                return true;
            }
        }

        // Check if this import is a sub-path of something referenced
        let parts: Vec<&str> = imported.split('.').collect();
        for i in 1..parts.len() {
            let partial = parts[..i].join(".");
            if referenced.contains(&partial) {
                return true;
            }
        }

        false
    }

    /// Detect unused imports via graph queries
    fn detect_from_graph(&self, graph: &GraphClient) -> anyhow::Result<Vec<Finding>> {
        // Get all imports
        let imports_query = r#"
            MATCH (importer)-[:IMPORTS]->(imported)
            WHERE importer.qualifiedName IS NOT NULL
              AND imported.qualifiedName IS NOT NULL
            RETURN
                imported.qualifiedName AS imported_name,
                importer.qualifiedName AS importer_name,
                importer.filePath AS file_path
        "#;

        // Get all referenced entities (called or inherited)
        let refs_query = r#"
            MATCH ()-[r:CALLS|INHERITS]->(target)
            WHERE target.qualifiedName IS NOT NULL
            RETURN DISTINCT target.qualifiedName AS ref_name
        "#;

        let imports_results = graph.execute(imports_query)?;
        let refs_results = graph.execute(refs_query)?;

        // Build set of referenced names
        let mut referenced: HashSet<String> = HashSet::new();
        for row in refs_results {
            if let Some(ref_name) = row.get_string("ref_name") {
                referenced.insert(ref_name);
            }
        }

        // Find unused imports
        let mut findings = Vec::new();

        for row in imports_results {
            let imported_name = row.get_string("imported_name").unwrap_or_default();

            if self.should_ignore(&imported_name) {
                continue;
            }

            if !Self::is_referenced(&imported_name, &referenced) {
                if findings.len() >= self.max_findings {
                    break;
                }

                let finding = self.create_finding(
                    &imported_name,
                    &row.get_string("importer_name").unwrap_or_default(),
                    &row.get_string("file_path").unwrap_or_default(),
                );
                findings.push(finding);
            }
        }

        // Sort by file path for consistent output
        findings.sort_by(|a, b| {
            let file_a = a.affected_files.first().map(|s| s.as_str()).unwrap_or("");
            let file_b = b.affected_files.first().map(|s| s.as_str()).unwrap_or("");
            file_a.cmp(file_b).then_with(|| a.title.cmp(&b.title))
        });

        Ok(findings)
    }

    fn create_finding(
        &self,
        imported_name: &str,
        importer_file: &str,
        file_path: &str,
    ) -> Finding {
        let simple_name = imported_name.split('.').last().unwrap_or(imported_name);

        let description = format!(
            "Import '{}' is not used in '{}'. \
             Unused imports add clutter and can slow down module loading.",
            imported_name, file_path
        );

        let recommendation = format!(
            "Remove the unused import:\n\
             - Delete the import statement for '{}'\n\
             - Or if it's needed for type checking, use:\n\
             if TYPE_CHECKING:\n\
                 from ... import {}",
            simple_name, simple_name
        );

        Finding {
            id: format!(
                "unused_import_{}_{}",
                importer_file.replace('.', "_"),
                imported_name.replace('.', "_")
            ),
            detector: "UnusedImportsDetector".to_string(),
            severity: Severity::Low,
            title: format!("Unused import: {}", simple_name),
            description,
            affected_nodes: vec![imported_name.to_string()],
            affected_files: if file_path.is_empty() || file_path == "unknown" {
                vec![]
            } else {
                vec![file_path.to_string()]
            },
            line_start: None,
            line_end: None,
            suggested_fix: Some(recommendation),
            estimated_effort: Some("Trivial (1-5 minutes)".to_string()),
            confidence: 0.85,
            tags: vec![
                "unused_import".to_string(),
                "dead_code".to_string(),
                "cleanup".to_string(),
            ],
            metadata: serde_json::json!({
                "imported_name": imported_name,
                "importer": importer_file,
            }),
        }
    }
}

impl Default for UnusedImportsDetector {
    fn default() -> Self {
        Self::new()
    }
}

impl Detector for UnusedImportsDetector {
    fn name(&self) -> &'static str {
        "UnusedImportsDetector"
    }

    fn description(&self) -> &'static str {
        "Detects imports that are never used in the codebase"
    }

    fn detect(&self, graph: &GraphClient) -> DetectorResult {
        self.detect_from_graph(graph)
    }

    fn is_dependent(&self) -> bool {
        false
    }
}

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

    #[test]
    fn test_should_ignore() {
        let detector = UnusedImportsDetector::new();
        assert!(detector.should_ignore("__future__"));
        assert!(detector.should_ignore("typing.Optional"));
        assert!(!detector.should_ignore("os.path"));
    }

    #[test]
    fn test_is_referenced() {
        let mut referenced = HashSet::new();
        referenced.insert("foo.bar".to_string());
        referenced.insert("baz".to_string());

        assert!(UnusedImportsDetector::is_referenced("foo.bar", &referenced));
        assert!(UnusedImportsDetector::is_referenced("foo", &referenced));
        assert!(UnusedImportsDetector::is_referenced("baz", &referenced));
        assert!(!UnusedImportsDetector::is_referenced("qux", &referenced));
    }
}