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;
use scryer_engine::search::{SearchQuery, SearchResults, SearchScope};

async fn test_project(db: &ScryerDb, root: &std::path::Path) -> anyhow::Result<Project> {
    let mut conn = db.lock().await;
    let project = Project::create()
        .slug("test-search".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?;
    Ok(project)
}

fn text(q: &str) -> SearchQuery {
    SearchQuery {
        text: Some(q.to_string()),
        limit: 20,
        ..SearchQuery::default()
    }
}

fn names(res: &SearchResults) -> Vec<&str> {
    res.hits.iter().map(|h| h.name.as_str()).collect()
}

#[tokio::test]
async fn test_symbol_search_ranking_like_symbol_and_generations() -> anyhow::Result<()> {
    let temp = tempdir()?;
    let root = temp.path();
    fs::create_dir_all(root.join("src"))?;
    fs::write(
        root.join("src/net.rs"),
        r#"
/// Retry an operation with exponential backoff.
pub fn retry_with_backoff(attempts: u32) -> u32 {
    attempts
}

/// Exponential backoff policy.
pub struct ExponentialBackoff {
    pub base_ms: u64,
}
"#,
    )?;
    fs::write(
        root.join("src/config.rs"),
        r#"
/// Parse the configuration file.
pub fn parse_config(path: &str) -> String {
    path.to_string()
}
"#,
    )?;

    let db = ScryerDb::new_in_memory().await?;
    let project = test_project(&db, root).await?;
    let engine = EngineService::new(db.clone());
    engine.index_project(project.id, root).await?;

    // 1. Concept query ranks the two backoff symbols first.
    let res = engine
        .search_symbols(project.id, text("backoff retry"))
        .await?;
    let top: Vec<&str> = names(&res).into_iter().take(2).collect();
    assert!(top.contains(&"retry_with_backoff"), "got {top:?}");
    assert!(top.contains(&"ExponentialBackoff"), "got {top:?}");
    assert!(!names(&res).contains(&"parse_config"));
    assert_eq!(res.hits[0].name, "retry_with_backoff");
    assert!(res.hits[0].matched_terms.contains(&"retry".to_string()));

    // 2. Kind and file filters.
    let res = engine
        .search_symbols(
            project.id,
            SearchQuery {
                kinds: vec!["STRUCT".to_string()],
                ..text("backoff")
            },
        )
        .await?;
    assert_eq!(names(&res), vec!["ExponentialBackoff"]);
    let res = engine
        .search_symbols(
            project.id,
            SearchQuery {
                file_filter: Some("CONFIG".to_string()),
                ..text("parse backoff")
            },
        )
        .await?;
    assert_eq!(names(&res), vec!["parse_config"]);

    // 3. like_symbol finds similar code and excludes the target itself.
    let res = engine
        .search_symbols(
            project.id,
            SearchQuery {
                like_symbol: Some("retry_with_backoff".to_string()),
                limit: 20,
                ..SearchQuery::default()
            },
        )
        .await?;
    assert_eq!(res.hits[0].name, "ExponentialBackoff");
    assert!(!names(&res).contains(&"retry_with_backoff"));
    assert!(res.like_symbol.is_some());

    // 4. Pagination.
    let res = engine
        .search_symbols(
            project.id,
            SearchQuery {
                offset: 1,
                limit: 1,
                ..text("backoff")
            },
        )
        .await?;
    assert_eq!(res.total, 2);
    assert_eq!(res.hits.len(), 1);
    assert!(!res.has_more);

    // 5. A re-index bumps the generation: new symbols show up.
    fs::write(
        root.join("src/jitter.rs"),
        "pub fn jittered_backoff() -> u64 { 0 }\n",
    )?;
    engine.index_project(project.id, root).await?;
    let res = engine.search_symbols(project.id, text("backoff")).await?;
    assert!(names(&res).contains(&"jittered_backoff"));

    // 6. Removing a file removes its results.
    fs::remove_file(root.join("src/net.rs"))?;
    engine.index_project(project.id, root).await?;
    let res = engine.search_symbols(project.id, text("backoff")).await?;
    assert_eq!(names(&res), vec!["jittered_backoff"]);

    // 7. Exactly one of query / like_symbol.
    assert!(
        engine
            .search_symbols(project.id, SearchQuery::default())
            .await
            .is_err()
    );
    Ok(())
}

#[tokio::test]
async fn test_dependency_scope_uses_project_links() -> 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 local_mutex() {}\n")?;

    let db = ScryerDb::new_in_memory().await?;
    let project = test_project(&db, root).await?;
    let engine = EngineService::new(db.clone());
    engine.index_project(project.id, root).await?;

    {
        let mut guard = db.lock().await;
        for (crate_name, linked) in [("tokio_mock", true), ("other_mock", false)] {
            let pkg = DependencyPackage::create()
                .name(crate_name.to_string())
                .version("1.0.0".to_string())
                .source_type("cratesio".to_string())
                .package_hash(format!("hash-{crate_name}"))
                .root_path(root.to_string_lossy().to_string())
                .manifest_path(root.join("Cargo.toml").to_string_lossy().to_string())
                .indexed_at("2026-10-07T00:00:00Z".to_string())
                .exec(&mut *guard)
                .await?;
            if linked {
                ProjectDependency::create()
                    .project_id(project.id)
                    .dependency_package_id(pkg.id)
                    .is_direct(true)
                    .features(String::new())
                    .exec(&mut *guard)
                    .await?;
            }
            let file = SourceFile::create()
                .project_id(0)
                .path("src/sync.rs".to_string())
                .language("rust".to_string())
                .content_hash(format!("file-{crate_name}"))
                .line_count(10)
                .byte_size(100)
                .dependency_package_id(Some(pkg.id))
                .exec(&mut *guard)
                .await?;
            Symbol::create()
                .project_id(0)
                .file_id(file.id)
                .name("Mutex".to_string())
                .qualified_name(format!("{crate_name}::sync::Mutex"))
                .kind("struct".to_string())
                .visibility("public".to_string())
                .signature("pub struct Mutex<T>".to_string())
                .start_line(1)
                .end_line(5)
                .start_byte(0)
                .end_byte(50)
                .dependency_package_id(Some(pkg.id))
                .exec(&mut *guard)
                .await?;
        }
    }

    let deps = |crate_name: Option<&str>| SearchQuery {
        scope: SearchScope::Dependencies {
            crate_name: crate_name.map(str::to_string),
        },
        ..text("Mutex")
    };

    // Without crate_name only linked packages are searched.
    let res = engine.search_symbols(project.id, deps(None)).await?;
    assert_eq!(res.total, 1);
    assert_eq!(res.hits[0].crate_name.as_deref(), Some("tokio_mock"));
    assert_eq!(res.hits[0].version.as_deref(), Some("1.0.0"));

    // crate_name resolves case-insensitively, falling back to unlinked cached crates.
    let res = engine
        .search_symbols(project.id, deps(Some("OTHER_MOCK")))
        .await?;
    assert_eq!(res.total, 1);
    assert_eq!(res.hits[0].crate_name.as_deref(), Some("other_mock"));

    let res = engine
        .search_symbols(project.id, deps(Some("missing")))
        .await?;
    assert_eq!(res.total, 0);

    // Project scope never sees dependency symbols.
    let res = engine.search_symbols(project.id, text("mutex")).await?;
    assert_eq!(names(&res), vec!["local_mutex"]);
    Ok(())
}

