use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::time::Duration;
use tempfile::tempdir;
use scryer_db::{
CodeGraphEdge, Project, Scope, ScryerDb, SourceFile, Symbol, SymbolReference,
UnresolvedReference,
};
use scryer_engine::EngineService;
#[tokio::test]
async fn test_scanner_gitignore_and_initial_indexing() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::create_dir_all(root.join("target"))?;
fs::write(root.join(".gitignore"), "target/\nignored.rs\n")?;
fs::write(
root.join("src/lib.rs"),
r#"
pub fn root_init() -> bool {
true
}
"#,
)?;
fs::write(
root.join("src/helper.rs"),
r#"
pub struct Helper {
pub id: u64,
}
"#,
)?;
fs::write(root.join("ignored.rs"), "pub fn ignored_fn() {}")?;
fs::write(root.join("target/build.rs"), "pub fn build_fn() {}")?;
let db = ScryerDb::new_in_memory().await?;
let mut conn = db.lock().await;
let project = Project::create()
.slug("test-incremental".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?;
drop(conn);
let engine = EngineService::new(db.clone());
let report1 = engine.index_project(project.id, root).await?;
assert_eq!(report1.scanned_files, 2);
assert_eq!(report1.added_files, 2);
assert_eq!(report1.modified_files, 0);
assert_eq!(report1.unchanged_files, 0);
assert_eq!(report1.deleted_files, 0);
{
let mut conn = db.lock().await;
let files = SourceFile::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
assert_eq!(files.len(), 2);
let paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
assert!(paths.contains(&"src/lib.rs".to_string()));
assert!(paths.contains(&"src/helper.rs".to_string()));
let symbols = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
assert_eq!(symbols.len(), 2);
let sym_names: Vec<String> = symbols.iter().map(|s| s.name.clone()).collect();
assert!(sym_names.contains(&"root_init".to_string()));
assert!(sym_names.contains(&"Helper".to_string()));
}
let report2 = engine.index_project(project.id, root).await?;
assert_eq!(report2.scanned_files, 2);
assert_eq!(report2.added_files, 0);
assert_eq!(report2.modified_files, 0);
assert_eq!(report2.unchanged_files, 2);
assert_eq!(report2.deleted_files, 0);
fs::write(
root.join("src/helper.rs"),
r#"
pub struct NewHelper {
pub name: String,
}
pub fn new_helper_fn() {}
"#,
)?;
let report3 = engine.index_project(project.id, root).await?;
assert_eq!(report3.scanned_files, 2);
assert_eq!(report3.added_files, 0);
assert_eq!(report3.modified_files, 1);
assert_eq!(report3.unchanged_files, 1);
assert_eq!(report3.deleted_files, 0);
{
let mut conn = db.lock().await;
let symbols = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let sym_names: Vec<String> = symbols.iter().map(|s| s.name.clone()).collect();
assert!(!sym_names.contains(&"Helper".to_string()));
assert!(sym_names.contains(&"NewHelper".to_string()));
assert!(sym_names.contains(&"new_helper_fn".to_string()));
assert!(sym_names.contains(&"root_init".to_string()));
}
fs::remove_file(root.join("src/helper.rs"))?;
let report4 = engine.index_project(project.id, root).await?;
assert_eq!(report4.scanned_files, 1);
assert_eq!(report4.added_files, 0);
assert_eq!(report4.modified_files, 0);
assert_eq!(report4.unchanged_files, 1);
assert_eq!(report4.deleted_files, 1);
{
let mut conn = db.lock().await;
let files = SourceFile::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "src/lib.rs");
let symbols = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "root_init");
let scopes = Scope::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
assert_eq!(scopes.len(), 1); }
Ok(())
}
#[tokio::test]
async fn test_workspace_watcher_reactive_indexing() -> 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 initial() {}")?;
let db = ScryerDb::new_in_memory().await?;
let mut conn = db.lock().await;
let project = Project::create()
.slug("test-watcher".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?;
drop(conn);
let engine = EngineService::new(db.clone());
engine.index_project(project.id, root).await?;
let watcher = engine.watch_project(project.id, root)?;
fs::write(
root.join("src/dynamic.rs"),
r#"
pub fn dynamically_added() -> i32 {
100
}
"#,
)?;
tokio::time::sleep(Duration::from_millis(500)).await;
{
let mut conn = db.lock().await;
let files = SourceFile::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
assert!(paths.contains(&"src/dynamic.rs".to_string()));
let symbols = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let names: Vec<String> = symbols.iter().map(|s| s.name.clone()).collect();
assert!(names.contains(&"dynamically_added".to_string()));
}
watcher.stop().await;
Ok(())
}
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-incremental-extra".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?;
Ok(project)
}
#[tokio::test]
async fn test_unparseable_file_does_not_abort_index() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/good.rs"), "pub fn good() {}\n")?;
fs::write(root.join("src/bad.rs"), [0xff, 0xfe, 0xfd, b'\n'])?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
service.index_project(project.id, root).await?;
let mut conn = db.lock().await;
let files = SourceFile::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let paths: Vec<_> = files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, ["src/good.rs"], "bad file skipped, good file kept");
Ok(())
}
#[tokio::test]
async fn test_incremental_index_after_restart_links_into_unchanged_files() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/a.rs"), "pub fn helper() {}\n")?;
fs::write(root.join("src/b.rs"), "pub fn caller() { helper(); }\n")?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
EngineService::new(db.clone())
.index_project(project.id, root)
.await?;
fs::write(
root.join("src/b.rs"),
"pub fn caller() { helper(); helper(); }\n",
)?;
let restarted = EngineService::new(db.clone());
let report = restarted.index_project(project.id, root).await?;
assert_eq!(report.modified_files, 1);
assert_eq!(report.unchanged_files, 1);
let mut conn = db.lock().await;
let helper = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?
.into_iter()
.find(|s| s.name == "helper")
.expect("helper is indexed");
let calls = SymbolReference::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?
.into_iter()
.filter(|r| r.symbol_id == helper.id && r.role == "call")
.count();
assert_eq!(calls, 2, "both calls in the modified file link to helper");
Ok(())
}
#[tokio::test]
async fn test_batched_rows_round_trip_scopes_and_symbols() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(
root.join("src/lib.rs"),
r#"
/// It's a "quoted" doc with a 'single' quote and a backslash \ too.
pub struct Widget {
pub id: u64,
}
impl Widget {
/// Builds a widget; mentions O'Reilly.
pub fn build(id: u64) -> Widget {
fn inner() -> u64 { 1 }
Widget { id: id + inner() }
}
}
"#,
)?;
fs::write(root.join("src/other.rs"), "pub fn other() {}\n")?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
EngineService::new(db.clone())
.index_project(project.id, root)
.await?;
let mut conn = db.lock().await;
let files = SourceFile::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let lib_id = files.iter().find(|f| f.path == "src/lib.rs").unwrap().id;
let other_id = files.iter().find(|f| f.path == "src/other.rs").unwrap().id;
let scopes = Scope::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let scope_ids: std::collections::HashSet<u64> = scopes.iter().map(|s| s.id).collect();
assert_eq!(scope_ids.len(), scopes.len(), "scope ids are unique");
for scope in &scopes {
if let Some(parent) = scope.parent_scope_id {
let parent_scope = scopes.iter().find(|s| s.id == parent).unwrap();
assert_eq!(parent_scope.file_id, scope.file_id, "parent in same file");
assert!(
parent_scope.start_byte <= scope.start_byte
&& scope.end_byte <= parent_scope.end_byte
);
}
}
assert!(
scopes.iter().any(|s| s.parent_scope_id.is_some()),
"nested scopes keep parents"
);
let symbols = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let symbol_ids: std::collections::HashSet<u64> = symbols.iter().map(|s| s.id).collect();
assert_eq!(symbol_ids.len(), symbols.len(), "symbol ids are unique");
let widget = symbols
.iter()
.find(|s| s.name == "Widget" && s.kind != "reexport")
.unwrap();
assert_eq!(widget.file_id, lib_id);
let doc = widget.docstring.as_deref().unwrap_or_default();
assert!(
doc.contains("It's a \"quoted\" doc with a 'single' quote and a backslash \\ too."),
"doc: {doc:?}"
);
let build = symbols.iter().find(|s| s.name == "build").unwrap();
assert!(
build
.docstring
.as_deref()
.unwrap_or_default()
.contains("O'Reilly")
);
if let Some(scope_id) = build.scope_id {
assert!(scope_ids.contains(&scope_id), "symbol scope exists");
}
let other = symbols.iter().find(|s| s.name == "other").unwrap();
assert_eq!(other.file_id, other_id);
assert!(other.dependency_package_id.is_none());
drop(conn);
fs::write(
root.join("src/other.rs"),
"pub fn other() {}\npub fn another() {}\n",
)?;
EngineService::new(db.clone())
.index_project(project.id, root)
.await?;
let mut conn = db.lock().await;
let symbols = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?;
let ids: std::collections::HashSet<u64> = symbols.iter().map(|s| s.id).collect();
assert_eq!(ids.len(), symbols.len());
assert!(symbols.iter().any(|s| s.name == "another"));
Ok(())
}
#[tokio::test]
async fn test_oversized_source_files_are_not_indexed() -> anyhow::Result<()> {
use scryer_engine::{WorkspaceScanner, scanner::MAX_SOURCE_FILE_BYTES};
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/small.rs"), "pub fn small() {}\n")?;
let mut big = String::from("pub fn big() {\n");
while (big.len() as u64) <= MAX_SOURCE_FILE_BYTES {
big.push_str(" let _x = 1;\n");
}
big.push_str("}\n");
fs::write(root.join("src/big.rs"), &big)?;
let scanned = WorkspaceScanner::new(root).scan();
assert_eq!(scanned, vec![std::path::PathBuf::from("src/small.rs")]);
Ok(())
}
async fn links_into(db: &ScryerDb, project: u64, name: &str) -> anyhow::Result<(usize, usize)> {
let mut conn = db.lock().await;
let ids: Vec<u64> = Symbol::filter_by_project_id(project)
.exec(&mut *conn)
.await?
.into_iter()
.filter(|s| s.name == name)
.map(|s| s.id)
.collect();
let refs = SymbolReference::filter_by_project_id(project)
.exec(&mut *conn)
.await?
.into_iter()
.filter(|r| ids.contains(&r.symbol_id))
.count();
let edges = CodeGraphEdge::filter_by_project_id(project)
.exec(&mut *conn)
.await?
.into_iter()
.filter(|e| ids.contains(&e.target_symbol_id))
.count();
Ok((refs, edges))
}
#[tokio::test]
async fn test_index_file_keeps_cross_file_references() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/a.rs"), "pub fn helper() {}\n")?;
fs::write(root.join("src/b.rs"), "pub fn caller() { helper(); }\n")?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
service.index_project(project.id, root).await?;
assert_eq!(links_into(&db, project.id, "helper").await?, (1, 1));
fs::write(
root.join("src/b.rs"),
"pub fn caller() { helper(); }\npub fn other() { helper(); }\n",
)?;
service
.index_file(project.id, root, std::path::Path::new("src/b.rs"))
.await?;
assert_eq!(
links_into(&db, project.id, "helper").await?,
(2, 2),
"both callers of `helper` after the single-file re-index"
);
Ok(())
}
async fn unresolved_count(db: &ScryerDb, project: u64) -> anyhow::Result<(usize, usize)> {
let mut conn = db.lock().await;
let rows = UnresolvedReference::filter(UnresolvedReference::fields().project_id().eq(project))
.exec(&mut *conn)
.await?;
let edges = rows.iter().filter(|r| r.kind == "edge").count();
Ok((rows.len() - edges, edges))
}
#[tokio::test]
async fn test_unresolved_reference_links_when_the_definition_is_added_later() -> anyhow::Result<()>
{
for by_file in [true, false] {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/a.rs"), "pub fn caller() { helper(); }\n")?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
let report = service.index_project(project.id, root).await?;
assert_eq!(report.unresolved_references, 1, "by_file={by_file}");
assert_eq!(unresolved_count(&db, project.id).await?, (1, 1));
fs::write(root.join("src/b.rs"), "pub fn helper() {}\n")?;
if by_file {
service
.index_file(project.id, root, std::path::Path::new("src/b.rs"))
.await?;
} else {
let report = service.index_project(project.id, root).await?;
assert_eq!(report.added_files, 1);
assert_eq!(report.unresolved_references, 0);
}
assert_eq!(
links_into(&db, project.id, "helper").await?,
(1, 1),
"by_file={by_file}: the reference and the call edge"
);
assert_eq!(unresolved_count(&db, project.id).await?, (0, 0));
}
Ok(())
}
#[tokio::test]
async fn test_unresolved_references_are_counted_for_the_whole_project() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/a.rs"), "pub fn a() { missing_one(); }\n")?;
fs::write(root.join("src/b.rs"), "pub fn b() { missing_two(); }\n")?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
assert_eq!(
service
.index_project(project.id, root)
.await?
.unresolved_references,
2
);
fs::write(
root.join("src/b.rs"),
"pub fn b() { missing_two(); missing_three(); }\n",
)?;
let report = service.index_project(project.id, root).await?;
assert_eq!(report.modified_files, 1);
assert_eq!(report.unresolved_references, 3, "a's one plus b's two");
assert_eq!(unresolved_count(&db, project.id).await?, (3, 3));
Ok(())
}
async fn symbol_ids(db: &ScryerDb, project: u64) -> anyhow::Result<BTreeMap<String, Vec<u64>>> {
let mut conn = db.lock().await;
let mut symbols = Symbol::filter_by_project_id(project)
.exec(&mut *conn)
.await?;
symbols.sort_by_key(|s| (s.file_id, s.start_byte));
let mut out: BTreeMap<String, Vec<u64>> = BTreeMap::new();
for s in symbols {
out.entry(s.qualified_name).or_default().push(s.id);
}
Ok(out)
}
async fn assert_no_dangling_links(db: &ScryerDb, project: u64) -> anyhow::Result<()> {
let mut conn = db.lock().await;
let ids: HashSet<u64> = Symbol::filter_by_project_id(project)
.exec(&mut *conn)
.await?
.into_iter()
.map(|s| s.id)
.collect();
for r in SymbolReference::filter_by_project_id(project)
.exec(&mut *conn)
.await?
{
assert!(ids.contains(&r.symbol_id), "dangling reference {r:?}");
}
for e in CodeGraphEdge::filter_by_project_id(project)
.exec(&mut *conn)
.await?
{
assert!(
ids.contains(&e.source_symbol_id) && ids.contains(&e.target_symbol_id),
"dangling edge {e:?}"
);
}
Ok(())
}
#[tokio::test]
async fn test_unchanged_symbols_keep_their_ids_across_a_re_index() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(
root.join("src/a.rs"),
"pub fn alpha() -> u32 { 1 }\npub fn beta() -> u32 { alpha() }\n",
)?;
fs::write(
root.join("src/b.rs"),
"pub fn user() { beta(); alpha(); }\n",
)?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
service.index_project(project.id, root).await?;
let before = symbol_ids(&db, project.id).await?;
assert_eq!(links_into(&db, project.id, "alpha").await?, (2, 2));
fs::write(
root.join("src/a.rs"),
"// header\n// more header\npub fn alpha() -> u32 { 2 }\npub fn beta() -> u32 { alpha() }\npub fn gamma() {}\n",
)?;
service
.index_file(project.id, root, std::path::Path::new("src/a.rs"))
.await?;
let after = symbol_ids(&db, project.id).await?;
for (name, ids) in &before {
assert_eq!(after.get(name), Some(ids), "{name} kept its id");
}
assert_eq!(after.len(), before.len() + 1, "only `gamma` was inserted");
{
let mut conn = db.lock().await;
let alpha = Symbol::filter_by_project_id(project.id)
.exec(&mut *conn)
.await?
.into_iter()
.find(|s| s.name == "alpha")
.unwrap();
assert_eq!(alpha.start_line, 3, "the row follows the edit");
}
assert_eq!(links_into(&db, project.id, "alpha").await?, (2, 2));
assert_eq!(links_into(&db, project.id, "beta").await?, (1, 1));
assert_no_dangling_links(&db, project.id).await?;
Ok(())
}
#[tokio::test]
async fn test_duplicate_symbols_keep_a_stable_pairing() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
let variants = |header: &str| {
format!(
"{header}#[cfg(unix)]\npub fn dup() -> u32 {{ 1 }}\n#[cfg(not(unix))]\npub fn dup() -> u32 {{ 2 }}\n"
)
};
fs::write(root.join("src/a.rs"), variants(""))?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
service.index_project(project.id, root).await?;
let before = symbol_ids(&db, project.id).await?;
assert_eq!(before.values().map(Vec::len).max(), Some(2), "{before:?}");
fs::write(root.join("src/a.rs"), variants("// header\n"))?;
service
.index_file(project.id, root, std::path::Path::new("src/a.rs"))
.await?;
assert_eq!(symbol_ids(&db, project.id).await?, before);
Ok(())
}
#[tokio::test]
async fn test_links_into_a_removed_symbol_come_back_when_it_is_defined_again() -> anyhow::Result<()>
{
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
let with_beta = "pub fn alpha() {}\npub fn beta() {}\n";
fs::write(root.join("src/a.rs"), with_beta)?;
fs::write(root.join("src/b.rs"), "pub fn user() { beta(); }\n")?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
service.index_project(project.id, root).await?;
assert_eq!(links_into(&db, project.id, "beta").await?, (1, 1));
let a = std::path::Path::new("src/a.rs");
fs::write(root.join("src/a.rs"), "pub fn alpha() {}\n")?;
service.index_file(project.id, root, a).await?;
assert_eq!(links_into(&db, project.id, "beta").await?, (0, 0));
assert_eq!(unresolved_count(&db, project.id).await?, (1, 1));
assert_no_dangling_links(&db, project.id).await?;
fs::write(root.join("src/a.rs"), with_beta)?;
service.index_file(project.id, root, a).await?;
assert_eq!(links_into(&db, project.id, "beta").await?, (1, 1));
assert_eq!(unresolved_count(&db, project.id).await?, (0, 0));
assert_no_dangling_links(&db, project.id).await?;
Ok(())
}
#[tokio::test]
async fn test_links_into_a_deleted_file_come_back_when_it_does() -> anyhow::Result<()> {
let temp = tempdir()?;
let root = temp.path();
fs::create_dir_all(root.join("src"))?;
fs::write(root.join("src/a.rs"), "pub fn beta() {}\n")?;
fs::write(root.join("src/b.rs"), "pub fn user() { beta(); }\n")?;
let db = ScryerDb::new_in_memory().await?;
let project = test_project(&db, root).await?;
let service = EngineService::new(db.clone());
service.index_project(project.id, root).await?;
let a = std::path::Path::new("src/a.rs");
service.remove_file(project.id, a).await?;
assert_eq!(unresolved_count(&db, project.id).await?, (1, 1));
assert_no_dangling_links(&db, project.id).await?;
service.index_file(project.id, root, a).await?;
assert_eq!(links_into(&db, project.id, "beta").await?, (1, 1));
assert_eq!(unresolved_count(&db, project.id).await?, (0, 0));
Ok(())
}