use crate::config::Settings;
use crate::indexing::pipeline::Pipeline;
use crate::semantic::remote::run_async;
use crate::semantic::{
EmbeddingBackend, EmbeddingPool, RemoteEmbedder, SemanticSearchError, SimpleSemanticSearch,
};
use crate::storage::{DocumentIndex, SearchResult};
use crate::symbol::context::{ContextIncludes, SymbolContext, SymbolRelationships};
use crate::{FileId, IndexError, RelationKind, Relationship, Symbol, SymbolId, SymbolKind};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
pub type FacadeResult<T> = Result<T, IndexError>;
#[derive(Debug, Clone, Default)]
pub struct IndexingStats {
pub files_indexed: usize,
pub symbols_found: usize,
pub relationships_resolved: usize,
pub files_removed: usize,
pub symbols_removed: usize,
}
#[derive(Debug, Clone, Default)]
pub struct SyncStats {
pub added_dirs: usize,
pub removed_dirs: usize,
pub files_indexed: usize,
pub symbols_found: usize,
pub files_modified: usize,
pub files_added: usize,
}
impl SyncStats {
pub fn has_changes(&self) -> bool {
self.added_dirs > 0
|| self.removed_dirs > 0
|| self.files_modified > 0
|| self.files_added > 0
}
}
pub struct IndexFacade {
document_index: Arc<DocumentIndex>,
pipeline: Pipeline,
semantic_search: Option<Arc<Mutex<SimpleSemanticSearch>>>,
embedding_pool: Option<Arc<EmbeddingBackend>>,
settings: Arc<Settings>,
indexed_paths: HashSet<PathBuf>,
index_base: PathBuf,
semantic_incompatible: bool,
semantic_metadata_snapshot: Option<crate::semantic::SemanticMetadata>,
}
impl IndexFacade {
pub fn new(settings: Arc<Settings>) -> FacadeResult<Self> {
let index_base = if let Some(ref workspace_root) = settings.workspace_root {
workspace_root.join(&settings.index_path)
} else {
settings.index_path.clone()
};
let tantivy_path = index_base.join("tantivy");
let document_index = Arc::new(DocumentIndex::new(&tantivy_path, &settings)?);
let pipeline = Pipeline::with_settings(settings.clone());
Ok(Self {
document_index,
pipeline,
semantic_search: None,
embedding_pool: None,
settings,
indexed_paths: HashSet::new(),
index_base,
semantic_incompatible: false,
semantic_metadata_snapshot: None,
})
}
pub fn from_components(
document_index: Arc<DocumentIndex>,
pipeline: Pipeline,
semantic_search: Option<Arc<Mutex<SimpleSemanticSearch>>>,
settings: Arc<Settings>,
) -> Self {
let index_base = if let Some(ref workspace_root) = settings.workspace_root {
workspace_root.join(&settings.index_path)
} else {
settings.index_path.clone()
};
Self {
document_index,
pipeline,
semantic_search,
embedding_pool: None,
settings,
indexed_paths: HashSet::new(),
index_base,
semantic_incompatible: false,
semantic_metadata_snapshot: None,
}
}
pub fn document_index(&self) -> &Arc<DocumentIndex> {
&self.document_index
}
pub fn pipeline(&self) -> &Pipeline {
&self.pipeline
}
pub fn settings(&self) -> &Arc<Settings> {
&self.settings
}
pub fn index_base(&self) -> &Path {
&self.index_base
}
pub fn enable_semantic_search(&mut self) -> FacadeResult<()> {
let semantic_path = self.index_base.join("semantic");
std::fs::create_dir_all(&semantic_path)?;
let backend = build_embedding_backend(&self.settings.semantic_search)?;
let backend = Arc::new(backend);
let is_remote = self.settings.semantic_search.remote_url.is_some()
|| std::env::var("CODANNA_EMBED_URL").is_ok();
let semantic = if is_remote {
SimpleSemanticSearch::new_empty(
backend.dimensions(),
&resolve_remote_model_name(&self.settings.semantic_search),
)
} else {
let model = &self.settings.semantic_search.model;
SimpleSemanticSearch::from_model_name(model)?
};
self.semantic_search = Some(Arc::new(Mutex::new(semantic)));
self.semantic_metadata_snapshot = self.get_semantic_metadata();
self.embedding_pool = Some(backend);
Ok(())
}
pub fn has_semantic_search(&self) -> bool {
self.semantic_search.is_some()
}
pub fn is_semantic_incompatible(&self) -> bool {
self.semantic_incompatible
}
pub fn save_semantic_search(&self, path: &Path) -> FacadeResult<()> {
if let Some(ref semantic) = self.semantic_search {
let sem = semantic.lock().map_err(|_| IndexError::lock_error())?;
sem.save(path)?;
}
Ok(())
}
pub fn load_semantic_search(&mut self, path: &Path) -> FacadeResult<bool> {
if path.join("metadata.json").exists() {
let is_remote = self.settings.semantic_search.remote_url.is_some()
|| std::env::var("CODANNA_EMBED_URL").is_ok();
let load_result = if is_remote {
SimpleSemanticSearch::load_remote(path)
} else {
SimpleSemanticSearch::load(path)
};
match load_result {
Ok(semantic) => {
if self.embedding_pool.is_none() {
match build_embedding_backend(&self.settings.semantic_search) {
Ok(b) => self.embedding_pool = Some(Arc::new(b)),
Err(e) => tracing::warn!("Failed to restore embedding backend: {e}"),
}
}
if let Some(ref pool) = self.embedding_pool {
let backend_dim = pool.dimensions();
let index_dim = semantic.dimensions();
if backend_dim != index_dim {
self.semantic_incompatible = true;
return Err(IndexError::SemanticSearch(
SemanticSearchError::DimensionMismatch {
expected: backend_dim,
actual: index_dim,
suggestion: format!(
"Index was built with {index_dim}-dimensional embeddings \
but current backend produces {backend_dim}d. \
Re-index with: codanna index <path> --force"
),
},
));
}
let index_is_remote = semantic.is_remote_index();
let backend_is_remote =
matches!(pool.as_ref(), EmbeddingBackend::Remote(_));
if index_is_remote != backend_is_remote {
tracing::warn!(
target: "semantic",
"Backend kind changed (index={}, current={}). \
Embedding spaces may differ — similarity scores could be inaccurate. \
Re-index with --force to fix.",
if index_is_remote { "remote" } else { "local" },
if backend_is_remote { "remote" } else { "local" },
);
}
}
self.semantic_search = Some(Arc::new(Mutex::new(semantic)));
self.semantic_metadata_snapshot = self.get_semantic_metadata();
return Ok(true);
}
Err(SemanticSearchError::DimensionMismatch {
expected,
actual,
ref suggestion,
}) => {
self.semantic_incompatible = true;
tracing::error!(
target: "semantic",
"Semantic index dimension mismatch (expected={expected}, actual={actual}): {suggestion}"
);
return Err(IndexError::SemanticSearch(
SemanticSearchError::DimensionMismatch {
expected,
actual,
suggestion: suggestion.to_string(),
},
));
}
Err(e) => {
tracing::warn!("Failed to load semantic search, continuing without it: {e}");
}
}
}
Ok(false)
}
pub fn load_semantic_metadata_snapshot(&mut self, path: &Path) -> FacadeResult<bool> {
if !path.join("metadata.json").exists() {
self.semantic_metadata_snapshot = None;
return Ok(false);
}
let metadata = crate::semantic::SemanticMetadata::load(path)?;
self.semantic_metadata_snapshot = Some(metadata);
Ok(true)
}
pub fn ensure_embedding_pool(&mut self) -> FacadeResult<()> {
if self.embedding_pool.is_some() {
return Ok(());
}
let backend = build_embedding_backend(&self.settings.semantic_search)?;
self.embedding_pool = Some(Arc::new(backend));
tracing::debug!("Initialized embedding backend for incremental updates");
Ok(())
}
pub fn semantic_search_embedding_count(&self) -> usize {
self.semantic_search
.as_ref()
.map(|s| s.lock().map(|sem| sem.embedding_count()).unwrap_or(0))
.or_else(|| {
self.semantic_metadata_snapshot
.as_ref()
.map(|m| m.embedding_count)
})
.unwrap_or(0)
}
pub fn get_semantic_metadata(&self) -> Option<crate::semantic::SemanticMetadata> {
self.semantic_search
.as_ref()
.and_then(|s| s.lock().ok().and_then(|sem| sem.metadata().cloned()))
.or_else(|| self.semantic_metadata_snapshot.clone())
}
pub fn find_symbol(&self, name: &str) -> Option<SymbolId> {
self.document_index
.find_symbols_by_name(name, None)
.ok()
.and_then(|symbols| symbols.first().map(|s| s.id))
}
pub fn find_symbols_by_name(&self, name: &str, language_filter: Option<&str>) -> Vec<Symbol> {
self.document_index
.find_symbols_by_name(name, language_filter)
.unwrap_or_default()
}
pub fn get_symbol(&self, id: SymbolId) -> Option<Symbol> {
self.document_index.find_symbol_by_id(id).ok().flatten()
}
pub fn get_all_symbols(&self) -> Vec<Symbol> {
self.document_index
.get_all_symbols(10000)
.unwrap_or_else(|e| {
tracing::warn!(target: "facade", "get_all_symbols error: {e}");
Vec::new()
})
}
pub fn get_symbols_by_file(&self, file_id: FileId) -> Vec<Symbol> {
self.document_index
.find_symbols_by_file(file_id)
.unwrap_or_default()
}
pub fn get_called_functions(&self, symbol_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_from(symbol_id, RelationKind::Calls)
.unwrap_or_default();
let mut symbols = Vec::new();
for (_, to_id, _) in relationships {
if let Some(symbol) = self.get_symbol(to_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_called_functions_with_metadata(
&self,
symbol_id: SymbolId,
) -> Vec<(Symbol, Option<crate::relationship::RelationshipMetadata>)> {
let relationships = self
.document_index
.get_relationships_from(symbol_id, RelationKind::Calls)
.unwrap_or_default();
let mut results = Vec::new();
for (_, to_id, rel) in relationships {
if let Some(symbol) = self.get_symbol(to_id) {
results.push((symbol, rel.metadata));
}
}
results
}
pub fn get_calling_functions(&self, symbol_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_to(symbol_id, RelationKind::Calls)
.unwrap_or_default();
let mut symbols = Vec::new();
for (from_id, _, _) in relationships {
if let Some(symbol) = self.get_symbol(from_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_calling_functions_with_metadata(
&self,
symbol_id: SymbolId,
) -> Vec<(Symbol, Option<crate::relationship::RelationshipMetadata>)> {
let relationships = self
.document_index
.get_relationships_to(symbol_id, RelationKind::Calls)
.unwrap_or_default();
let mut results = Vec::new();
for (from_id, _, rel) in relationships {
if let Some(symbol) = self.get_symbol(from_id) {
results.push((symbol, rel.metadata));
}
}
results
}
pub fn get_implementations(&self, trait_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_to(trait_id, RelationKind::Implements)
.unwrap_or_default();
let mut symbols = Vec::new();
for (from_id, _, _) in relationships {
if let Some(symbol) = self.get_symbol(from_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_implemented_traits(&self, type_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_from(type_id, RelationKind::Implements)
.unwrap_or_default();
let mut symbols = Vec::new();
for (_, to_id, _) in relationships {
if let Some(symbol) = self.get_symbol(to_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_extends(&self, class_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_from(class_id, RelationKind::Extends)
.unwrap_or_default();
let mut symbols = Vec::new();
for (_, to_id, _) in relationships {
if let Some(symbol) = self.get_symbol(to_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_extended_by(&self, base_class_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_to(base_class_id, RelationKind::Extends)
.unwrap_or_default();
let mut symbols = Vec::new();
for (from_id, _, _) in relationships {
if let Some(symbol) = self.get_symbol(from_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_uses(&self, symbol_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_from(symbol_id, RelationKind::Uses)
.unwrap_or_default();
let mut symbols = Vec::new();
for (_, to_id, _) in relationships {
if let Some(symbol) = self.get_symbol(to_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_used_by(&self, type_id: SymbolId) -> Vec<Symbol> {
let relationships = self
.document_index
.get_relationships_to(type_id, RelationKind::Uses)
.unwrap_or_default();
let mut symbols = Vec::new();
for (from_id, _, _) in relationships {
if let Some(symbol) = self.get_symbol(from_id) {
symbols.push(symbol);
}
}
symbols
}
pub fn get_relationships_for_symbol(
&self,
symbol_id: SymbolId,
) -> FacadeResult<Vec<(SymbolId, SymbolId, Relationship)>> {
let mut all_rels = Vec::new();
for kind in &[
RelationKind::Calls,
RelationKind::Uses,
RelationKind::Implements,
RelationKind::Extends,
RelationKind::Defines,
] {
if let Ok(rels) = self.document_index.get_relationships_from(symbol_id, *kind) {
all_rels.extend(rels);
}
}
for kind in &[
RelationKind::Calls,
RelationKind::Uses,
RelationKind::Implements,
RelationKind::Extends,
] {
if let Ok(rels) = self.document_index.get_relationships_to(symbol_id, *kind) {
all_rels.extend(rels);
}
}
Ok(all_rels)
}
pub fn get_symbol_context(
&self,
symbol_id: SymbolId,
include: ContextIncludes,
) -> Option<SymbolContext> {
let symbol = self.get_symbol(symbol_id)?;
let file_path = self
.document_index
.get_file_path(symbol.file_id)
.ok()
.flatten()
.unwrap_or_else(|| symbol.file_path.to_string());
let mut relationships = SymbolRelationships::default();
if include.contains(ContextIncludes::IMPLEMENTATIONS) {
let impls = self.get_implementations(symbol_id);
if !impls.is_empty() {
relationships.implemented_by = Some(impls);
}
let implemented = self.get_implemented_traits(symbol_id);
if !implemented.is_empty() {
relationships.implements = Some(implemented);
}
}
if include.contains(ContextIncludes::DEFINITIONS) {
if let Ok(rels) = self
.document_index
.get_relationships_from(symbol_id, RelationKind::Defines)
{
let defines: Vec<Symbol> = rels
.iter()
.filter_map(|(_, to_id, _)| self.get_symbol(*to_id))
.collect();
if !defines.is_empty() {
relationships.defines = Some(defines);
}
}
}
if include.contains(ContextIncludes::CALLS) {
let calls = self.get_called_functions_with_metadata(symbol_id);
if !calls.is_empty() {
relationships.calls = Some(calls);
}
}
if include.contains(ContextIncludes::CALLERS) {
let callers = self.get_calling_functions_with_metadata(symbol_id);
if !callers.is_empty() {
relationships.called_by = Some(callers);
}
}
if include.contains(ContextIncludes::EXTENDS) {
let extends = self.get_extends(symbol_id);
if !extends.is_empty() {
relationships.extends = Some(extends);
}
let extended_by = self.get_extended_by(symbol_id);
if !extended_by.is_empty() {
relationships.extended_by = Some(extended_by);
}
}
if include.contains(ContextIncludes::USES) {
let uses = self.get_uses(symbol_id);
if !uses.is_empty() {
relationships.uses = Some(uses);
}
let used_by = self.get_used_by(symbol_id);
if !used_by.is_empty() {
relationships.used_by = Some(used_by);
}
}
Some(SymbolContext {
symbol,
file_path,
relationships,
})
}
pub fn get_dependencies(&self, symbol_id: SymbolId) -> HashMap<RelationKind, Vec<Symbol>> {
let mut deps: HashMap<RelationKind, Vec<Symbol>> = HashMap::new();
for kind in &[
RelationKind::Calls,
RelationKind::Uses,
RelationKind::Implements,
RelationKind::Defines,
] {
let rels = self
.document_index
.get_relationships_from(symbol_id, *kind)
.unwrap_or_default();
let symbols: Vec<Symbol> = rels
.iter()
.filter_map(|(_, to_id, _)| self.get_symbol(*to_id))
.collect();
if !symbols.is_empty() {
deps.insert(*kind, symbols);
}
}
deps
}
pub fn get_dependents(&self, symbol_id: SymbolId) -> HashMap<RelationKind, Vec<Symbol>> {
let mut deps: HashMap<RelationKind, Vec<Symbol>> = HashMap::new();
for kind in &[
RelationKind::Calls,
RelationKind::Uses,
RelationKind::Implements,
] {
let rels = self
.document_index
.get_relationships_to(symbol_id, *kind)
.unwrap_or_default();
let symbols: Vec<Symbol> = rels
.iter()
.filter_map(|(from_id, _, _)| self.get_symbol(*from_id))
.collect();
if !symbols.is_empty() {
deps.insert(*kind, symbols);
}
}
deps
}
pub fn get_impact_radius(
&self,
symbol_id: SymbolId,
max_depth: Option<usize>,
) -> Vec<SymbolId> {
let max_depth = max_depth.unwrap_or(2);
let mut visited = HashSet::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back((symbol_id, 0usize));
visited.insert(symbol_id);
while let Some((current_id, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
for kind in &[
RelationKind::Calls,
RelationKind::Uses,
RelationKind::Implements,
RelationKind::Extends,
] {
if let Ok(rels) = self.document_index.get_relationships_to(current_id, *kind) {
for (from_id, _, _) in rels {
if visited.insert(from_id) {
queue.push_back((from_id, depth + 1));
}
}
}
}
}
visited.remove(&symbol_id);
visited.into_iter().collect()
}
pub fn search(
&self,
query: &str,
limit: usize,
kind_filter: Option<SymbolKind>,
module_filter: Option<&str>,
language_filter: Option<&str>,
) -> FacadeResult<Vec<SearchResult>> {
self.document_index
.search(query, limit, kind_filter, module_filter, language_filter)
.map_err(Into::into)
}
pub fn semantic_search_docs(
&self,
query: &str,
limit: usize,
) -> FacadeResult<Vec<(Symbol, f32)>> {
self.semantic_search_docs_with_language(query, limit, None)
}
pub fn semantic_search_docs_with_language(
&self,
query: &str,
limit: usize,
language_filter: Option<&str>,
) -> FacadeResult<Vec<(Symbol, f32)>> {
let semantic = self
.semantic_search
.as_ref()
.ok_or(IndexError::SemanticSearchNotEnabled)?;
let sem = semantic.lock().map_err(|_| IndexError::lock_error())?;
let results = if sem.has_local_model() {
sem.search_with_language(query, limit, language_filter)?
} else {
let pool = self.embedding_pool.as_ref().ok_or_else(|| {
IndexError::General(
"Remote-mode index requires an embedding backend for queries. \
Set CODANNA_EMBED_URL or re-index with a local model."
.to_string(),
)
})?;
let query_vec = pool.embed_one(query)?;
sem.search_with_embedding_and_language(&query_vec, limit, language_filter)?
};
let mut symbols = Vec::new();
for (symbol_id, score) in results {
if let Some(symbol) = self.get_symbol(symbol_id) {
symbols.push((symbol, score));
}
}
Ok(symbols)
}
pub fn semantic_search_docs_with_threshold(
&self,
query: &str,
limit: usize,
threshold: f32,
) -> FacadeResult<Vec<(Symbol, f32)>> {
self.semantic_search_docs_with_threshold_and_language(query, limit, threshold, None)
}
pub fn semantic_search_docs_with_threshold_and_language(
&self,
query: &str,
limit: usize,
threshold: f32,
language_filter: Option<&str>,
) -> FacadeResult<Vec<(Symbol, f32)>> {
let results = self.semantic_search_docs_with_language(query, limit, language_filter)?;
Ok(results
.into_iter()
.filter(|(_, score)| *score >= threshold)
.collect())
}
pub fn get_file_id_for_path(&self, path: &str) -> Option<FileId> {
self.document_index
.get_file_info(path)
.ok()
.flatten()
.map(|(id, _, _)| id)
}
pub fn get_file_path(&self, file_id: FileId) -> Option<String> {
self.document_index.get_file_path(file_id).ok().flatten()
}
pub fn get_all_indexed_paths(&self) -> Vec<PathBuf> {
self.document_index
.get_all_indexed_paths()
.unwrap_or_default()
}
pub fn symbol_count(&self) -> usize {
self.document_index.count_symbols().unwrap_or(0)
}
pub fn file_count(&self) -> u32 {
self.document_index.count_files().unwrap_or(0) as u32
}
pub fn relationship_count(&self) -> usize {
self.document_index.count_relationships().unwrap_or(0)
}
pub fn document_count(&self) -> FacadeResult<u64> {
self.document_index.document_count().map_err(Into::into)
}
pub fn add_indexed_path(&mut self, dir_path: &Path) {
if let Ok(canonical) = dir_path.canonicalize() {
let already_covered = self
.indexed_paths
.iter()
.any(|p| canonical.starts_with(p) && canonical != *p);
if already_covered {
return;
}
self.indexed_paths
.retain(|p| !p.starts_with(&canonical) || *p == canonical);
self.indexed_paths.insert(canonical);
} else {
self.indexed_paths.insert(dir_path.to_path_buf());
}
}
pub fn get_indexed_paths(&self) -> &HashSet<PathBuf> {
&self.indexed_paths
}
pub fn set_indexed_paths(&mut self, paths: Vec<PathBuf>) {
self.indexed_paths = paths.into_iter().collect();
}
pub fn index_file(
&mut self,
path: impl AsRef<std::path::Path>,
) -> crate::IndexResult<crate::IndexingResult> {
let path = path.as_ref();
if self.has_semantic_search() {
if let Err(e) = self.ensure_embedding_pool() {
tracing::warn!("Failed to initialize embedding pool: {e}");
}
}
let stats = self.pipeline.index_file_single(
path,
Arc::clone(&self.document_index),
self.semantic_search.clone(),
self.embedding_pool.clone(),
)?;
Ok(crate::IndexingResult::Indexed(stats.file_id))
}
pub fn index_file_with_force(
&mut self,
path: impl AsRef<std::path::Path>,
force: bool,
) -> crate::IndexResult<crate::IndexingResult> {
let path = path.as_ref();
if force {
self.remove_file(path)?;
}
self.index_file(path)
}
pub fn remove_file(&mut self, path: impl AsRef<std::path::Path>) -> crate::IndexResult<()> {
let path = path.as_ref();
let semantic_path = self.settings.index_path.join("semantic");
use crate::indexing::pipeline::stages::CleanupStage;
let cleanup_stage = if let Some(ref sem) = self.semantic_search {
CleanupStage::new(Arc::clone(&self.document_index), &semantic_path)
.with_semantic(Arc::clone(sem))
} else {
CleanupStage::new(Arc::clone(&self.document_index), &semantic_path)
};
cleanup_stage.cleanup_files(&[path.to_path_buf()])?;
Ok(())
}
pub fn index_directory(&mut self, path: &Path, force: bool) -> FacadeResult<IndexingStats> {
if self.has_semantic_search() {
if let Err(e) = self.ensure_embedding_pool() {
tracing::warn!("Failed to initialize embedding pool: {e}");
}
}
let stats = self.pipeline.index_incremental(
path,
Arc::clone(&self.document_index),
self.semantic_search.clone(),
self.embedding_pool.clone(),
force,
)?;
self.add_indexed_path(path);
Ok(IndexingStats {
files_indexed: stats.new_files + stats.modified_files,
symbols_found: stats.index_stats.symbols_found,
relationships_resolved: stats.phase2_stats.defines_resolved
+ stats.phase2_stats.calls_resolved
+ stats.phase2_stats.other_resolved,
files_removed: stats.deleted_files,
symbols_removed: stats.deleted_symbols,
})
}
pub fn index_directory_with_options(
&mut self,
dir: impl AsRef<Path>,
progress: bool,
dry_run: bool,
force: bool,
max_files: Option<usize>,
) -> crate::IndexResult<crate::indexing::progress::IndexStats> {
use crate::indexing::FileWalker;
use crate::indexing::progress::IndexStats;
let dir = dir.as_ref();
let walker = FileWalker::new(Arc::clone(&self.settings));
let files: Vec<_> = walker.walk(dir).collect();
let files = if let Some(max) = max_files {
files.into_iter().take(max).collect()
} else {
files
};
let total_files = files.len();
if dry_run {
println!("Would index {total_files} files:");
for (i, file_path) in files.iter().enumerate() {
if i < 5 {
println!(" {}", file_path.display());
} else if i == 5 && total_files > 5 {
println!(" ... and {} more files", total_files - 5);
break;
}
}
let mut stats = IndexStats::new();
stats.files_indexed = total_files;
return Ok(stats);
}
let force = force || self.document_count().unwrap_or(0) == 0;
if self.has_semantic_search() {
if let Err(e) = self.ensure_embedding_pool() {
tracing::warn!("Failed to initialize embedding pool: {e}");
}
}
let pipeline_stats = self.pipeline.index_incremental_with_progress_flag(
dir,
Arc::clone(&self.document_index),
self.semantic_search.clone(),
self.embedding_pool.clone(),
force,
progress && total_files > 0,
total_files,
)?;
self.add_indexed_path(dir);
let mut stats = IndexStats::default();
stats.files_indexed = pipeline_stats.new_files + pipeline_stats.modified_files;
stats.symbols_found = pipeline_stats.index_stats.symbols_found;
stats.files_removed = pipeline_stats.deleted_files;
stats.symbols_removed = pipeline_stats.deleted_symbols;
stats.elapsed = pipeline_stats.elapsed;
Ok(stats)
}
pub fn sync_with_config(
&mut self,
stored_paths: Option<Vec<PathBuf>>,
config_paths: &[PathBuf],
progress: bool,
) -> FacadeResult<SyncStats> {
let stored = stored_paths.unwrap_or_default();
let stored_set: HashSet<PathBuf> = stored.iter().cloned().collect();
let config_set: HashSet<PathBuf> = config_paths.iter().cloned().collect();
let to_add: Vec<&PathBuf> = config_set.difference(&stored_set).collect();
let to_remove: Vec<&PathBuf> = stored_set.difference(&config_set).collect();
let mut stats = SyncStats::default();
if self.has_semantic_search() && !to_add.is_empty() {
if let Err(e) = self.ensure_embedding_pool() {
tracing::warn!("Failed to initialize embedding pool: {e}");
}
}
for path in &to_add {
eprintln!();
eprintln!("Indexing directory: {}", path.display());
let file_count = if progress {
use crate::indexing::FileWalker;
let walker = FileWalker::new(Arc::clone(&self.settings));
walker.walk(path).count()
} else {
0
};
let result = self.pipeline.index_incremental_with_progress_flag(
path,
Arc::clone(&self.document_index),
self.semantic_search.clone(),
self.embedding_pool.clone(),
true, progress,
file_count,
)?;
stats.files_indexed += result.new_files + result.modified_files;
stats.symbols_found += result.index_stats.symbols_found;
}
stats.added_dirs = to_add.len();
for path in &to_remove {
self.remove_directory_files(path)?;
}
stats.removed_dirs = to_remove.len();
self.indexed_paths = config_set;
Ok(stats)
}
fn remove_directory_files(&self, _dir: &Path) -> FacadeResult<()> {
Ok(())
}
}
pub fn resolve_remote_model_name(cfg: &crate::config::SemanticSearchConfig) -> String {
std::env::var("CODANNA_EMBED_MODEL")
.ok()
.or_else(|| cfg.remote_model.clone())
.unwrap_or_else(|| "text-embedding-ada-002".to_string())
}
pub fn format_semantic_status(cfg: &crate::config::SemanticSearchConfig) -> String {
let is_remote = std::env::var("CODANNA_EMBED_URL").is_ok() || cfg.remote_url.is_some();
let threshold = cfg.threshold;
if is_remote {
let model = resolve_remote_model_name(cfg);
format!("Semantic search enabled (backend: remote, model: {model}, threshold: {threshold})")
} else {
let model = &cfg.model;
format!("Semantic search enabled (model: {model}, threshold: {threshold})")
}
}
pub fn build_embedding_backend(
cfg: &crate::config::SemanticSearchConfig,
) -> FacadeResult<EmbeddingBackend> {
let remote_url = std::env::var("CODANNA_EMBED_URL")
.ok()
.or_else(|| cfg.remote_url.clone());
if let Some(url) = remote_url {
let model = resolve_remote_model_name(cfg);
let dim: Option<usize> = match std::env::var("CODANNA_EMBED_DIM") {
Ok(s) => {
let parsed = s.parse::<usize>().map_err(|_| {
IndexError::General(format!(
"CODANNA_EMBED_DIM must be a positive integer, got: {s:?}"
))
})?;
if parsed == 0 {
return Err(IndexError::General(
"CODANNA_EMBED_DIM must be greater than zero".to_string(),
));
}
Some(parsed)
}
Err(_) => cfg.remote_dim,
};
let api_key = std::env::var("CODANNA_EMBED_API_KEY").ok();
tracing::info!(
target: "semantic",
"Using remote embedding backend: url={url} model={model} auth={}",
if api_key.is_some() { "bearer" } else { "none" }
);
let url_owned = url.clone();
let model_owned = model.clone();
let embedder =
run_async(
async move { RemoteEmbedder::new(&url_owned, &model_owned, dim, api_key).await },
)
.map_err(|e| IndexError::General(format!("Remote embedder init failed: {e}")))?;
return Ok(EmbeddingBackend::Remote(Arc::new(embedder)));
}
let pool_size = cfg.embedding_threads;
let embedding_model = crate::vector::parse_embedding_model(&cfg.model)
.map_err(|e| IndexError::General(format!("Failed to parse embedding model: {e}")))?;
let pool = EmbeddingPool::new(pool_size, embedding_model)
.map_err(|e| IndexError::General(format!("Local embedding pool init failed: {e}")))?;
Ok(EmbeddingBackend::Local(pool))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_returns_err_on_corrupt_tantivy_dir() {
let dir = tempfile::tempdir().unwrap();
let tantivy_dir = dir.path().join("tantivy");
std::fs::create_dir_all(&tantivy_dir).unwrap();
std::fs::write(tantivy_dir.join("meta.json"), b"not valid json").unwrap();
let settings = Settings {
index_path: dir.path().to_path_buf(),
workspace_root: None,
..Default::default()
};
let result = IndexFacade::new(std::sync::Arc::new(settings));
assert!(result.is_err());
}
#[test]
fn symbol_card_context_carries_extends_both_directions() {
use crate::symbol::context::ContextIncludes;
let dir = tempfile::tempdir().unwrap();
let settings = Settings {
index_path: dir.path().join("index"),
workspace_root: None,
..Default::default()
};
let source = dir.path().join("classes.py");
std::fs::write(
&source,
"class Base:\n def m(self):\n pass\n\n\nclass Derived(Base):\n def m(self):\n pass\n",
)
.unwrap();
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
facade.index_file(&source).unwrap();
let derived = facade
.find_symbols_by_name("Derived", None)
.pop()
.expect("Derived indexed");
let ctx = facade
.get_symbol_context(derived.id, ContextIncludes::SYMBOL_CARD)
.expect("context for Derived");
let extends = ctx
.relationships
.extends
.expect("extends fetched under SYMBOL_CARD");
assert!(
extends.iter().any(|s| s.name.as_ref() == "Base"),
"Derived extends Base"
);
let base = facade
.find_symbols_by_name("Base", None)
.pop()
.expect("Base indexed");
let ctx = facade
.get_symbol_context(base.id, ContextIncludes::SYMBOL_CARD)
.expect("context for Base");
let extended_by = ctx
.relationships
.extended_by
.expect("extended_by fetched under SYMBOL_CARD");
assert!(
extended_by.iter().any(|s| s.name.as_ref() == "Derived"),
"Base extended by Derived"
);
}
#[test]
fn deletion_only_run_reports_removal_counts() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("fixture");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("alpha.py"), "def alpha():\n pass\n").unwrap();
std::fs::write(
root.join("beta.py"),
"def beta_one():\n pass\n\n\ndef beta_two():\n pass\n",
)
.unwrap();
let settings = Settings {
index_path: dir.path().join("index"),
workspace_root: None,
..Default::default()
};
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
let seed = facade.index_directory(&root, false).unwrap();
assert_eq!(seed.files_indexed, 2);
assert_eq!(seed.files_removed, 0);
std::fs::remove_file(root.join("beta.py")).unwrap();
let stats = facade.index_directory(&root, false).unwrap();
assert_eq!(stats.files_indexed, 0, "no files re-indexed");
assert_eq!(stats.files_removed, 1, "deletion must surface");
assert_eq!(
stats.symbols_removed, 3,
"beta.py carried <module> + two functions"
);
}
#[test]
fn index_file_with_force_succeeds_on_unindexed_file() {
let dir = tempfile::tempdir().unwrap();
let settings = Settings {
index_path: dir.path().join("index"),
workspace_root: None,
..Default::default()
};
let source = dir.path().join("sample.rs");
std::fs::write(&source, "fn main() {}\n").unwrap();
let mut facade = IndexFacade::new(std::sync::Arc::new(settings)).unwrap();
let result = facade.index_file_with_force(&source, true);
assert!(result.is_ok(), "force on unindexed file: {result:?}");
}
}