scryer-engine 0.2.1

Tree-sitter and stack-graphs AST indexing engine for Scryer code intelligence
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use async_trait::async_trait;

use super::provider::{DependencyProvider, DependencySourceType, DiscoveredDependency};

/// How long `cargo metadata` may run before discovery gives up on it.
const CARGO_METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);

/// Run `cargo metadata` without touching the network, with a timeout.
///
/// `--offline --locked` is tried first so the user's `Cargo.lock` is never rewritten; if the
/// lockfile is missing or stale it retries with `--offline` alone. The child is killed if it
/// outlives the timeout (discovery runs under the project's index guard).
async fn run_cargo_metadata(manifest_path: &Path) -> Result<cargo_metadata::Metadata> {
    let mut last_err = None;
    for flags in [&["--offline", "--locked"][..], &["--offline"][..]] {
        let mut cmd = cargo_metadata::MetadataCommand::new();
        cmd.manifest_path(manifest_path)
            .other_options(flags.iter().map(|f| f.to_string()).collect::<Vec<_>>());
        let mut cmd = tokio::process::Command::from(cmd.cargo_command());
        cmd.kill_on_drop(true)
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped());
        let child = cmd.spawn().context("Failed to spawn cargo")?;
        let output = tokio::time::timeout(CARGO_METADATA_TIMEOUT, child.wait_with_output())
            .await
            .map_err(|_| {
                anyhow::anyhow!(
                    "cargo metadata timed out after {}s",
                    CARGO_METADATA_TIMEOUT.as_secs()
                )
            })??;
        if output.status.success() {
            let stdout = String::from_utf8(output.stdout)?;
            let json = stdout
                .lines()
                .find(|line| line.starts_with('{'))
                .context("cargo metadata produced no JSON")?;
            return Ok(cargo_metadata::MetadataCommand::parse(json)?);
        }
        last_err = Some(anyhow::anyhow!(
            "cargo metadata {} failed: {}",
            flags.join(" "),
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    Err(last_err.expect("at least one attempt ran"))
}

/// Cargo dependency provider discovering workspace dependencies using `cargo_metadata`.
#[derive(Debug, Default, Clone)]
pub struct CargoDependencyProvider;

impl CargoDependencyProvider {
    pub fn new() -> Self {
        Self
    }
}

#[async_trait]
impl DependencyProvider for CargoDependencyProvider {
    fn ecosystem(&self) -> &'static str {
        "cargo"
    }

    async fn discover_dependencies(&self, root: &Path) -> Result<Vec<DiscoveredDependency>> {
        let manifest_path = if root.is_file() {
            root.to_path_buf()
        } else {
            root.join("Cargo.toml")
        };

        if !manifest_path.exists() {
            anyhow::bail!("Cargo manifest not found at {}", manifest_path.display());
        }

        let metadata = run_cargo_metadata(&manifest_path).await.with_context(|| {
            format!(
                "Failed to execute cargo metadata for {}",
                manifest_path.display()
            )
        })?;

        // 1. Extract package checksums from Cargo.lock if present
        let lock_path = metadata.workspace_root.join("Cargo.lock");
        let lock_checksums = if lock_path.exists() {
            std::fs::read_to_string(lock_path.as_std_path())
                .map(|content| parse_lockfile_checksums(&content))
                .unwrap_or_default()
        } else {
            HashMap::new()
        };

        // 2. Identify workspace members and direct vs. transitive dependencies
        let workspace_members: HashSet<_> = metadata.workspace_members.iter().collect();
        let mut direct_package_ids = HashSet::new();
        let mut package_features: HashMap<&cargo_metadata::PackageId, Vec<String>> = HashMap::new();

        if let Some(ref resolve) = metadata.resolve {
            for node in &resolve.nodes {
                let feats: Vec<String> = node.features.iter().map(|f| f.to_string()).collect();
                package_features.insert(&node.id, feats);

                if workspace_members.contains(&node.id) {
                    for dep in &node.deps {
                        direct_package_ids.insert(&dep.pkg);
                    }
                }
            }
        }

        // 3. Classify external packages
        let mut discovered = Vec::new();
        for pkg in &metadata.packages {
            if workspace_members.contains(&pkg.id) {
                continue; // Skip workspace members
            }

            let source_type = match &pkg.source {
                Some(src) => {
                    let s = src.to_string();
                    if s.starts_with("registry+") {
                        DependencySourceType::CratesIo
                    } else if s.starts_with("git+") {
                        DependencySourceType::Git
                    } else {
                        DependencySourceType::Unknown(s)
                    }
                }
                None => DependencySourceType::Path,
            };

            let manifest_path = pkg.manifest_path.as_std_path().to_path_buf();
            let root_path = manifest_path
                .parent()
                .map(|p| p.to_path_buf())
                .unwrap_or_else(|| manifest_path.clone());

            let package_hash = lock_checksums
                .get(&(pkg.name.to_string(), pkg.version.to_string()))
                .cloned();

            let is_direct = direct_package_ids.contains(&pkg.id);
            let features = package_features.get(&pkg.id).cloned().unwrap_or_default();

            discovered.push(DiscoveredDependency {
                name: pkg.name.to_string(),
                version: pkg.version.to_string(),
                source_type,
                manifest_path,
                root_path,
                package_hash,
                is_direct,
                features,
            });
        }

        discovered.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
        Ok(discovered)
    }

    fn resolve_source_directory(&self, dep: &DiscoveredDependency) -> Result<PathBuf> {
        if dep.root_path.exists() && dep.root_path.is_dir() {
            Ok(dep.root_path.clone())
        } else if dep.manifest_path.exists() {
            Ok(dep
                .manifest_path
                .parent()
                .unwrap_or(&dep.manifest_path)
                .to_path_buf())
        } else {
            anyhow::bail!(
                "Unpacked source directory does not exist for dependency {} v{} at {}",
                dep.name,
                dep.version,
                dep.root_path.display()
            )
        }
    }
}

/// Parses package checksums from standard `Cargo.lock` text.
fn parse_lockfile_checksums(content: &str) -> HashMap<(String, String), String> {
    let mut map = HashMap::new();
    let mut current_name = None;
    let mut current_version = None;
    let mut current_checksum = None;

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed == "[[package]]" {
            if let (Some(name), Some(ver), Some(chk)) = (
                current_name.take(),
                current_version.take(),
                current_checksum.take(),
            ) {
                map.insert((name, ver), chk);
            }
            current_name = None;
            current_version = None;
            current_checksum = None;
        } else if let Some(rest) = trimmed.strip_prefix("name = ") {
            current_name = Some(rest.trim_matches('"').to_string());
        } else if let Some(rest) = trimmed.strip_prefix("version = ") {
            current_version = Some(rest.trim_matches('"').to_string());
        } else if let Some(rest) = trimmed.strip_prefix("checksum = ") {
            current_checksum = Some(rest.trim_matches('"').to_string());
        }
    }

    if let (Some(name), Some(ver), Some(chk)) = (current_name, current_version, current_checksum) {
        map.insert((name, ver), chk);
    }

    map
}

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

    #[test]
    fn parses_lockfile_checksums_correctly() {
        let lockfile = r#"
# This file is automatically @generated by Cargo.
version = 4

[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b823d03a0719213154163092b5077492f80252d76ab8103ed98bc045adc0109"

[[package]]
name = "scryer-db"
version = "0.1.0"
dependencies = [
 "toasty",
]
"#;
        let checksums = parse_lockfile_checksums(lockfile);
        assert_eq!(
            checksums.get(&("serde".to_string(), "1.0.229".to_string())),
            Some(&"6b823d03a0719213154163092b5077492f80252d76ab8103ed98bc045adc0109".to_string())
        );
        assert_eq!(
            checksums.get(&("scryer-db".to_string(), "0.1.0".to_string())),
            None
        );
    }
}