use std::fs;
use std::time::Duration;
use tempfile::tempdir;
use scryer_db::{Project, Scope, ScryerDb, SourceFile, Symbol};
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_loads_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 engine = restarted.pool().get_or_create(project.id).await;
let engine = engine.lock().await;
assert!(
engine.files.contains_key(std::path::Path::new("src/a.rs")),
"unchanged file must be in the graph so the modified file resolves against it"
);
assert!(engine.files.contains_key(std::path::Path::new("src/b.rs")));
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(())
}