scryer-engine 0.3.0

Tree-sitter AST indexing and reference resolution engine for Scryer code intelligence
use std::fs;
use tempfile::tempdir;

use scryer_db::{DependencyPackage, Project, ProjectDependency, ScryerDb, SourceFile, Symbol};
use scryer_engine::EngineService;

#[tokio::test]
async fn test_dependency_ingestion_and_deduplication() -> anyhow::Result<()> {
    let temp = tempdir()?;
    let root = temp.path();

    // Set up a minimal project with cached Cargo dependency (cfg-if)
    fs::create_dir_all(root.join("src"))?;
    fs::write(
        root.join("Cargo.toml"),
        r#"[package]
name = "fixture-app"
version = "0.1.0"
edition = "2021"

[dependencies]
bitflags = "2.4"
"#,
    )?;
    fs::write(root.join("src/lib.rs"), "pub fn run() {}")?;

    let db = ScryerDb::new_in_memory().await?;
    let mut conn = db.lock().await;

    // 1. Create first project
    let project1 = Project::create()
        .slug("test-project-1".to_string())
        .root_path(root.to_string_lossy().to_string())
        .default_branch("main".to_string())
        .is_active(true)
        .created_at("2026-10-07T00:00:00Z".to_string())
        .updated_at("2026-10-07T00:00:00Z".to_string())
        .exec(&mut *conn)
        .await?;
    drop(conn);

    let engine = EngineService::new(db.clone());

    // 2. Initial dependency indexing for project 1
    let count1 = engine.index_dependencies(project1.id, root).await?;
    assert!(count1 > 0, "Should have indexed dependencies for project 1");

    let (pkg_count, file_count, sym_count, proj_dep_count_1) = {
        let mut conn = db.lock().await;
        let packages = DependencyPackage::all().exec(&mut *conn).await?;
        assert!(!packages.is_empty(), "DependencyPackage rows must exist");

        let project_deps = ProjectDependency::all().exec(&mut *conn).await?;
        assert!(
            !project_deps.is_empty(),
            "ProjectDependency rows must exist"
        );
        assert!(
            project_deps.iter().all(|d| d.project_id == project1.id),
            "All project_deps must point to project 1"
        );

        let files = SourceFile::filter_by_project_id(0).exec(&mut *conn).await?;
        assert!(
            !files.is_empty(),
            "Dependency SourceFile rows with project_id = 0 must exist"
        );
        assert!(
            files.iter().all(|f| f.dependency_package_id.is_some()),
            "Dependency files must have dependency_package_id set"
        );

        let symbols = Symbol::filter_by_project_id(0).exec(&mut *conn).await?;
        assert!(
            !symbols.is_empty(),
            "Dependency Symbol rows with project_id = 0 must exist"
        );

        (
            packages.len(),
            files.len(),
            symbols.len(),
            project_deps.len(),
        )
    };

    // 3. Second indexing pass for project 1 (assert O(1) deduplication: zero added packages/files/symbols)
    let count2 = engine.index_dependencies(project1.id, root).await?;
    assert_eq!(
        count2, 0,
        "Re-index should find all packages cached and parse 0 new packages"
    );

    {
        let mut conn = db.lock().await;
        let packages_after = DependencyPackage::all().exec(&mut *conn).await?;
        assert_eq!(
            packages_after.len(),
            pkg_count,
            "Package count must not change on re-index"
        );

        let files_after = SourceFile::filter_by_project_id(0).exec(&mut *conn).await?;
        assert_eq!(
            files_after.len(),
            file_count,
            "Dependency file count must not change on re-index"
        );

        let symbols_after = Symbol::filter_by_project_id(0).exec(&mut *conn).await?;
        assert_eq!(
            symbols_after.len(),
            sym_count,
            "Dependency symbol count must not change on re-index"
        );

        let project_deps_after = ProjectDependency::all().exec(&mut *conn).await?;
        assert_eq!(
            project_deps_after.len(),
            proj_dep_count_1,
            "ProjectDependency count must not change on re-index"
        );
    }

    // 4. Create second project with separate root_path and index dependencies (assert cross-project deduplication)
    let temp2 = tempdir()?;
    let root2 = temp2.path();
    fs::create_dir_all(root2.join("src"))?;
    fs::write(
        root2.join("Cargo.toml"),
        r#"[package]
name = "fixture-app-2"
version = "0.1.0"
edition = "2021"

[dependencies]
bitflags = "2.4"
"#,
    )?;
    fs::write(root2.join("src/lib.rs"), "pub fn app2() {}")?;

    let mut conn = db.lock().await;
    let project2 = Project::create()
        .slug("test-project-2".to_string())
        .root_path(root2.to_string_lossy().to_string())
        .default_branch("main".to_string())
        .is_active(false)
        .created_at("2026-10-07T00:00:00Z".to_string())
        .updated_at("2026-10-07T00:00:00Z".to_string())
        .exec(&mut *conn)
        .await?;
    drop(conn);

    let count3 = engine.index_dependencies(project2.id, root2).await?;
    assert_eq!(
        count3, 0,
        "Project 2 should reuse cached packages without re-parsing"
    );

    {
        let mut conn = db.lock().await;
        let packages_final = DependencyPackage::all().exec(&mut *conn).await?;
        assert_eq!(
            packages_final.len(),
            pkg_count,
            "Package count must remain identical when second project links dependencies"
        );

        let files_final = SourceFile::filter_by_project_id(0).exec(&mut *conn).await?;
        assert_eq!(
            files_final.len(),
            file_count,
            "Dependency SourceFile count must remain identical"
        );

        let symbols_final = Symbol::filter_by_project_id(0).exec(&mut *conn).await?;
        assert_eq!(
            symbols_final.len(),
            sym_count,
            "Dependency Symbol count must remain identical"
        );

        let project_deps_final = ProjectDependency::all().exec(&mut *conn).await?;
        assert_eq!(
            project_deps_final.len(),
            proj_dep_count_1 * 2,
            "ProjectDependency rows should double to link both projects to shared packages"
        );

        let p2_deps: Vec<_> = project_deps_final
            .iter()
            .filter(|d| d.project_id == project2.id)
            .collect();
        assert_eq!(p2_deps.len(), proj_dep_count_1);
    }

    Ok(())
}

