use anyhow::Result;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use crate::chunking::ChunkingStrategy;
use crate::config::TurboPropConfig;
use crate::embeddings::EmbeddingGenerator;
use crate::files::FileDiscovery;
use crate::storage::{IndexConfig, IndexStorage};
use crate::types::{ChunkIndex, FileMetadata, IndexedChunk};
#[derive(Debug, Clone)]
pub struct PersistentChunkIndex {
chunk_index: ChunkIndex,
storage: IndexStorage,
config: IndexConfig,
indexed_path: PathBuf,
storage_version: String,
}
impl PersistentChunkIndex {
pub fn new<P: AsRef<Path>>(base_path: P) -> Result<Self> {
let indexed_path = base_path.as_ref().to_path_buf();
let storage = IndexStorage::new(&indexed_path)?;
let chunk_index = ChunkIndex::new();
let config = IndexConfig::default();
Ok(Self {
chunk_index,
storage,
config,
indexed_path,
storage_version: TurboPropConfig::default().general.storage_version,
})
}
pub fn load<P: AsRef<Path>>(base_path: P) -> Result<Self> {
let indexed_path = base_path.as_ref().to_path_buf();
let storage = IndexStorage::new(&indexed_path)?;
if !storage.index_exists() {
anyhow::bail!("No index found at {}", indexed_path.display());
}
let default_config = TurboPropConfig::default();
let (indexed_chunks, config) =
storage.load_index(&default_config.general.storage_version)?;
let mut chunk_index = ChunkIndex::new();
for indexed_chunk in indexed_chunks {
chunk_index.add_chunk(indexed_chunk.chunk, indexed_chunk.embedding);
}
info!("Loaded persistent index with {} chunks", chunk_index.len());
Ok(Self {
chunk_index,
storage,
config,
indexed_path,
storage_version: default_config.general.storage_version,
})
}
pub async fn build<P: AsRef<Path>>(base_path: P, config: &TurboPropConfig) -> Result<Self> {
let indexed_path = base_path.as_ref().to_path_buf();
info!("Building persistent index for: {}", indexed_path.display());
if !indexed_path.exists() {
anyhow::bail!("Path does not exist: {}", indexed_path.display());
}
if !indexed_path.is_dir() {
anyhow::bail!("Path is not a directory: {}", indexed_path.display());
}
let storage = IndexStorage::new(&indexed_path)?;
let mut embedding_generator = EmbeddingGenerator::new(config.embedding.clone()).await?;
let discovery = FileDiscovery::new(config.file_discovery.clone());
let files = discovery.discover_files(&indexed_path)?;
info!("Discovered {} files for indexing", files.len());
let index_config = IndexConfig {
model_name: config.embedding.model_name.clone(),
embedding_dimensions: config.embedding.embedding_dimensions,
batch_size: config.embedding.batch_size,
respect_gitignore: config.file_discovery.respect_gitignore,
include_untracked: config.file_discovery.include_untracked,
};
let mut all_indexed_chunks = Vec::new();
let chunking_strategy = ChunkingStrategy::new(config.chunking.clone());
for file in &files {
info!("Processing: {}", file.path.display());
let chunks = chunking_strategy.chunk_file(&file.path)?;
if chunks.is_empty() {
debug!("No chunks generated for: {}", file.path.display());
continue;
}
let chunk_texts: Vec<String> =
chunks.iter().map(|chunk| chunk.content.clone()).collect();
debug!("Generating embeddings for {} chunks", chunks.len());
let embeddings = embedding_generator.embed_batch(&chunk_texts)?;
let batch_indexed_chunks: Vec<IndexedChunk> = chunks
.into_iter()
.zip(embeddings.into_iter())
.map(|(chunk, embedding)| IndexedChunk { chunk, embedding })
.collect();
all_indexed_chunks.extend(batch_indexed_chunks);
}
info!("Generated {} indexed chunks", all_indexed_chunks.len());
storage.save_index(
&all_indexed_chunks,
&index_config,
&config.general.storage_version,
)?;
let mut chunk_index = ChunkIndex::new();
chunk_index.add_indexed_chunks(all_indexed_chunks);
Ok(Self {
chunk_index,
storage,
config: index_config,
indexed_path,
storage_version: config.general.storage_version.clone(),
})
}
pub async fn update_incremental(&mut self, config: &TurboPropConfig) -> Result<UpdateResult> {
info!(
"Performing incremental index update for: {}",
self.indexed_path.display()
);
let discovery = FileDiscovery::new(config.file_discovery.clone());
let current_files = discovery.discover_files(&self.indexed_path)?;
let (existing_indexed_chunks, existing_metadata) = if self.chunk_index.is_empty() {
if self.storage.index_exists() {
let (chunks, _config) = self.storage.load_index(&self.storage_version)?;
let metadata = self.storage.load_metadata()?;
(chunks, Some(metadata))
} else {
(Vec::new(), None)
}
} else {
(self.chunk_index.get_chunks().to_vec(), None)
};
let existing_files: HashSet<PathBuf> = existing_indexed_chunks
.iter()
.map(|chunk| chunk.chunk.source_location.file_path.clone())
.collect();
let current_file_paths: HashSet<PathBuf> =
current_files.iter().map(|f| f.path.clone()).collect();
let mut files_to_add = Vec::new();
let mut files_to_update = Vec::new();
let files_to_remove: Vec<PathBuf> = existing_files
.difference(¤t_file_paths)
.cloned()
.collect();
for file in ¤t_files {
if !existing_files.contains(&file.path) {
files_to_add.push(file.clone());
} else {
let needs_update = if let Some(ref metadata) = existing_metadata {
match metadata.file_timestamps.get(&file.path) {
Some(stored_timestamp) => {
match file.last_modified.duration_since(*stored_timestamp) {
Ok(duration) => {
duration.as_secs() > 0 || duration.as_nanos() > 0
}
Err(_) => {
warn!(
"Failed to compare timestamps for {}, updating file",
file.path.display()
);
true
}
}
}
None => {
debug!(
"No stored timestamp for {}, updating file",
file.path.display()
);
true
}
}
} else {
debug!(
"No metadata available for timestamp comparison, updating file: {}",
file.path.display()
);
true
};
if needs_update {
files_to_update.push(file.clone());
}
}
}
let mut result = UpdateResult {
added_files: files_to_add.len(),
updated_files: files_to_update.len(),
removed_files: files_to_remove.len(),
total_chunks_before: existing_indexed_chunks.len(),
total_chunks_after: 0,
};
if files_to_add.is_empty() && files_to_update.is_empty() && files_to_remove.is_empty() {
info!("No file changes detected, index is up to date");
result.total_chunks_after = existing_indexed_chunks.len();
return Ok(result);
}
info!(
"Incremental update: {} files to add, {} to update, {} to remove",
files_to_add.len(),
files_to_update.len(),
files_to_remove.len()
);
let mut embedding_generator = EmbeddingGenerator::new(config.embedding.clone()).await?;
let mut filtered_chunks: Vec<IndexedChunk> = existing_indexed_chunks
.into_iter()
.filter(|chunk| {
let file_path = &chunk.chunk.source_location.file_path;
!files_to_remove.contains(file_path)
&& !files_to_update.iter().any(|f| f.path == *file_path)
})
.collect();
let chunking_strategy = ChunkingStrategy::new(config.chunking.clone());
let files_to_process: Vec<&FileMetadata> =
files_to_add.iter().chain(files_to_update.iter()).collect();
let mut failed_files = Vec::new();
let mut processed_files = Vec::new();
for file in files_to_process {
info!("Processing: {}", file.path.display());
let chunks = match chunking_strategy.chunk_file(&file.path) {
Ok(chunks) => chunks,
Err(e) => {
warn!(
"Failed to chunk file {}: {}. Skipping file.",
file.path.display(),
e
);
failed_files.push(file.path.clone());
continue;
}
};
if chunks.is_empty() {
debug!("No chunks generated for: {}", file.path.display());
continue;
}
let chunk_texts: Vec<String> =
chunks.iter().map(|chunk| chunk.content.clone()).collect();
let embeddings = match embedding_generator.embed_batch(&chunk_texts) {
Ok(embeddings) => embeddings,
Err(e) => {
warn!(
"Failed to generate embeddings for {}: {}. Skipping file.",
file.path.display(),
e
);
failed_files.push(file.path.clone());
continue;
}
};
for (chunk, embedding) in chunks.into_iter().zip(embeddings.into_iter()) {
filtered_chunks.push(IndexedChunk { chunk, embedding });
}
processed_files.push(file.path.clone());
debug!("Successfully processed: {}", file.path.display());
}
if !failed_files.is_empty() {
warn!(
"Failed to process {} files: {:?}. Continuing with {} successfully processed files.",
failed_files.len(),
failed_files,
processed_files.len()
);
}
info!(
"Batch processing completed: {} files processed successfully, {} files failed",
processed_files.len(),
failed_files.len()
);
result.total_chunks_after = filtered_chunks.len();
self.storage.save_index(
&filtered_chunks,
&self.config,
&config.general.storage_version,
)?;
let mut new_chunk_index = ChunkIndex::new();
new_chunk_index.add_indexed_chunks(filtered_chunks);
self.chunk_index = new_chunk_index;
self.config.model_name = config.embedding.model_name.clone();
self.config.embedding_dimensions = config.embedding.embedding_dimensions;
self.config.batch_size = config.embedding.batch_size;
info!(
"Incremental update completed: {} chunks before, {} chunks after",
result.total_chunks_before, result.total_chunks_after
);
Ok(result)
}
pub fn similarity_search(
&self,
query_embedding: &[f32],
limit: usize,
) -> Vec<(f32, &IndexedChunk)> {
self.chunk_index.similarity_search(query_embedding, limit)
}
pub async fn search_text(
&mut self,
query: &str,
limit: usize,
config: &TurboPropConfig,
) -> Result<Vec<(f32, &IndexedChunk)>> {
let mut embedding_generator = EmbeddingGenerator::new(config.embedding.clone()).await?;
let query_embedding = embedding_generator.embed_single(query)?;
Ok(self.similarity_search(&query_embedding, limit))
}
pub fn len(&self) -> usize {
self.chunk_index.len()
}
pub fn is_empty(&self) -> bool {
self.chunk_index.is_empty()
}
pub fn get_chunks(&self) -> &[IndexedChunk] {
self.chunk_index.get_chunks()
}
pub fn config(&self) -> &IndexConfig {
&self.config
}
pub fn indexed_path(&self) -> &Path {
&self.indexed_path
}
pub fn exists_on_disk(&self) -> bool {
self.storage.index_exists()
}
pub fn save(&self) -> Result<()> {
let indexed_chunks = self.chunk_index.get_chunks();
self.storage
.save_index(indexed_chunks, &self.config, &self.storage_version)
}
pub fn clear(&mut self) -> Result<()> {
self.chunk_index = ChunkIndex::new();
self.storage.clear_index()
}
pub fn storage_path(&self) -> &Path {
self.storage.index_dir()
}
}
#[derive(Debug, Clone)]
pub struct UpdateResult {
pub added_files: usize,
pub updated_files: usize,
pub removed_files: usize,
pub total_chunks_before: usize,
pub total_chunks_after: usize,
}
impl UpdateResult {
pub fn has_changes(&self) -> bool {
self.added_files > 0 || self.updated_files > 0 || self.removed_files > 0
}
pub fn chunk_delta(&self) -> i64 {
self.total_chunks_after as i64 - self.total_chunks_before as i64
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TurboPropConfig;
use std::fs;
use tempfile::TempDir;
use tracing::warn;
fn create_test_file(dir: &Path, name: &str, content: &str) -> PathBuf {
let file_path = dir.join(name);
fs::write(&file_path, content).unwrap();
file_path
}
#[tokio::test]
async fn test_persistent_index_creation() {
let temp_dir = TempDir::new().unwrap();
let index = PersistentChunkIndex::new(temp_dir.path()).unwrap();
assert_eq!(index.len(), 0);
assert!(index.is_empty());
assert!(!index.exists_on_disk());
}
#[tokio::test]
async fn test_build_and_load_index() {
let temp_dir = TempDir::new().unwrap();
create_test_file(
temp_dir.path(),
"test1.txt",
"Hello world this is a test file",
);
create_test_file(
temp_dir.path(),
"test2.txt",
"Another test file with different content",
);
if std::env::var("OFFLINE_TESTS").is_ok() {
return;
}
let config = TurboPropConfig::default();
let index = PersistentChunkIndex::build(temp_dir.path(), &config).await;
if index.is_err() {
warn!("Skipping build test due to network/model requirements");
return;
}
let index = index.unwrap();
assert!(!index.is_empty());
assert!(index.exists_on_disk());
let loaded_index = PersistentChunkIndex::load(temp_dir.path()).unwrap();
assert_eq!(loaded_index.len(), index.len());
assert!(loaded_index.exists_on_disk());
}
#[test]
fn test_load_nonexistent_index() {
let temp_dir = TempDir::new().unwrap();
let result = PersistentChunkIndex::load(temp_dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No index found"));
}
#[test]
fn test_update_result() {
let result = UpdateResult {
added_files: 2,
updated_files: 1,
removed_files: 0,
total_chunks_before: 10,
total_chunks_after: 15,
};
assert!(result.has_changes());
assert_eq!(result.chunk_delta(), 5);
let no_change_result = UpdateResult {
added_files: 0,
updated_files: 0,
removed_files: 0,
total_chunks_before: 10,
total_chunks_after: 10,
};
assert!(!no_change_result.has_changes());
assert_eq!(no_change_result.chunk_delta(), 0);
}
}
use crate::types::{ChunkId, DocumentChunk};
use std::collections::HashMap;
#[derive(Debug)]
pub struct SearchIndex {
chunks: HashMap<ChunkId, DocumentChunk>,
}
impl Default for SearchIndex {
fn default() -> Self {
Self::new()
}
}
impl SearchIndex {
pub fn new() -> Self {
Self {
chunks: HashMap::new(),
}
}
pub fn add_chunk(&mut self, chunk: DocumentChunk) {
let chunk_id = ChunkId::from(format!(
"{}:{}",
chunk.metadata.file_path.display(),
chunk.metadata.start_line
));
self.chunks.insert(chunk_id, chunk);
}
pub fn remove_chunk(&mut self, chunk_id: ChunkId) {
self.chunks.remove(&chunk_id);
}
pub fn len(&self) -> usize {
self.chunks.len()
}
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
pub fn chunks(&self) -> impl Iterator<Item = (&ChunkId, &DocumentChunk)> {
self.chunks.iter()
}
pub fn find_chunks_by_file_path(&self, file_path: &std::path::Path) -> Vec<ChunkId> {
self.chunks
.iter()
.filter(|(_, chunk)| chunk.metadata.file_path == file_path)
.map(|(chunk_id, _)| chunk_id.clone())
.collect()
}
}