#[test]
fn test_code_is_recognised_by_path_and_module() {
    use scryer_engine::search::cache::is_test_symbol;

    for path in [
        "tests/test_engine.py",
        "scrapy/tests/helpers.py",
        "pkg/test_utils.py",
        "pkg/utils_test.py",
        "pkg/conftest.py",
        "packages/server/src/foo.test.ts",
        "packages/server/src/foo.spec.tsx",
        "packages/server/src/__tests__/foo.ts",
        "crates/a/tests/integration.rs",
        "crates/a/benches/speed.rs",
        "examples/demo.rs",
        "src\\test\\helpers.rs",
    ] {
        assert!(is_test_symbol(path, "m.f"), "{path} is test code");
    }
    for path in [
        "scrapy/core/engine.py",
        "scrapy/utils/test.py",
        "scrapy/contest.py",
        "packages/server/src/latest.ts",
        "packages/server/src/testing.ts",
        "src/attest.rs",
        "src/lib.rs",
    ] {
        assert!(!is_test_symbol(path, "m.f"), "{path} is source");
    }
    // Inline test modules of a Rust source file.
    assert!(is_test_symbol(
        "src/lib.rs",
        "crate::buffer::tests::set_string"
    ));
    assert!(is_test_symbol("src/lib.rs", "crate::test::helper"));
    assert!(!is_test_symbol("src/lib.rs", "crate::buffer::set_string"));
    assert!(!is_test_symbol("src/lib.rs", "crate::contests::run"));
}

#[tokio::test]
async fn test_code_ranks_below_source_for_the_same_match_but_is_still_found() -> anyhow::Result<()>
{
    let temp = tempdir()?;
    let root = temp.path();
    fs::create_dir_all(root.join("pkg"))?;
    fs::create_dir_all(root.join("tests"))?;
    // The test helper sorts first by path and matches better (the name is longer-repeated),
    // yet the handler in source must come first.
    fs::write(
        root.join("tests/test_handlers.py"),
        "def download_handler_scheme():\n    return 1\n\ndef test_only_in_tests_scheme():\n    return 2\n",
    )?;
    fs::write(
        root.join("pkg/handlers.py"),
        "def download_handler_scheme():\n    return 3\n",
    )?;

    let db = ScryerDb::new_in_memory().await?;
    let project = test_project(&db, root).await?;
    let engine = EngineService::new(db.clone());
    engine.index_project(project.id, root).await?;

    let res = engine
        .search_symbols(project.id, text("download handler scheme"))
        .await?;
    assert_eq!(res.hits[0].file_path, "pkg/handlers.py", "{:?}", res.hits);
    assert_eq!(res.hits[1].file_path, "tests/test_handlers.py");

    // A query that only the tests answer still returns them.
    let res = engine
        .search_symbols(project.id, text("test only in tests"))
        .await?;
    assert_eq!(res.hits[0].name, "test_only_in_tests_scheme");
    Ok(())
}