scryer-engine 0.2.1

Tree-sitter and stack-graphs AST indexing engine for Scryer code intelligence
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tempfile::tempdir;

use scryer_db::{CodeGraphEdge, Project, ScryerDb, SymbolReference};
use scryer_engine::{
    EngineService, ProjectEnginePool, RawScope, RawSymbol, ScmFallbackResolver, StackGraphEngine,
};

#[test]
fn test_minimal_engine() -> anyhow::Result<()> {
    let mut engine = StackGraphEngine::new_rust()?;
    let path1 = Path::new("src/lib.rs");
    let content1 = "pub fn add(a: i32, b: i32) -> i32 { a + b }";
    let file1 = engine.add_file(path1, content1)?;

    let path2 = Path::new("src/main.rs");
    let content2 = "fn main() { add(1, 2); }";
    let file2 = engine.add_file(path2, content2)?;

    let cancellation = stack_graphs::NoCancellation;
    let paths_count1 = engine.precompute_file_paths(file1, &cancellation)?;
    let paths_count2 = engine.precompute_file_paths(file2, &cancellation)?;
    assert!(paths_count1 > 0);
    assert!(paths_count2 > 0);

    let resolved = engine.resolve_at_location(path2, 1, 13)?;
    assert!(resolved.is_some());
    let def = resolved.unwrap();
    assert_eq!(def.symbol_name, "add");
    assert_eq!(def.file_path, Path::new("src/lib.rs"));

    Ok(())
}

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

    fs::create_dir_all(root.join("src"))?;
    fs::write(
        root.join("src/lib.rs"),
        r#"pub fn calculate_total(price: i32, tax: i32) -> i32 {
    price + tax
}
"#,
    )?;

    fs::write(
        root.join("src/main.rs"),
        r#"fn main() {
    let total = calculate_total(100, 20);
}
"#,
    )?;

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

    let service = EngineService::new(db.clone());
    let report = service.index_project(project.id, root).await?;

    assert_eq!(report.added_files, 2);
    assert!(report.total_symbols >= 2);
    assert!(report.total_references >= 1);
    assert!(report.total_edges >= 1);

    // Verify resolve_definition resolves caller `calculate_total` in main.rs (line 2, col 18) to lib.rs
    let def = service
        .resolve_definition(project.id, root, Path::new("src/main.rs"), 2, 18)
        .await?;
    assert!(def.is_some());
    let def = def.unwrap();
    assert_eq!(def.symbol_name, "calculate_total");
    assert_eq!(def.file_path, Path::new("src/lib.rs"));

    // Verify DB records
    let mut conn = db.lock().await;
    let refs = SymbolReference::filter(
        SymbolReference::fields()
            .project_id()
            .eq(project.id)
            .and(SymbolReference::fields().role().eq("call")),
    )
    .exec(&mut *conn)
    .await?;
    assert!(!refs.is_empty());

    let edges = CodeGraphEdge::filter(
        CodeGraphEdge::fields()
            .project_id()
            .eq(project.id)
            .and(CodeGraphEdge::fields().edge_type().eq("calls")),
    )
    .exec(&mut *conn)
    .await?;
    assert!(!edges.is_empty());

    Ok(())
}

/// Watcher-driven `index_file` must keep the stack-graph engine in sync, both for
/// edited files and for files added after the initial index.
#[tokio::test]
async fn test_index_file_updates_stack_graph() -> anyhow::Result<()> {
    let temp = tempdir()?;
    let root = temp.path();

    fs::create_dir_all(root.join("src"))?;
    fs::write(root.join("src/lib.rs"), "pub fn existing() {}\n")?;
    fs::write(
        root.join("src/main.rs"),
        "fn main() {\n    existing();\n}\n",
    )?;

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

    let service = EngineService::new(db.clone());
    service.index_project(project.id, root).await?;

    // Simulate watcher events: a new file appears and main.rs is edited to call it.
    fs::write(
        root.join("src/util.rs"),
        "pub fn helper(x: i32) -> i32 { x }\n",
    )?;
    fs::write(
        root.join("src/main.rs"),
        "mod util;\nfn main() {\n    let t = helper(1);\n}\n",
    )?;
    service
        .index_file(project.id, root, Path::new("src/util.rs"))
        .await?;
    service
        .index_file(project.id, root, Path::new("src/main.rs"))
        .await?;

    // Line 3 only has `helper` in the edited main.rs; stale engine content would miss it.
    let def = service
        .resolve_definition(project.id, root, Path::new("src/main.rs"), 3, 14)
        .await?
        .expect("helper should resolve after watcher-driven index_file");
    assert_eq!(def.symbol_name, "helper");
    assert_eq!(def.file_path, Path::new("src/util.rs"));

    Ok(())
}