/// A package left unstamped by a failed run is re-indexed (replacing, not duplicating, its
/// files) and stamped once ingestion succeeds.
#[tokio::test]
async fn test_incomplete_dependency_package_is_reindexed() -> anyhow::Result<()> {
    let temp = tempdir()?;
    let root = temp.path();

    fs::create_dir_all(root.join("src"))?;
    fs::write(
        root.join("Cargo.toml"),
        r#"[package]
name = "fixture-app"
version = "0.1.0"
edition = "2021"

[dependencies]
bitflags = "2.4"
"#,
    )?;
    fs::write(root.join("src/lib.rs"), "pub fn run() {}")?;

    let db = ScryerDb::new_in_memory().await?;
    let project = {
        let mut conn = db.lock().await;
        Project::create()
            .slug("test-incomplete-dep".to_string())
            .root_path(root.to_string_lossy().to_string())
            .default_branch("main".to_string())
            .is_active(true)
            .created_at("2026-10-07T00:00:00Z".to_string())
            .updated_at("2026-10-07T00:00:00Z".to_string())
            .exec(&mut *conn)
            .await?
    };
    let engine = EngineService::new(db.clone());

    assert!(engine.index_dependencies(project.id, root).await? > 0);

    // A successful run stamps every package.
    let (files_before, symbols_before) = {
        let mut conn = db.lock().await;
        let packages = DependencyPackage::all().exec(&mut *conn).await?;
        assert!(!packages.is_empty());
        assert!(
            packages.iter().all(|p| !p.indexed_at.is_empty()),
            "completed packages must be stamped"
        );
        (
            SourceFile::filter_by_project_id(0)
                .exec(&mut *conn)
                .await?
                .len(),
            Symbol::filter_by_project_id(0)
                .exec(&mut *conn)
                .await?
                .len(),
        )
    };

    // Simulate a run that failed after creating the rows: unstamp one package.
    {
        let mut conn = db.lock().await;
        let mut pkg = DependencyPackage::all()
            .exec(&mut *conn)
            .await?
            .into_iter()
            .next()
            .unwrap();
        pkg.update()
            .indexed_at(String::new())
            .exec(&mut *conn)
            .await?;
    }

    // The next run retries it, without duplicating what the earlier run already wrote.
    let retried = engine.index_dependencies(project.id, root).await?;
    assert_eq!(retried, 1, "only the incomplete package is re-indexed");

    let mut conn = db.lock().await;
    let packages = DependencyPackage::all().exec(&mut *conn).await?;
    assert!(packages.iter().all(|p| !p.indexed_at.is_empty()));
    assert_eq!(
        SourceFile::filter_by_project_id(0)
            .exec(&mut *conn)
            .await?
            .len(),
        files_before,
        "retry must replace files, not duplicate them"
    );
    assert_eq!(
        Symbol::filter_by_project_id(0)
            .exec(&mut *conn)
            .await?
            .len(),
        symbols_before,
        "retry must replace symbols, not duplicate them"
    );
    Ok(())
}

/// Dropping a dependency from Cargo.toml removes the project's link to it (the shared
/// package rows stay for other projects).
#[tokio::test]
async fn test_removed_dependency_link_is_pruned() -> anyhow::Result<()> {
    let temp = tempdir()?;
    let root = temp.path();

    fs::create_dir_all(root.join("src"))?;
    let manifest = |deps: &str| {
        format!(
            "[package]\nname = \"fixture-app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{deps}"
        )
    };
    fs::write(root.join("Cargo.toml"), manifest("bitflags = \"2.4\"\n"))?;
    fs::write(root.join("src/lib.rs"), "pub fn run() {}")?;

    let db = ScryerDb::new_in_memory().await?;
    let project = {
        let mut conn = db.lock().await;
        Project::create()
            .slug("test-prune-links".to_string())
            .root_path(root.to_string_lossy().to_string())
            .default_branch("main".to_string())
            .is_active(true)
            .created_at("2026-10-07T00:00:00Z".to_string())
            .updated_at("2026-10-07T00:00:00Z".to_string())
            .exec(&mut *conn)
            .await?
    };
    let engine = EngineService::new(db.clone());
    engine.index_dependencies(project.id, root).await?;
    {
        let mut conn = db.lock().await;
        assert!(!ProjectDependency::all().exec(&mut *conn).await?.is_empty());
    }

    fs::write(root.join("Cargo.toml"), manifest(""))?;
    engine.index_dependencies(project.id, root).await?;

    let mut conn = db.lock().await;
    assert!(
        ProjectDependency::all().exec(&mut *conn).await?.is_empty(),
        "link to the removed dependency must be pruned"
    );
    assert!(
        !DependencyPackage::all().exec(&mut *conn).await?.is_empty(),
        "shared package rows are kept"
    );
    Ok(())
}