use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tracing::{info, warn};
use crate::discover::Walker;
use crate::index::error::{IndexError, Result};
use crate::index::hash::compute_file_hash;
use crate::index::incremental::FileDiff;
use crate::model::{new_file_id, Language, Node, NodeLabel};
use crate::parse::parallel::RamFirstSources;
use crate::storage::{Repository, StorageError};
#[cfg(feature = "cache")]
use crate::cache::CacheStore;
use super::phases::{
ConfidencePhase, LoadOutput, LoadPhase, ParsePhase, ResolvePhase, ScanInput, ScanPhase,
ScopeResolutionPhase,
};
use super::pipeline_dag::{Phase, Pipeline as DagPipeline, PipelineCtx};
pub(crate) const DEFAULT_MAX_RETRIES: u32 = 3;
pub(crate) fn with_retry<T, F>(max_retries: u32, mut f: F) -> Result<T>
where
F: FnMut() -> Result<T>,
{
let mut delay_ms = 100u64;
for attempt in 0..=max_retries {
match f() {
Ok(v) => return Ok(v),
Err(IndexError::Storage(StorageError::Query(ref msg)))
if msg.contains("locked") || msg.contains("Lock") =>
{
if attempt < max_retries {
warn!(
attempt = attempt + 1,
max_retries, delay_ms, "database locked, retrying"
);
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
delay_ms *= 2;
continue;
}
return Err(IndexError::DatabaseLocked);
}
Err(e) => return Err(e),
}
}
Err(IndexError::DatabaseLocked)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexResult {
pub project_id: String,
pub files_indexed: usize,
pub files_skipped: usize,
pub nodes_created: usize,
pub edges_created: usize,
pub duration_ms: u64,
}
impl IndexResult {
#[must_use]
pub fn new(
project_id: impl Into<String>,
files_indexed: usize,
files_skipped: usize,
nodes_created: usize,
edges_created: usize,
duration_ms: u64,
) -> Self {
Self {
project_id: project_id.into(),
files_indexed,
files_skipped,
nodes_created,
edges_created,
duration_ms,
}
}
#[must_use]
pub fn empty(project_id: impl Into<String>) -> Self {
Self::new(project_id, 0, 0, 0, 0, 0)
}
}
pub struct IndexFacade {
db_path: PathBuf,
#[cfg(feature = "cache")]
cache: Option<Arc<dyn CacheStore>>,
}
impl IndexFacade {
pub fn new(db_path: &Path) -> Result<Self> {
Ok(Self {
db_path: db_path.to_path_buf(),
#[cfg(feature = "cache")]
cache: None,
})
}
#[cfg(feature = "cache")]
#[must_use]
pub fn with_cache(mut self, cache: Arc<dyn CacheStore>) -> Self {
self.cache = Some(cache);
self
}
pub fn index(&self, path: &Path, project_name: &str, force: bool) -> Result<IndexResult> {
let repository = with_retry(DEFAULT_MAX_RETRIES, || {
Repository::open(&self.db_path).map_err(IndexError::from)
})?;
let pipeline = Pipeline::new(repository);
let result = pipeline.run(path, project_name, force)?;
#[cfg(feature = "cache")]
if let Some(ref cache) = self.cache {
cache.invalidate_all();
}
Ok(result)
}
pub fn index_incremental(
&self,
path: &Path,
project_name: &str,
force: bool,
) -> Result<IndexResult> {
self.index(path, project_name, force)
}
pub fn index_ram_first(
&self,
path: &Path,
project_name: &str,
force: bool,
) -> Result<IndexResult> {
if !path.exists() {
return Err(IndexError::PathNotFound(path.display().to_string()));
}
let disk_files = Walker::new(path).discover().map_err(IndexError::from)?;
let mut compressed: RamFirstSources =
std::collections::HashMap::with_capacity(disk_files.len());
for file in &disk_files {
match std::fs::read(&file.path) {
Ok(bytes) => {
compressed.insert(file.path.clone(), lz4_flex::compress_prepend_size(&bytes));
}
Err(err) => {
warn!(
file = %file.relative_path,
error = %err,
"RAM-first: failed to read file for compression, will fall back to disk read in parse phase"
);
}
}
}
let repository = with_retry(DEFAULT_MAX_RETRIES, || {
Repository::open(&self.db_path).map_err(IndexError::from)
})?;
let pipeline = Pipeline::new(repository);
let result = pipeline.run_ram_first(path, project_name, force, compressed)?;
#[cfg(feature = "cache")]
if let Some(ref cache) = self.cache {
cache.invalidate_all();
}
Ok(result)
}
}
pub struct Pipeline {
repository: Arc<Repository>,
}
impl Pipeline {
#[must_use]
pub fn new(repository: Repository) -> Self {
Self {
repository: Arc::new(repository),
}
}
pub fn run(&self, path: &Path, project_name: &str, force: bool) -> Result<IndexResult> {
self.run_inner(path, project_name, force, None)
}
pub fn run_ram_first(
&self,
path: &Path,
project_name: &str,
force: bool,
compressed: RamFirstSources,
) -> Result<IndexResult> {
self.run_inner(path, project_name, force, Some(compressed))
}
fn run_inner(
&self,
path: &Path,
project_name: &str,
force: bool,
compressed: Option<RamFirstSources>,
) -> Result<IndexResult> {
let start = Instant::now();
info!(
event = "index_started",
project = %project_name,
path = %path.display(),
ram_first = compressed.is_some(),
"indexing started"
);
if !path.exists() {
return Err(IndexError::PathNotFound(path.display().to_string()));
}
let mut ctx = PipelineCtx::new();
ctx.insert(
ScanPhase::NAME,
ScanInput {
path: path.to_path_buf(),
project_name: project_name.to_string(),
force,
start,
},
);
ctx.insert(ParsePhase::NAME, ());
ctx.insert(ScopeResolutionPhase::NAME, ());
ctx.insert(ResolvePhase::NAME, ());
ctx.insert(ConfidencePhase::NAME, ());
ctx.insert(LoadPhase::NAME, ());
let mut dag = DagPipeline::new();
dag.register(ScanPhase {
repo: self.repository.clone(),
})
.map_err(IndexError::from)?;
dag.register(ParsePhase {
ram_first_compressed: compressed,
})
.map_err(IndexError::from)?;
dag.register(ScopeResolutionPhase)
.map_err(IndexError::from)?;
dag.register(ResolvePhase).map_err(IndexError::from)?;
dag.register(ConfidencePhase).map_err(IndexError::from)?;
dag.register(LoadPhase {
repo: self.repository.clone(),
})
.map_err(IndexError::from)?;
dag.run(&mut ctx).map_err(IndexError::from)?;
let load_output = ctx.remove::<LoadOutput>(LoadPhase::NAME).ok_or_else(|| {
IndexError::Storage(StorageError::Query(
"load phase did not produce output".to_string(),
))
})?;
if let Err(err) = self
.repository
.connection()
.execute("CALL force_checkpoint_on_close=true;")
{
warn!(error = %err, "failed to enable force_checkpoint_on_close");
}
if let Err(err) = self.repository.connection().execute("CHECKPOINT;") {
warn!(error = %err, "post-index checkpoint failed; data may not persist if other DB handles are open");
}
Ok(load_output.index_result)
}
}
pub(crate) fn build_file_nodes(diff: &FileDiff, project_id: &str) -> Vec<Node> {
let mut nodes = Vec::new();
for file in diff.changed.iter().chain(diff.added.iter()) {
let hash = match compute_file_hash(&file.path) {
Ok(h) => h,
Err(err) => {
warn!(
file = %file.relative_path,
error = %err,
"failed to hash file, skipping File node"
);
continue;
}
};
let language = file.language.unwrap_or_else(|| Language::all()[0]);
let line_count = line_count_of(&file.path).unwrap_or(0);
let node = Node::builder(
NodeLabel::File,
file.relative_path.clone(),
file.relative_path.clone(),
)
.id(new_file_id())
.project(project_id)
.file_path(&file.relative_path)
.language(language)
.properties(serde_json::json!({
"hash": hash,
"lineCount": line_count,
}))
.build();
nodes.push(node);
}
nodes
}
pub(crate) fn line_count_of(path: &Path) -> Option<u32> {
let content = std::fs::read_to_string(path).ok()?;
Some(content.lines().count() as u32)
}
pub(crate) fn now_unix_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(all(test, feature = "lsp"))]
pub(crate) fn enhance_with_lsp(
provider: &dyn crate::lsp::LspProvider,
nodes: &mut [crate::model::Node],
workspace: &Path,
) -> Result<()> {
use crate::lsp::LspError;
use crate::model::NodeLabel;
if let Err(err) = provider.start(workspace) {
warn!(
error = %err,
"LSP server start failed, degrading to pure tree-sitter extraction"
);
return Ok(());
}
for node in nodes.iter_mut() {
let is_target = matches!(node.label, NodeLabel::Function | NodeLabel::Method);
let is_rust = node
.file_path
.as_deref()
.map(|p| p.ends_with(".rs"))
.unwrap_or(false);
if !is_target || !is_rust {
continue;
}
let file_path = match node.file_path.as_deref() {
Some(p) => Path::new(p),
None => continue,
};
let line = node.start_line.unwrap_or(0);
match provider.hover(file_path, line, 0) {
Ok(Some(hover)) => {
if let Some(text) = crate::lsp::extract_hover_text(&hover) {
write_semantic_type(node, text);
}
}
Ok(None) => {}
Err(LspError::Timeout(_)) | Err(LspError::Communication(_)) => {
}
Err(LspError::ServerStart(_)) => {
}
}
}
if let Err(err) = provider.shutdown() {
warn!(error = %err, "LSP server shutdown failed (non-fatal)");
}
Ok(())
}
#[cfg(all(test, feature = "lsp"))]
fn write_semantic_type(node: &mut crate::model::Node, text: String) {
if !node.properties.is_object() {
if node.properties.is_null() {
node.properties = serde_json::Value::Object(serde_json::Map::new());
} else {
return;
}
}
if let Some(obj) = node.properties.as_object_mut() {
obj.insert("semantic_type".to_string(), serde_json::Value::String(text));
}
}
#[cfg(all(test, feature = "lang-rust"))]
mod tests {
use super::*;
use crate::discover::FileInfo;
use crate::model::Language;
use crate::test_log_capture::capture_tracing;
use std::fs;
use std::sync::Arc;
use tempfile::TempDir;
fn write_file(dir: &Path, rel: &str, content: &str) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn fresh_db_path() -> PathBuf {
let dir = TempDir::new().unwrap();
let path = dir.path().join("testdb");
std::mem::forget(dir);
path
}
#[test]
fn index_result_new_sets_all_fields() {
let r = IndexResult::new("proj_1", 10, 5, 100, 50, 1234);
assert_eq!(r.project_id, "proj_1");
assert_eq!(r.files_indexed, 10);
assert_eq!(r.files_skipped, 5);
assert_eq!(r.nodes_created, 100);
assert_eq!(r.edges_created, 50);
assert_eq!(r.duration_ms, 1234);
}
#[test]
fn index_result_empty_zeros_everything() {
let r = IndexResult::empty("proj_x");
assert_eq!(r.project_id, "proj_x");
assert_eq!(r.files_indexed, 0);
assert_eq!(r.files_skipped, 0);
assert_eq!(r.nodes_created, 0);
assert_eq!(r.edges_created, 0);
assert_eq!(r.duration_ms, 0);
}
#[test]
fn index_result_clone_is_equal() {
let r = IndexResult::new("p", 1, 2, 3, 4, 5);
assert_eq!(r, r.clone());
}
#[test]
fn index_result_debug_contains_fields() {
let r = IndexResult::new("proj_1", 10, 5, 100, 50, 1234);
let s = format!("{r:?}");
assert!(s.contains("proj_1"));
assert!(s.contains("IndexResult"));
}
#[cfg(all(feature = "lang-c", feature = "lang-fortran"))]
#[test]
fn ac_index_001_indexes_c_rust_fortran_files() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "main.rs", "fn main() { helper(); }\n");
write_file(root, "util.c", "int util(void) { return 42; }\n");
write_file(
root,
"math.f90",
"subroutine math_sub()\nend subroutine math_sub\n",
);
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade
.index(root, "demo", false)
.expect("index should succeed");
assert!(result.files_indexed > 0, "should index files: {result:?}");
assert!(result.nodes_created > 0, "should create nodes: {result:?}");
assert!(result.duration_ms < u64::MAX, "duration should be recorded");
assert!(!result.project_id.is_empty(), "project_id should be set");
}
#[test]
fn ac_index_002_incremental_only_parses_changed_file() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "a.rs", "fn a() {}\n");
write_file(root, "b.rs", "fn b() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let first = facade.index(root, "demo", false).expect("first index");
assert_eq!(first.files_indexed, 2, "first run parses both files");
assert_eq!(first.files_skipped, 0, "nothing to skip on first run");
let second = facade
.index_incremental(root, "demo", false)
.expect("second index");
assert_eq!(
second.files_skipped, 2,
"BR-INDEX-001: unchanged files skipped"
);
assert_eq!(second.files_indexed, 0, "no files to re-parse");
write_file(root, "a.rs", "fn a() { /* modified */ }\n");
let third = facade
.index_incremental(root, "demo", false)
.expect("third index");
assert_eq!(third.files_indexed, 1, "only the modified file is parsed");
assert_eq!(third.files_skipped, 1, "the other file is skipped");
}
#[test]
fn ac_index_003_multiple_projects_coexist() {
let tmp_a = TempDir::new().unwrap();
let tmp_b = TempDir::new().unwrap();
write_file(tmp_a.path(), "a.rs", "fn alpha() {}\n");
write_file(tmp_b.path(), "b.rs", "fn beta() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result_a = facade
.index(tmp_a.path(), "project_a", false)
.expect("index A");
let result_b = facade
.index(tmp_b.path(), "project_b", false)
.expect("index B");
assert!(result_a.files_indexed > 0);
assert!(result_b.files_indexed > 0);
let repo = Repository::open(&db_path).expect("repo");
let projects = repo.list_projects().expect("list_projects");
assert_eq!(projects.len(), 2, "AC-INDEX-003: both projects coexist");
let names: Vec<String> = projects.iter().map(|p| p.name.clone()).collect();
assert!(names.contains(&"project_a".to_string()));
assert!(names.contains(&"project_b".to_string()));
}
#[test]
fn ac_index_005_force_re_parses_all_files() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "a.rs", "fn a() {}\n");
write_file(root, "b.rs", "fn b() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let first = facade.index(root, "demo", false).expect("first index");
assert_eq!(first.files_indexed, 2);
assert_eq!(first.files_skipped, 0);
let forced = facade
.index_incremental(root, "demo", true)
.expect("forced index");
assert_eq!(
forced.files_indexed, 2,
"AC-INDEX-005: --force re-parses all files"
);
assert_eq!(forced.files_skipped, 0, "force must not skip any files");
}
#[test]
fn path_not_found_returns_error() {
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(Path::new("/nonexistent/path/xyz"), "demo", false);
assert!(result.is_err(), "path not found should error");
let err = result.unwrap_err();
assert!(
matches!(err, IndexError::PathNotFound(_)),
"expected PathNotFound, got {err:?}"
);
assert_eq!(err.exit_code(), 1, "PRD §4.1.6: path not found → exit 1");
}
#[test]
fn empty_directory_indexes_zero_files() {
let tmp = TempDir::new().unwrap();
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(tmp.path(), "empty", false).expect("index");
assert_eq!(result.files_indexed, 0, "empty dir → 0 files indexed");
assert_eq!(result.files_skipped, 0);
assert!(result.duration_ms < u64::MAX, "duration should be recorded");
}
#[test]
fn duration_is_recorded() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(tmp.path(), "demo", false).expect("index");
assert!(result.duration_ms < u64::MAX);
}
#[test]
fn re_index_reuses_project_id() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let first = facade.index(tmp.path(), "demo", false).expect("first");
let second = facade
.index_incremental(tmp.path(), "demo", false)
.expect("second");
assert_eq!(
first.project_id, second.project_id,
"re-index should reuse the project id"
);
}
#[test]
fn definition_node_id_equals_qualified_name() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "main.rs", "fn helper() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(root, "demo", false).expect("index");
let repo = Repository::open(&db_path).expect("repo");
let funcs = repo
.query_functions(&result.project_id)
.expect("query_functions");
assert!(!funcs.is_empty(), "should have at least one function");
for func in &funcs {
assert_eq!(
func.id, func.qualified_name,
"Function node id must equal qualified_name for trace to work"
);
}
}
#[test]
fn definition_node_file_path_is_relative() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "main.rs", "fn helper() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(root, "demo", false).expect("index");
let repo = Repository::open(&db_path).expect("repo");
let funcs = repo
.query_functions(&result.project_id)
.expect("query_functions");
assert!(!funcs.is_empty(), "should have at least one function");
for func in &funcs {
assert!(
!func.file_path.starts_with('/') && !func.file_path.contains(':'),
"filePath should be relative, got: {}",
func.file_path
);
}
}
#[test]
fn incremental_reindex_no_duplicate_nodes() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let first = facade.index(root, "demo", false).expect("first index");
assert_eq!(first.files_indexed, 1);
write_file(root, "a.rs", "fn a() { /* modified */ }\n");
let second = facade
.index_incremental(root, "demo", false)
.expect("second index");
assert_eq!(second.files_indexed, 1);
let repo = Repository::open(&db_path).expect("repo");
let funcs = repo
.query_functions(&second.project_id)
.expect("query_functions");
assert_eq!(
funcs.len(),
1,
"should have exactly one function (no duplicates after re-index)"
);
}
#[test]
fn pipeline_new_wraps_repository() {
let repo = Repository::in_memory().expect("repo");
let _pipeline = Pipeline::new(repo);
}
#[test]
fn build_file_nodes_creates_file_node_per_changed_or_added_file() {
let tmp = TempDir::new().unwrap();
let f1 = FileInfo {
path: tmp.path().join("a.rs"),
relative_path: "a.rs".to_string(),
language: Some(Language::Rust),
size: 0,
};
fs::write(&f1.path, "fn a() {}\n").unwrap();
let f2 = FileInfo {
path: tmp.path().join("b.rs"),
relative_path: "b.rs".to_string(),
language: Some(Language::Rust),
size: 0,
};
fs::write(&f2.path, "fn b() {}\n").unwrap();
let mut diff = FileDiff::new();
diff.changed.push(f1);
diff.added.push(f2);
let nodes = build_file_nodes(&diff, "proj");
assert_eq!(nodes.len(), 2, "one File node per changed/added file");
assert!(nodes.iter().all(|n| n.label == NodeLabel::File));
assert!(nodes.iter().all(|n| n.project == "proj"));
for node in &nodes {
let hash = node.properties.get("hash").and_then(|v| v.as_str());
assert!(hash.is_some(), "File node should carry a hash");
assert_eq!(hash.unwrap().len(), 64, "hash should be 64 hex chars");
}
}
#[test]
fn build_file_nodes_skips_missing_files() {
let missing = FileInfo {
path: PathBuf::from("/nonexistent/missing.rs"),
relative_path: "missing.rs".to_string(),
language: Some(Language::Rust),
size: 0,
};
let mut diff = FileDiff::new();
diff.added.push(missing);
let nodes = build_file_nodes(&diff, "proj");
assert!(nodes.is_empty(), "missing file should produce no File node");
}
#[test]
fn build_file_nodes_empty_diff_returns_empty() {
let diff = FileDiff::new();
let nodes = build_file_nodes(&diff, "proj");
assert!(nodes.is_empty());
}
#[test]
fn line_count_of_counts_lines() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("a.rs");
fs::write(&path, "line1\nline2\nline3\n").unwrap();
assert_eq!(line_count_of(&path), Some(3));
}
#[test]
fn line_count_of_empty_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("empty.rs");
fs::write(&path, "").unwrap();
assert_eq!(line_count_of(&path), Some(0));
}
#[test]
fn line_count_of_missing_file_returns_none() {
assert!(line_count_of(Path::new("/nonexistent/missing.rs")).is_none());
}
#[test]
fn now_unix_seconds_is_positive() {
let ts = now_unix_seconds();
assert!(ts > 0, "unix timestamp should be positive: {ts}");
}
#[test]
fn index_facade_new_succeeds() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("testdb");
let facade = IndexFacade::new(&db_path);
assert!(facade.is_ok(), "facade creation should succeed");
}
#[test]
fn re_index_detects_deleted_files() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "a.rs", "fn a() {}\n");
write_file(root, "b.rs", "fn b() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let first = facade.index(root, "demo", false).expect("first");
assert_eq!(first.files_indexed, 2);
fs::remove_file(root.join("b.rs")).unwrap();
let second = facade
.index_incremental(root, "demo", false)
.expect("second");
assert_eq!(second.files_skipped, 1, "a.rs unchanged → skipped");
assert_eq!(second.files_indexed, 0, "no new/changed files to parse");
}
#[test]
fn pipeline_indexes_nested_directories() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "main.rs", "fn main() {}\n");
write_file(root, "src/lib.rs", "fn lib_fn() {}\n");
write_file(root, "src/sub/mod.rs", "fn mod_fn() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(root, "nested", false).expect("index");
assert_eq!(result.files_indexed, 3, "all nested files indexed");
}
#[test]
fn pipeline_persists_nodes_to_db() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "main.rs", "fn main() { helper(); }\nfn helper() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(root, "demo", false).expect("index");
assert!(result.nodes_created > 0);
let repo = Repository::open(&db_path).expect("repo");
let functions = repo.query_functions(&result.project_id).expect("query");
assert!(
!functions.is_empty(),
"functions should be persisted: {functions:?}"
);
let names: Vec<String> = functions.iter().map(|f| f.name.clone()).collect();
assert!(
names.contains(&"main".to_string()),
"main should be persisted"
);
assert!(
names.contains(&"helper".to_string()),
"helper should be persisted"
);
}
#[test]
fn pipeline_persists_project_node() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade
.index(tmp.path(), "my_project", false)
.expect("index");
let repo = Repository::open(&db_path).expect("repo");
let project = repo
.get_project(&result.project_id)
.expect("get_project")
.expect("project should exist");
assert_eq!(project.name, "my_project");
assert_eq!(project.id, result.project_id);
}
#[test]
fn pipeline_continues_after_parse_failure() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(root, "good.rs", "fn good() {}\n");
write_file(root, "empty.rs", "");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index(root, "demo", false).expect("index");
assert_eq!(result.files_indexed, 2);
}
#[test]
fn index_result_files_indexed_plus_skipped_le_disk_files() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
write_file(tmp.path(), "b.rs", "fn b() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let _first = facade.index(tmp.path(), "demo", false).expect("first");
let second = facade
.index_incremental(tmp.path(), "demo", false)
.expect("second");
assert_eq!(second.files_indexed + second.files_skipped, 2);
}
#[test]
fn log_001_index_started_event_emitted() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let captured = capture_tracing(|| {
facade
.index(tmp.path(), "log_001_started", false)
.expect("index");
});
assert!(
captured.contains("index_started"),
"LOG-001: index_started event should be emitted, got: {captured:?}"
);
assert!(
captured.contains("log_001_started"),
"index_started should carry the project name"
);
}
#[test]
fn log_001_index_completed_event_emitted() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let captured = capture_tracing(|| {
facade
.index(tmp.path(), "log_001_completed", false)
.expect("index");
});
assert!(
captured.contains("index_completed"),
"LOG-001: index_completed event should be emitted, got: {captured:?}"
);
assert!(
captured.contains("files_indexed"),
"index_completed should carry files_indexed field"
);
assert!(
captured.contains("duration_ms"),
"index_completed should carry duration_ms field"
);
}
#[test]
fn log_006_performance_event_emitted() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let captured = capture_tracing(|| {
facade
.index(tmp.path(), "log_006_perf", false)
.expect("index");
});
assert!(
captured.contains("performance"),
"LOG-006: performance event should be emitted, got: {captured:?}"
);
assert!(
captured.contains("files_per_second"),
"performance event should carry files_per_second field"
);
}
use std::sync::atomic::{AtomicU32, Ordering};
fn lock_error() -> IndexError {
IndexError::Storage(StorageError::Query("database is locked".to_string()))
}
#[test]
fn with_retry_returns_value_on_first_success() {
let calls = AtomicU32::new(0);
let result: Result<u32> = with_retry(3, || {
calls.fetch_add(1, Ordering::SeqCst);
Ok(42)
});
assert_eq!(result.unwrap(), 42);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"should not retry on success"
);
}
#[test]
fn with_retry_retries_on_lock_then_succeeds() {
let calls = AtomicU32::new(0);
let result: Result<u32> = with_retry(3, || {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Err(lock_error())
} else {
Ok(7)
}
});
assert_eq!(result.unwrap(), 7);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"should retry once then succeed"
);
}
#[test]
fn with_retry_returns_database_locked_after_all_retries_exhausted() {
let calls = AtomicU32::new(0);
let max_retries = 3u32;
let result: Result<u32> = with_retry(max_retries, || {
calls.fetch_add(1, Ordering::SeqCst);
Err(lock_error())
});
assert!(
matches!(result, Err(IndexError::DatabaseLocked)),
"should return DatabaseLocked after exhausting retries, got: {result:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
max_retries + 1,
"should attempt max_retries+1 times"
);
}
#[test]
fn with_retry_propagates_non_lock_error_immediately() {
let calls = AtomicU32::new(0);
let result: Result<u32> = with_retry(3, || {
calls.fetch_add(1, Ordering::SeqCst);
Err(IndexError::Parse("syntax error".to_string()))
});
assert!(
matches!(result, Err(IndexError::Parse(_))),
"should propagate non-lock error immediately, got: {result:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"should not retry on non-lock error"
);
}
#[cfg(feature = "lsp")]
#[allow(dead_code)] enum MockHoverBehavior {
Ok(Option<lsp_types::Hover>),
Timeout,
Communication,
}
#[cfg(feature = "lsp")]
struct MockLspProvider {
start_fails: bool,
hover_behavior: MockHoverBehavior,
start_calls: AtomicU32,
hover_calls: AtomicU32,
shutdown_calls: AtomicU32,
}
#[cfg(feature = "lsp")]
impl crate::lsp::LspProvider for MockLspProvider {
fn start(&self, _workspace: &Path) -> std::result::Result<(), crate::lsp::LspError> {
self.start_calls.fetch_add(1, Ordering::SeqCst);
if self.start_fails {
Err(crate::lsp::LspError::ServerStart(
"mock: server unavailable".into(),
))
} else {
Ok(())
}
}
fn definition(
&self,
_file: &Path,
_line: u32,
_col: u32,
) -> std::result::Result<Option<lsp_types::Location>, crate::lsp::LspError> {
Ok(None)
}
fn type_definition(
&self,
_file: &Path,
_line: u32,
_col: u32,
) -> std::result::Result<Option<lsp_types::Location>, crate::lsp::LspError> {
Ok(None)
}
fn hover(
&self,
_file: &Path,
_line: u32,
_col: u32,
) -> std::result::Result<Option<lsp_types::Hover>, crate::lsp::LspError> {
self.hover_calls.fetch_add(1, Ordering::SeqCst);
match &self.hover_behavior {
MockHoverBehavior::Ok(h) => Ok(h.clone()),
MockHoverBehavior::Timeout => Err(crate::lsp::LspError::Timeout(
crate::lsp::REQUEST_TIMEOUT_MS,
)),
MockHoverBehavior::Communication => Err(crate::lsp::LspError::Communication(
"mock: channel closed".into(),
)),
}
}
fn shutdown(&self) -> std::result::Result<(), crate::lsp::LspError> {
self.shutdown_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[cfg(feature = "lsp")]
fn make_hover(text: &str) -> lsp_types::Hover {
use lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind};
Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: text.to_string(),
}),
range: None,
}
}
#[cfg(feature = "lsp")]
fn make_function_node(name: &str, file_path: &str, start_line: u32) -> Node {
Node::builder(NodeLabel::Function, name, format!("proj.{name}"))
.file_path(file_path)
.start_line(start_line)
.language(Language::Rust)
.project("proj")
.build()
}
#[test]
#[cfg(feature = "lsp")]
fn lsp_enhance_skips_on_server_start_failure() {
let mock = MockLspProvider {
start_fails: true,
hover_behavior: MockHoverBehavior::Ok(Some(make_hover("fn foo()"))),
start_calls: AtomicU32::new(0),
hover_calls: AtomicU32::new(0),
shutdown_calls: AtomicU32::new(0),
};
let mut nodes = vec![make_function_node("foo", "src/main.rs", 0)];
let result = enhance_with_lsp(&mock, &mut nodes, Path::new("/workspace"));
assert!(
result.is_ok(),
"ServerStart failure must NOT abort enhancement: {:?}",
result.err()
);
assert_eq!(
mock.start_calls.load(Ordering::SeqCst),
1,
"start must be called exactly once"
);
assert_eq!(
mock.hover_calls.load(Ordering::SeqCst),
0,
"hover must NOT be called when start fails"
);
assert_eq!(
mock.shutdown_calls.load(Ordering::SeqCst),
0,
"shutdown must NOT be called when start fails (nothing to shut down)"
);
assert!(
nodes[0].properties.get("semantic_type").is_none(),
"node must NOT be enhanced when start fails"
);
}
#[test]
#[cfg(feature = "lsp")]
fn lsp_enhance_skips_on_query_timeout() {
let mock = MockLspProvider {
start_fails: false,
hover_behavior: MockHoverBehavior::Timeout,
start_calls: AtomicU32::new(0),
hover_calls: AtomicU32::new(0),
shutdown_calls: AtomicU32::new(0),
};
let mut nodes = vec![make_function_node("foo", "src/main.rs", 0)];
let result = enhance_with_lsp(&mock, &mut nodes, Path::new("/workspace"));
assert!(
result.is_ok(),
"Timeout must NOT abort enhancement: {:?}",
result.err()
);
assert_eq!(
mock.hover_calls.load(Ordering::SeqCst),
1,
"hover must be attempted for the symbol"
);
assert_eq!(
mock.shutdown_calls.load(Ordering::SeqCst),
1,
"shutdown must be called after the loop even if all symbols timed out"
);
assert!(
nodes[0].properties.get("semantic_type").is_none(),
"timed-out symbol must NOT be enhanced"
);
}
#[test]
#[cfg(feature = "lsp")]
fn lsp_enhance_writes_semantic_type() {
let signature = "fn add(a: i32, b: i32) -> i32";
let mock = MockLspProvider {
start_fails: false,
hover_behavior: MockHoverBehavior::Ok(Some(make_hover(signature))),
start_calls: AtomicU32::new(0),
hover_calls: AtomicU32::new(0),
shutdown_calls: AtomicU32::new(0),
};
let mut nodes = vec![make_function_node("add", "src/lib.rs", 0)];
let result = enhance_with_lsp(&mock, &mut nodes, Path::new("/workspace"));
assert!(
result.is_ok(),
"enhancement should succeed: {:?}",
result.err()
);
assert_eq!(
mock.hover_calls.load(Ordering::SeqCst),
1,
"hover must be called for the Function node"
);
assert_eq!(
mock.shutdown_calls.load(Ordering::SeqCst),
1,
"shutdown must be called after enhancement"
);
let sem = nodes[0]
.properties
.get("semantic_type")
.and_then(|v| v.as_str());
assert_eq!(
sem,
Some(signature),
"semantic_type must be the hover text signature"
);
}
#[test]
#[cfg(feature = "lsp")]
fn lsp_enhance_noop_when_no_rust_nodes() {
let mock = MockLspProvider {
start_fails: false,
hover_behavior: MockHoverBehavior::Ok(Some(make_hover("fn foo()"))),
start_calls: AtomicU32::new(0),
hover_calls: AtomicU32::new(0),
shutdown_calls: AtomicU32::new(0),
};
let mut nodes: Vec<Node> = vec![];
let result = enhance_with_lsp(&mock, &mut nodes, Path::new("/workspace"));
assert!(
result.is_ok(),
"empty node list must return Ok: {:?}",
result.err()
);
assert_eq!(
mock.hover_calls.load(Ordering::SeqCst),
0,
"hover must NOT be called for an empty node list"
);
assert_eq!(
mock.shutdown_calls.load(Ordering::SeqCst),
1,
"shutdown must still be called even with no nodes to enhance"
);
}
#[test]
#[cfg(feature = "lsp")]
#[ignore = "requires rust-analyzer on PATH; run with --ignored"]
fn lsp_enhance_integration_with_real_rust_analyzer() {
use crate::lsp::RustAnalyzerClient;
use std::process::{Command, Stdio};
let ra_available = Command::new("rust-analyzer")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok();
if !ra_available {
eprintln!("skipping: rust-analyzer not on PATH");
return;
}
let workspace = TempDir::new().unwrap();
std::fs::write(
workspace.path().join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(workspace.path().join("src")).unwrap();
std::fs::write(
workspace.path().join("src/lib.rs"),
"pub fn add(a: i32, b: i32) -> i32 { a + b }\n",
)
.unwrap();
let abs_file = workspace.path().join("src/lib.rs");
let mut nodes = vec![Node::builder(NodeLabel::Function, "add", "demo.add")
.file_path("src/lib.rs")
.start_line(0)
.language(Language::Rust)
.project("demo")
.build()];
let client = RustAnalyzerClient::new();
let result = enhance_with_lsp(&client, &mut nodes, workspace.path());
assert!(
result.is_ok(),
"enhancement should not error even if rust-analyzer indexing is incomplete: {:?}",
result.err()
);
let _ = abs_file;
}
#[cfg(feature = "cache")]
#[test]
fn with_cache_sets_cache_and_invalidates_on_index() {
use std::sync::atomic::{AtomicU32, Ordering};
struct CountingCache {
invalidates: AtomicU32,
}
impl CacheStore for CountingCache {
fn get(&self, _key: &str) -> Option<Vec<u8>> {
None
}
fn set(&self, _key: &str, _val: Vec<u8>) {}
fn invalidate_all(&self) {
self.invalidates.fetch_add(1, Ordering::SeqCst);
}
}
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "main.rs", "fn main() {}\n");
let db_path = fresh_db_path();
let cache = Arc::new(CountingCache {
invalidates: AtomicU32::new(0),
});
let facade = IndexFacade::new(&db_path)
.expect("facade")
.with_cache(cache.clone());
let result = facade.index(tmp.path(), "demo", false);
assert!(result.is_ok(), "index should succeed: {:?}", result);
assert!(
cache.invalidates.load(Ordering::SeqCst) >= 1,
"invalidate_all should be called after index"
);
}
#[test]
fn index_ram_first_indexes_rust_file() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "main.rs", "fn main() { helper(); }\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index_ram_first(tmp.path(), "demo", false);
assert!(
result.is_ok(),
"index_ram_first should succeed: {:?}",
result
);
let result = result.unwrap();
assert!(result.files_indexed > 0, "should index files: {result:?}");
}
#[test]
fn index_ram_first_returns_error_for_nonexistent_path() {
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade.index_ram_first(Path::new("/nonexistent/path/xyz"), "demo", false);
assert!(result.is_err());
match result.unwrap_err() {
IndexError::PathNotFound(msg) => assert!(msg.contains("nonexistent")),
other => panic!("expected PathNotFound, got: {other:?}"),
}
}
#[test]
fn with_retry_retries_on_capital_lock_then_succeeds() {
let calls = AtomicU32::new(0);
let result: Result<u32> = with_retry(3, || {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Err(IndexError::Storage(StorageError::Query(
"Lock timeout".to_string(),
)))
} else {
Ok(7)
}
});
assert_eq!(result.unwrap(), 7);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn with_retry_with_zero_max_retries_returns_after_one_attempt() {
let calls = AtomicU32::new(0);
let result: Result<u32> = with_retry(0, || {
calls.fetch_add(1, Ordering::SeqCst);
Err(lock_error())
});
assert!(
matches!(result, Err(IndexError::DatabaseLocked)),
"should return DatabaseLocked with 0 retries: {result:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"should attempt exactly once with max_retries=0"
);
}
#[test]
fn with_retry_succeeds_on_second_attempt_with_max_one() {
let calls = AtomicU32::new(0);
let result: Result<u32> = with_retry(1, || {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Err(lock_error())
} else {
Ok(99)
}
});
assert_eq!(result.unwrap(), 99);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn index_facade_index_incremental_same_as_index() {
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "a.rs", "fn a() {}\n");
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade
.index_incremental(tmp.path(), "demo", false)
.expect("index_incremental");
assert!(result.files_indexed > 0, "should index files");
}
#[test]
fn pipeline_run_returns_error_for_nonexistent_path() {
let repo = Repository::in_memory().expect("repo");
let pipeline = Pipeline::new(repo);
let result = pipeline.run(Path::new("/nonexistent/xyz"), "demo", false);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), IndexError::PathNotFound(_)),
"should be PathNotFound"
);
}
#[test]
fn pipeline_run_ram_first_returns_error_for_nonexistent_path() {
let repo = Repository::in_memory().expect("repo");
let pipeline = Pipeline::new(repo);
let result = pipeline.run_ram_first(
Path::new("/nonexistent/xyz"),
"demo",
false,
std::collections::HashMap::new(),
);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), IndexError::PathNotFound(_)),
"should be PathNotFound"
);
}
#[test]
fn index_ram_first_with_empty_directory_succeeds() {
let tmp = TempDir::new().unwrap();
let db_path = fresh_db_path();
let facade = IndexFacade::new(&db_path).expect("facade");
let result = facade
.index_ram_first(tmp.path(), "empty", false)
.expect("index_ram_first should succeed");
assert_eq!(result.files_indexed, 0, "empty dir → 0 files indexed");
}
#[test]
fn with_retry_propagates_storage_error_that_is_not_lock() {
let calls = AtomicU32::new(0);
let result: Result<u32> = with_retry(3, || {
calls.fetch_add(1, Ordering::SeqCst);
Err(IndexError::Storage(StorageError::Query(
"syntax error near CALL".to_string(),
)))
});
assert!(result.is_err());
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"should not retry on non-lock error"
);
}
#[test]
fn build_file_nodes_uses_fallback_language_when_none() {
let tmp = TempDir::new().unwrap();
let f = FileInfo {
path: tmp.path().join("unknown.xyz"),
relative_path: "unknown.xyz".to_string(),
language: None,
size: 0,
};
fs::write(&f.path, "content\n").unwrap();
let mut diff = FileDiff::new();
diff.added.push(f);
let nodes = build_file_nodes(&diff, "proj");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].language, Some(Language::all()[0]));
}
#[test]
fn line_count_of_file_with_no_trailing_newline() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("no_newline.rs");
fs::write(&path, "line1\nline2").unwrap();
assert_eq!(line_count_of(&path), Some(2));
}
#[cfg(feature = "cache")]
#[test]
fn index_ram_first_invalidates_cache_on_success() {
use std::sync::atomic::{AtomicU32, Ordering};
struct CountingCache {
invalidates: AtomicU32,
}
impl CacheStore for CountingCache {
fn get(&self, _key: &str) -> Option<Vec<u8>> {
None
}
fn set(&self, _key: &str, _val: Vec<u8>) {}
fn invalidate_all(&self) {
self.invalidates.fetch_add(1, Ordering::SeqCst);
}
}
let tmp = TempDir::new().unwrap();
write_file(tmp.path(), "main.rs", "fn main() {}\n");
let db_path = fresh_db_path();
let cache = Arc::new(CountingCache {
invalidates: AtomicU32::new(0),
});
let facade = IndexFacade::new(&db_path)
.expect("facade")
.with_cache(cache.clone());
let result = facade.index_ram_first(tmp.path(), "demo", false);
assert!(
result.is_ok(),
"index_ram_first should succeed: {:?}",
result
);
assert!(
cache.invalidates.load(Ordering::SeqCst) >= 1,
"invalidate_all should be called after ram_first index"
);
}
}