#[test]
fn test_path_explosion_mitigation() -> anyhow::Result<()> {
    let mut engine = StackGraphEngine::new_rust()?;
    // Mutual recursive dependency
    let file_a = "pub fn a() { b(); }";
    let file_b = "pub fn b() { a(); }";

    let ha = engine.add_file(Path::new("src/a.rs"), file_a)?;
    let hb = engine.add_file(Path::new("src/b.rs"), file_b)?;

    let cancellation = stack_graphs::NoCancellation;
    engine.precompute_file_paths(ha, &cancellation)?;
    engine.precompute_file_paths(hb, &cancellation)?;

    // Bounded search terminates without hanging or panicking
    let def_a = engine.resolve_at_location(Path::new("src/a.rs"), 1, 14)?;
    assert!(def_a.is_some());
    assert_eq!(def_a.unwrap().symbol_name, "b");

    let def_b = engine.resolve_at_location(Path::new("src/b.rs"), 1, 14)?;
    assert!(def_b.is_some());
    assert_eq!(def_b.unwrap().symbol_name, "a");

    Ok(())
}

#[tokio::test]
async fn test_multi_project_isolation() -> anyhow::Result<()> {
    let pool = ProjectEnginePool::new(4)?;

    // Project 1
    let p1_lock = pool.get_or_create(1).await;
    {
        let mut eng = p1_lock.lock().await;
        eng.add_file(Path::new("src/lib.rs"), "pub fn worker_one() {}")?;
        eng.add_file(Path::new("src/main.rs"), "fn main() { worker_one(); }")?;
        let cancellation = stack_graphs::NoCancellation;
        eng.precompute_all_files(&cancellation)?;
    }

    // Project 2
    let p2_lock = pool.get_or_create(2).await;
    {
        let mut eng = p2_lock.lock().await;
        eng.add_file(Path::new("src/lib.rs"), "pub fn worker_two() {}")?;
        eng.add_file(Path::new("src/main.rs"), "fn main() { worker_two(); }")?;
        let cancellation = stack_graphs::NoCancellation;
        eng.precompute_all_files(&cancellation)?;
    }

    // Project 1 resolves worker_one, fails worker_two
    {
        let mut eng = p1_lock.lock().await;
        let res = eng.resolve_at_location(Path::new("src/main.rs"), 1, 22)?;
        assert!(res.is_some());
        assert_eq!(res.unwrap().symbol_name, "worker_one");
    }

    // Project 2 resolves worker_two, fails worker_one
    {
        let mut eng = p2_lock.lock().await;
        let res = eng.resolve_at_location(Path::new("src/main.rs"), 1, 22)?;
        assert!(res.is_some());
        assert_eq!(res.unwrap().symbol_name, "worker_two");
    }

    // Verify pool retrieve gives the same engine
    let p1_again = pool.get_or_create(1).await;
    assert!(Arc::ptr_eq(&p1_lock, &p1_again));

    Ok(())
}

#[test]
fn test_tier2_scm_fallback() {
    let scopes = vec![
        RawScope {
            local_id: 0,
            parent_local_id: None,
            scope_kind: "module".to_string(),
            start_byte: 0,
            end_byte: 200,
            start_line: 1,
            end_line: 15,
        },
        RawScope {
            local_id: 1,
            parent_local_id: Some(0),
            scope_kind: "function".to_string(),
            start_byte: 30,
            end_byte: 120,
            start_line: 3,
            end_line: 9,
        },
    ];

    let symbols = vec![
        RawSymbol {
            scope_local_id: Some(0),
            name: "GLOBAL_CFG".to_string(),
            qualified_name: "crate::GLOBAL_CFG".to_string(),
            kind: "const".to_string(),
            visibility: "public".to_string(),
            signature: "pub const GLOBAL_CFG: u32 = 10;".to_string(),
            docstring: None,
            start_byte: 5,
            end_byte: 25,
            start_line: 1,
            end_line: 1,
        },
        RawSymbol {
            scope_local_id: Some(1),
            name: "local_helper".to_string(),
            qualified_name: "crate::my_func::local_helper".to_string(),
            kind: "fn".to_string(),
            visibility: "private".to_string(),
            signature: "fn local_helper()".to_string(),
            docstring: None,
            start_byte: 40,
            end_byte: 70,
            start_line: 4,
            end_line: 6,
        },
    ];

    // Inside function scope (line 5): resolves local_helper
    let res = ScmFallbackResolver::resolve_identifier(
        "local_helper",
        Path::new("src/lib.rs"),
        5,
        10,
        &scopes,
        &symbols,
    );
    assert!(res.is_some());
    let def = res.unwrap();
    assert_eq!(def.symbol_name, "local_helper");
    assert_eq!(def.qualified_name, "crate::my_func::local_helper");

    // Inside function scope: can also resolve file-level symbol GLOBAL_CFG
    let res_global = ScmFallbackResolver::resolve_identifier(
        "GLOBAL_CFG",
        Path::new("src/lib.rs"),
        7,
        10,
        &scopes,
        &symbols,
    );
    assert!(res_global.is_some());
    assert_eq!(res_global.unwrap().symbol_name, "GLOBAL_CFG");

    // Unknown symbol returns None
    let unknown = ScmFallbackResolver::resolve_identifier(
        "nonexistent",
        Path::new("src/lib.rs"),
        5,
        10,
        &scopes,
        &symbols,
    );
    assert!(unknown.is_none());
}