pub mod chunking;
pub mod cli;
pub mod config;
pub mod embedding;
pub mod enumeration;
pub mod error;
pub mod hooks;
pub mod mcp;
pub mod prompts;
pub mod search;
pub mod store;
pub mod types;
pub mod util;
pub use error::{ClaudixError, Result};
pub use types::{
ByteRange, Chunk, ChunkId, ChunkKind, Dimension, EmbeddedChunk, FileHash, Language, LineRange,
RelativePath,
};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use chunking::MultiLanguageChunker;
use config::{Config, EmbeddingProvider};
#[cfg(any(test, feature = "test-stub"))]
use embedding::StubProvider;
use embedding::bundled::{BUNDLED_DIMENSIONS, BUNDLED_MODEL_ID};
use embedding::{BundledProvider, FallbackProvider, HttpProvider, Provider};
use enumeration::{EnumeratedFile, FileEnumerator, PathFilters, WatchFilter};
use error::RecoveryHint;
use prompts::hints;
use search::neighbors::neighbors;
use search::{SearchQuery, SearchResults, Searcher};
use store::marker::change_neighbors::{
ChangeNeighborsMarker, NeighborEntry, write as write_neighbors_marker,
};
use store::{Store, stored_chunks_from_embedded};
use tokio::{fs, task};
use types::reject_path_escape;
pub struct Claudix {
config: Arc<Config>,
project_root: PathBuf,
embedder: Arc<dyn Provider>,
store: Store,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexStats {
pub file_count: usize,
pub chunk_count: usize,
}
pub enum IndexFileStatus {
Indexed,
Verified,
Skipped(&'static str),
}
pub trait IndexProgress {
fn file(&mut self, path: &RelativePath, status: IndexFileStatus) -> Result<()>;
}
impl IndexProgress for () {
fn file(&mut self, _path: &RelativePath, _status: IndexFileStatus) -> Result<()> {
Ok(())
}
}
impl Claudix {
pub async fn new(project_root: PathBuf, config: Arc<Config>) -> Result<Self> {
let embedder = build_provider(config.as_ref()).await?;
let store = Store::new(&project_root, config.as_ref())?;
store.validate_manifest_compatibility(embedder.model_id(), embedder.dimensions().0)?;
Ok(Self {
config,
project_root,
embedder,
store,
})
}
#[cfg(test)]
pub(crate) fn from_parts(
project_root: PathBuf,
config: Arc<Config>,
embedder: Arc<dyn Provider>,
store: Store,
) -> Self {
Self {
config,
project_root,
embedder,
store,
}
}
pub fn config(&self) -> &Config {
self.config.as_ref()
}
pub fn project_root(&self) -> &Path {
&self.project_root
}
pub async fn index_full(&self, progress: &mut dyn IndexProgress) -> Result<IndexStats> {
let enumerator =
FileEnumerator::new(self.project_root.clone(), self.config.as_ref().clone())?;
let files = enumerator.enumerate(&mut *progress)?;
let current_files: Vec<(String, [u8; 16])> = files
.iter()
.map(|f| (f.relative_path.as_str().to_owned(), f.file_hash.0))
.collect();
let force_included: HashSet<&str> = files
.iter()
.filter(|file| file.force_indexed)
.map(|file| file.relative_path.as_str())
.collect();
let force_recheck = self
.store
.force_included_without_chunks(&force_included)
.await?;
let table_matches = self.store.table_matches_manifest_chunk_count().await?;
if force_recheck.is_empty()
&& table_matches
&& self
.store
.manifest_hashes_match(¤t_files, self.config.as_ref())?
&& let Some(stats) = self
.store
.touch_manifest_if_in_sync(¤t_files, self.config.as_ref())?
{
return Ok(IndexStats {
file_count: stats.file_count,
chunk_count: stats.chunk_count,
});
}
let (changed_paths, unchanged_rows) = self
.store
.incremental_file_state(¤t_files, &force_recheck, &mut *progress)
.await?;
if table_matches
&& changed_paths.is_empty()
&& let Some(stats) = self
.store
.touch_manifest_if_in_sync(¤t_files, self.config.as_ref())?
{
return Ok(IndexStats {
file_count: stats.file_count,
chunk_count: stats.chunk_count,
});
}
let mut rows = unchanged_rows;
let mut file_chunk_counts: Vec<(&EnumeratedFile, usize)> = Vec::new();
let mut all_chunks: Vec<Chunk> = Vec::new();
for file in files
.iter()
.filter(|file| changed_paths.contains(file.relative_path.as_str()))
{
let chunks = self.collect_file_chunks(file).await?;
if chunks.is_empty() {
progress.file(
&file.relative_path,
IndexFileStatus::Skipped("no indexable chunks"),
)?;
continue;
}
file_chunk_counts.push((file, chunks.len()));
all_chunks.extend(chunks);
}
let all_embedded = self.embed_chunks(all_chunks).await?;
let mut offset = 0;
for (file, count) in file_chunk_counts {
let embedded_chunks = &all_embedded[offset..offset + count];
offset += count;
rows.retain(|row| row.file_path != file.relative_path.as_str());
rows.extend(stored_chunks_from_embedded(
embedded_chunks,
Dimension(self.config.embedding.dimensions),
)?);
progress.file(&file.relative_path, IndexFileStatus::Indexed)?;
}
let stats = self
.store
.persist_incremental(&[], rows, self.config.as_ref(), ¤t_files)
.await?;
Ok(IndexStats {
file_count: stats.file_count,
chunk_count: stats.chunk_count,
})
}
pub async fn reindex_file(&self, path: &Path) -> Result<IndexStats> {
let relative_path = self.relative_path_from_input(path)?;
let filter = WatchFilter::load(&self.project_root)?;
if !filter.is_watchable(&relative_path.to_path_buf()) {
let manifest = self.store.read_manifest()?;
return Ok(IndexStats {
file_count: manifest
.as_ref()
.map(|m| m.file_count as usize)
.unwrap_or(0),
chunk_count: manifest
.as_ref()
.map(|m| m.chunk_count as usize)
.unwrap_or(0),
});
}
let (skip_stats, preread_bytes) = self.skip_unchanged_target(&relative_path).await?;
if let Some(stats) = skip_stats {
let stats = match self.store.prune_missing_files(self.config.as_ref()).await {
Ok(Some(pruned)) => IndexStats {
file_count: pruned.file_count,
chunk_count: pruned.chunk_count,
},
_ => stats,
};
return Ok(stats);
}
let enumerator =
FileEnumerator::new(self.project_root.clone(), self.config.as_ref().clone())?;
let force_indexed = PathFilters::for_path(&self.project_root, &relative_path)?
.is_force_included(&relative_path);
let Some(file) = enumerator.enumerate_one_with_bytes(
relative_path.clone(),
force_indexed,
preread_bytes,
)?
else {
let stats = self
.store
.delete_file_chunks(&relative_path, self.config.as_ref())
.await?;
let stats = match self.store.prune_missing_files(self.config.as_ref()).await {
Ok(Some(pruned)) => pruned,
_ => stats,
};
return Ok(IndexStats {
file_count: stats.file_count,
chunk_count: stats.chunk_count,
});
};
let chunks = self.collect_file_chunks(&file).await?;
let embedded_chunks = self.embed_chunks(chunks).await?;
let stats = if embedded_chunks.is_empty() {
let stats = self
.store
.delete_file_chunks(&relative_path, self.config.as_ref())
.await?;
self.store
.note_file_hash(&relative_path, file.file_hash.0, self.config.as_ref())?;
stats
} else {
self.store
.replace_file_chunks(&embedded_chunks, self.config.as_ref())
.await?
};
let stats = match self.store.prune_missing_files(self.config.as_ref()).await {
Ok(Some(pruned)) => pruned,
_ => stats,
};
if !embedded_chunks.is_empty() && self.config.hooks.surface_related_on_edit {
self.write_change_neighbors_marker(&relative_path, &embedded_chunks)
.await;
}
Ok(IndexStats {
file_count: stats.file_count,
chunk_count: stats.chunk_count,
})
}
async fn write_change_neighbors_marker(
&self,
relative_path: &RelativePath,
embedded_chunks: &[EmbeddedChunk],
) {
let query_vectors: Vec<Vec<f32>> =
embedded_chunks.iter().map(|ec| ec.vector.clone()).collect();
let Ok(all_rows) = self.store.read_chunks().await else {
return;
};
let exclude = relative_path.clone();
let top_k = self.config.hooks.related_top_k;
let min_similarity = self.config.hooks.related_min_similarity;
let Ok(hits) = task::spawn_blocking(move || {
neighbors(&all_rows, &query_vectors, &exclude, top_k, min_similarity)
})
.await
else {
return;
};
if hits.is_empty() {
return;
}
let Ok(store) = Store::new(&self.project_root, self.config.as_ref()) else {
return;
};
let marker_path = store.change_neighbors_marker_path();
let entries: Vec<NeighborEntry> = hits
.iter()
.map(|n| NeighborEntry {
file_path: n.file_path.clone(),
line_start: n.line_start,
line_end: n.line_end,
name: n.name.clone(),
score: n.score,
})
.collect();
write_neighbors_marker(
&marker_path,
&ChangeNeighborsMarker {
edited_path: relative_path.as_str().to_owned(),
neighbors: entries,
},
);
}
pub async fn search(&self, query: SearchQuery) -> Result<SearchResults> {
let searcher = Searcher::new(
self.project_root.clone(),
self.store.clone(),
Arc::clone(&self.embedder),
self.config.search.clone(),
);
searcher.search(query).await
}
pub async fn embedder_health_check(&self) -> Result<()> {
self.embedder.health_check().await
}
async fn skip_unchanged_target(
&self,
relative_path: &RelativePath,
) -> Result<(Option<IndexStats>, Option<Vec<u8>>)> {
let Some(manifest) = self.store.read_manifest()? else {
return Ok((None, None));
};
let Some(stored_hash) = manifest.file_hashes.get(relative_path.as_str()).copied() else {
return Ok((None, None));
};
if manifest.embedding_model != self.config.embedding.model
|| manifest.dimensions != self.config.embedding.dimensions
{
return Ok((None, None));
}
let absolute_path = self.project_root.join(relative_path.to_path_buf());
let bytes = match fs::read(&absolute_path).await {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((None, None)),
Err(error) => return Err(error.into()),
};
if bytes.len() as u64 > self.config.indexing.max_file_size_kb.saturating_mul(1024) {
return Ok((None, None));
}
if enumeration::hash_bytes(&bytes).0 != stored_hash {
return Ok((None, Some(bytes)));
}
Ok((
Some(IndexStats {
file_count: usize::try_from(manifest.file_count).unwrap_or(usize::MAX),
chunk_count: usize::try_from(manifest.chunk_count).unwrap_or(usize::MAX),
}),
None,
))
}
async fn collect_file_chunks(&self, file: &EnumeratedFile) -> Result<Vec<Chunk>> {
let content = if let Some(bytes) = &file.content {
match String::from_utf8(bytes.clone()) {
Ok(s) => s,
Err(_) => return Ok(Vec::new()),
}
} else {
match fs::read_to_string(&file.absolute_path).await {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::InvalidData => {
return Ok(Vec::new());
}
Err(error) => return Err(error.into()),
}
};
let path = file.relative_path.clone();
let language = file.language;
let file_hash = file.file_hash;
let force_indexed = file.force_indexed;
let overlap_lines = self.config.indexing.chunk_overlap_lines;
task::spawn_blocking(move || {
let chunker = MultiLanguageChunker::with_fallback_params(
chunking::DEFAULT_CHUNK_LINES,
overlap_lines,
);
if force_indexed && language == Language::Unknown {
chunker.chunk_as_text(&path, language, file_hash, &content)
} else {
chunker.chunk(&path, language, file_hash, &content)
}
})
.await
.map_err(|error| ClaudixError::TreeSitter(error.to_string()))?
}
async fn embed_chunks(&self, chunks: Vec<Chunk>) -> Result<Vec<EmbeddedChunk>> {
let mut embedded_chunks = Vec::with_capacity(chunks.len());
let batch_size = self.config.embedding.batch_size;
let expected_dimensions = self.embedder.dimensions();
for batch in chunks.chunks(batch_size) {
let inputs = batch
.iter()
.map(|chunk| chunk.content.as_str())
.collect::<Vec<_>>();
let vectors = self.embedder.embed(&inputs).await?;
if vectors.len() != batch.len() {
return Err(ClaudixError::Embedding(format!(
"provider returned {} vectors for {} chunks",
vectors.len(),
batch.len()
)));
}
for (chunk, vector) in batch.iter().cloned().zip(vectors) {
let actual_dimensions = u16::try_from(vector.len()).unwrap_or(u16::MAX);
if actual_dimensions != expected_dimensions.0 {
return Err(ClaudixError::DimensionMismatch {
store_dim: expected_dimensions.0,
model_dim: actual_dimensions,
recovery: RecoveryHint(hints::REINDEX_ALIGN_DIMENSIONS),
});
}
embedded_chunks.push(EmbeddedChunk { chunk, vector });
}
}
Ok(embedded_chunks)
}
fn relative_path_from_input(&self, path: &Path) -> Result<RelativePath> {
if path.is_absolute() {
let relative =
path.strip_prefix(&self.project_root)
.map_err(|_| ClaudixError::PathTraversal {
path: path.to_path_buf(),
recovery: RecoveryHint(hints::REINDEX_INSIDE_PROJECT_DIR),
})?;
reject_path_escape(relative, hints::REINDEX_INSIDE_PROJECT_DIR)?;
return Ok(RelativePath::from_path(relative));
}
reject_path_escape(path, hints::REINDEX_INSIDE_PROJECT_DIR)?;
Ok(RelativePath::from_path(path))
}
}
async fn build_provider(config: &Config) -> Result<Arc<dyn Provider>> {
let dimensions = Dimension(config.embedding.dimensions);
#[cfg(any(test, feature = "test-stub"))]
if config.embedding.model.starts_with("stub") {
return Ok(Arc::new(StubProvider::with_model_id(
config.embedding.model.clone(),
dimensions,
)) as Arc<dyn Provider>);
}
match config.embedding.provider {
EmbeddingProvider::Bundled => Ok(Arc::new(
BundledProvider::new(config.embedding.model.clone(), dimensions).await?,
) as Arc<dyn Provider>),
EmbeddingProvider::Http => {
let primary = Arc::new(HttpProvider::new(
config.embedding.endpoint.clone(),
config.embedding.model.clone(),
dimensions,
Duration::from_millis(config.embedding.timeout_ms),
None,
)?) as Arc<dyn Provider>;
if config.embedding.model == BUNDLED_MODEL_ID && dimensions == BUNDLED_DIMENSIONS {
let fallback = Arc::new(
BundledProvider::new(config.embedding.model.clone(), dimensions).await?,
) as Arc<dyn Provider>;
Ok(Arc::new(FallbackProvider::new(primary, fallback)) as Arc<dyn Provider>)
} else {
Ok(primary)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embedding::StubProvider;
use crate::store::marker::change_neighbors as cn_marker;
use async_trait::async_trait;
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicUsize, Ordering};
mod fixture {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/fixture.rs"
));
}
mod config_support {
use crate as claudix;
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/config_support.rs"
));
}
use config_support::stub_config;
use fixture::TestFixture;
fn test_claudix(project_root: PathBuf, config: Config) -> Result<Claudix> {
let store = Store::new(&project_root, &config)?;
let embedder: Arc<dyn Provider> = Arc::new(StubProvider::with_model_id(
config.embedding.model.clone(),
Dimension(config.embedding.dimensions),
));
Ok(Claudix {
config: Arc::new(config),
project_root,
embedder,
store,
})
}
struct CountingProvider {
inner: StubProvider,
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl Provider for CountingProvider {
fn name(&self) -> &str {
self.inner.name()
}
fn dimensions(&self) -> Dimension {
self.inner.dimensions()
}
fn model_id(&self) -> &str {
self.inner.model_id()
}
async fn embed(&self, batch: &[&str]) -> Result<Vec<Vec<f32>>> {
self.calls.fetch_add(batch.len(), Ordering::Relaxed);
self.inner.embed(batch).await
}
async fn health_check(&self) -> Result<()> {
self.inner.health_check().await
}
}
struct InvocationCountingProvider {
inner: StubProvider,
invocations: Arc<AtomicUsize>,
}
#[async_trait]
impl Provider for InvocationCountingProvider {
fn name(&self) -> &str {
self.inner.name()
}
fn dimensions(&self) -> Dimension {
self.inner.dimensions()
}
fn model_id(&self) -> &str {
self.inner.model_id()
}
async fn embed(&self, batch: &[&str]) -> Result<Vec<Vec<f32>>> {
self.invocations.fetch_add(1, Ordering::Relaxed);
self.inner.embed(batch).await
}
async fn health_check(&self) -> Result<()> {
self.inner.health_check().await
}
}
struct RotatingProvider {
dimension: Dimension,
vectors: Vec<Vec<f32>>,
calls: std::sync::Mutex<usize>,
}
impl RotatingProvider {
fn new(dimension: Dimension, vectors: Vec<Vec<f32>>) -> Self {
Self {
dimension,
vectors,
calls: std::sync::Mutex::new(0),
}
}
}
#[async_trait]
impl Provider for RotatingProvider {
fn name(&self) -> &str {
"rotating"
}
fn dimensions(&self) -> Dimension {
self.dimension
}
fn model_id(&self) -> &str {
"stub-v1"
}
async fn embed(&self, batch: &[&str]) -> Result<Vec<Vec<f32>>> {
let mut idx = self.calls.lock().unwrap_or_else(|e| e.into_inner());
let result = batch
.iter()
.map(|_| {
let v = self.vectors[*idx % self.vectors.len()].clone();
*idx += 1;
v
})
.collect();
Ok(result)
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
}
fn test_claudix_with_embedder(
project_root: PathBuf,
config: Config,
embedder: Arc<dyn Provider>,
) -> Result<Claudix> {
let store = Store::new(&project_root, &config)?;
Ok(Claudix {
config: Arc::new(config),
project_root,
embedder,
store,
})
}
#[tokio::test]
async fn index_full_persists_fixture_chunks() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config.clone());
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
let stats = claudix.index_full(&mut ()).await;
assert!(stats.is_ok());
assert_eq!(
stats.ok().unwrap_or_else(|| unreachable!()),
IndexStats {
file_count: 2,
chunk_count: 3,
}
);
let rows = claudix.store.read_chunks().await;
assert!(rows.is_ok());
let rows = rows.ok().unwrap_or_else(|| unreachable!());
let names = rows
.iter()
.filter_map(|row| row.name.clone())
.collect::<BTreeSet<_>>();
assert!(names.contains("greet"));
assert!(names.contains("add"));
assert!(rows.iter().all(|row| row.vector.len() == 8));
let manifest = claudix.store.read_manifest();
assert!(manifest.is_ok());
let manifest = manifest.ok().unwrap_or_else(|| unreachable!());
assert!(manifest.is_some());
let manifest = manifest.unwrap_or_else(|| unreachable!());
assert_eq!(manifest.embedding_model, "stub-v1");
assert_eq!(manifest.dimensions, 8);
assert_eq!(manifest.file_count, 2);
assert_eq!(manifest.chunk_count, 3);
}
#[tokio::test]
async fn index_full_replaces_stale_chunks() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config);
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
assert!(claudix.index_full(&mut ()).await.is_ok());
assert!(
fs::write(
fixture.root().join("src/lib.rs"),
"pub mod math;\n\npub fn salute(name: &str) -> String {\n format!(\"hi {name}\")\n}\n\npub fn wave(name: &str) -> String {\n format!(\"bye {name}\")\n}\n",
)
.await
.is_ok()
);
let stats = claudix.index_full(&mut ()).await;
assert!(stats.is_ok());
assert_eq!(
stats.ok().unwrap_or_else(|| unreachable!()),
IndexStats {
file_count: 2,
chunk_count: 3,
}
);
let rows = claudix.store.read_chunks().await;
assert!(rows.is_ok());
let rows = rows.ok().unwrap_or_else(|| unreachable!());
let names = rows
.iter()
.filter_map(|row| row.name.clone())
.collect::<BTreeSet<_>>();
assert!(names.contains("salute"));
assert!(!names.contains("greet"));
}
#[tokio::test]
async fn reindex_file_updates_only_target_file() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config);
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
assert!(claudix.index_full(&mut ()).await.is_ok());
assert!(
fs::write(
fixture.root().join("src/math.rs"),
"pub fn multiply(left: i32, right: i32) -> i32 {\n left * right\n}\n",
)
.await
.is_ok()
);
let stats = claudix.reindex_file(Path::new("src/math.rs")).await;
assert!(stats.is_ok());
assert_eq!(
stats.ok().unwrap_or_else(|| unreachable!()),
IndexStats {
file_count: 2,
chunk_count: 3,
}
);
let rows = claudix.store.read_chunks().await;
assert!(rows.is_ok());
let rows = rows.ok().unwrap_or_else(|| unreachable!());
let names = rows
.iter()
.filter_map(|row| row.name.clone())
.collect::<BTreeSet<_>>();
assert!(names.contains("greet"));
assert!(names.contains("multiply"));
assert!(!names.contains("add"));
}
#[tokio::test]
async fn reindex_file_skips_embedding_when_hash_unchanged() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config);
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
assert!(claudix.index_full(&mut ()).await.is_ok());
let stats = claudix.reindex_file(Path::new("src/math.rs")).await;
assert!(stats.is_ok());
let stats = stats.ok().unwrap_or_else(|| unreachable!());
assert_eq!(stats.chunk_count, 3);
}
#[tokio::test]
async fn reindex_file_unchanged_skip_triggers_zero_embed_calls() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config)?;
claudix.index_full(&mut ()).await?;
let calls = Arc::new(AtomicUsize::new(0));
let embedder: Arc<dyn Provider> = Arc::new(CountingProvider {
inner: StubProvider::with_model_id(
claudix.config().embedding.model.clone(),
Dimension(claudix.config().embedding.dimensions),
),
calls: calls.clone(),
});
let c2 = test_claudix_with_embedder(
claudix.project_root().to_path_buf(),
claudix.config().clone(),
embedder,
)?;
c2.reindex_file(Path::new("src/math.rs")).await?;
assert_eq!(
calls.load(Ordering::Relaxed),
0,
"unchanged file must skip embed entirely"
);
Ok(())
}
#[tokio::test]
async fn reindex_file_records_hash_for_no_chunk_file() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
fs::write(fixture.root().join("binary.bin"), [0xff, 0xfe, 0xfd]).await?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config)?;
claudix.index_full(&mut ()).await?;
let (hash_before, _) = claudix
.store
.stored_file_hash_and_stats(&RelativePath::new("binary.bin"))
.await?;
assert!(
hash_before.is_some(),
"hash must be stored for no-chunk file after index_full"
);
let calls_before = {
let calls = Arc::new(AtomicUsize::new(0));
let embedder: Arc<dyn Provider> = Arc::new(CountingProvider {
inner: StubProvider::with_model_id(
claudix.config().embedding.model.clone(),
Dimension(claudix.config().embedding.dimensions),
),
calls: calls.clone(),
});
let c2 = test_claudix_with_embedder(
claudix.project_root().to_path_buf(),
claudix.config().clone(),
embedder,
)?;
c2.reindex_file(std::path::Path::new("binary.bin")).await?;
calls.load(Ordering::Relaxed)
};
assert_eq!(
calls_before, 0,
"unchanged no-chunk file must not trigger embedding"
);
Ok(())
}
#[tokio::test]
async fn index_full_skips_unchanged_files_without_chunks() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
fs::write(fixture.root().join("src/empty.rs"), "pub mod child;\n").await?;
let config = stub_config();
let calls = Arc::new(AtomicUsize::new(0));
let embedder: Arc<dyn Provider> = Arc::new(CountingProvider {
inner: StubProvider::with_model_id(
config.embedding.model.clone(),
Dimension(config.embedding.dimensions),
),
calls: calls.clone(),
});
let claudix = test_claudix_with_embedder(fixture.root().to_path_buf(), config, embedder)?;
claudix.index_full(&mut ()).await?;
let first_call_count = calls.load(Ordering::Relaxed);
claudix.index_full(&mut ()).await?;
assert_eq!(calls.load(Ordering::Relaxed), first_call_count);
Ok(())
}
#[tokio::test]
async fn index_full_preserves_unchanged_file_chunks_on_second_run() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config)?;
claudix.index_full(&mut ()).await?;
fs::write(
fixture.root().join("src/lib.rs"),
"pub mod math;\n\npub fn salute(name: &str) -> String { format!(\"hi {name}\") }\n",
)
.await?;
claudix.index_full(&mut ()).await?;
let rows = claudix.store.read_chunks().await?;
let names: BTreeSet<_> = rows.iter().filter_map(|r| r.name.clone()).collect();
assert!(names.contains("salute"), "changed file must be re-embedded");
assert!(!names.contains("greet"), "stale chunk must be gone");
assert!(
names.contains("add"),
"unchanged file chunks must be preserved"
);
Ok(())
}
#[tokio::test]
async fn index_full_skips_lancedb_rewrite_when_nothing_changed() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config)?;
claudix.index_full(&mut ()).await?;
let chunks_dir = claudix
.store
.state_dir_path()
.join("index")
.join("chunks.lance");
let before = snapshot_dir(&chunks_dir);
assert!(
!before.is_empty(),
"first index_full should have written chunks.lance"
);
claudix.index_full(&mut ()).await?;
let after = snapshot_dir(&chunks_dir);
assert_eq!(
before, after,
"chunks.lance must not be rewritten when every file is verified as unchanged"
);
let manifest = claudix.store.read_manifest()?;
let manifest = manifest.unwrap_or_else(|| unreachable!());
assert!(
manifest.last_full_index_at.is_some(),
"verification run must still bump last_full_index_at"
);
Ok(())
}
#[tokio::test]
async fn index_full_takes_manifest_first_early_exit_when_hashes_match() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config.clone())?;
let first = claudix.index_full(&mut ()).await?;
assert!(first.file_count > 0);
let manifest = claudix
.store
.read_manifest()?
.unwrap_or_else(|| unreachable!());
assert!(
!manifest.file_hashes.is_empty(),
"first index_full must populate file_hashes"
);
assert!(
claudix.store.manifest_hashes_match(
&manifest
.file_hashes
.iter()
.map(|(p, h)| (p.clone(), *h))
.collect::<Vec<_>>(),
&config
)?,
"manifest_hashes_match must return true before second index_full"
);
let second = claudix.index_full(&mut ()).await?;
assert_eq!(
first, second,
"early-exit must return the same stats as the first index"
);
let manifest2 = claudix
.store
.read_manifest()?
.unwrap_or_else(|| unreachable!());
assert!(
manifest2.last_full_index_at.is_some(),
"early-exit must still bump last_full_index_at"
);
Ok(())
}
#[tokio::test]
async fn index_full_falls_through_when_manifest_file_hashes_empty() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config.clone())?;
claudix.store.ensure_layout()?;
let mut manifest =
crate::store::Manifest::new(&config.embedding.model, config.embedding.dimensions);
manifest.file_count = 0;
manifest.chunk_count = 0;
claudix.store.write_manifest(&manifest)?;
let current: Vec<(String, [u8; 16])> = vec![("src/lib.rs".to_owned(), [1u8; 16])];
assert!(
!claudix.store.manifest_hashes_match(¤t, &config)?,
"empty file_hashes must not trigger the manifest-first guard"
);
let stats = claudix.index_full(&mut ()).await?;
assert!(stats.file_count > 0);
Ok(())
}
#[tokio::test]
async fn index_full_rechunks_force_included_zero_chunk_doc() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
fs::write(fixture.root().join(".gitignore"), "docs/\n").await?;
let doc = "# Internals\n\nLoad-bearing design notes for the project.\n";
fs::create_dir_all(fixture.root().join("docs")).await?;
fs::write(fixture.root().join("docs/internals.md"), doc).await?;
let claudix = test_claudix(fixture.root().to_path_buf(), config)?;
claudix.index_full(&mut ()).await?;
let baseline = claudix.store.read_chunks().await?;
assert!(
baseline.iter().all(|r| r.file_path != "docs/internals.md"),
"doc must not be indexed before its rule exists"
);
claudix.store.note_file_hash(
&RelativePath::new("docs/internals.md"),
crate::enumeration::hash_bytes(doc.as_bytes()).0,
claudix.config.as_ref(),
)?;
fs::write(fixture.root().join(".indexinclude"), "docs/**\n").await?;
claudix.index_full(&mut ()).await?;
let rows = claudix.store.read_chunks().await?;
assert!(
rows.iter().any(|r| r.file_path == "docs/internals.md"),
"force-included doc must re-chunk on incremental reindex after rule add"
);
claudix.index_full(&mut ()).await?;
let rows = claudix.store.read_chunks().await?;
assert!(
rows.iter().any(|r| r.file_path == "docs/internals.md"),
"doc must stay indexed on subsequent reindexes"
);
Ok(())
}
#[tokio::test]
async fn index_full_batches_embed_calls_across_files() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let mut config = stub_config();
config.embedding.batch_size = 1;
let invocations = Arc::new(AtomicUsize::new(0));
let embedder: Arc<dyn Provider> = Arc::new(InvocationCountingProvider {
inner: StubProvider::with_model_id(
config.embedding.model.clone(),
Dimension(config.embedding.dimensions),
),
invocations: invocations.clone(),
});
let claudix = test_claudix_with_embedder(fixture.root().to_path_buf(), config, embedder)?;
let stats = claudix.index_full(&mut ()).await?;
let total_chunks = stats.chunk_count;
let observed = invocations.load(Ordering::Relaxed);
assert_eq!(
observed, total_chunks,
"expected {total_chunks} embed invocations (batch_size=1, cross-file), got {observed}"
);
Ok(())
}
fn snapshot_dir(dir: &Path) -> BTreeSet<(PathBuf, u64)> {
fn walk(dir: &Path, into: &mut BTreeSet<(PathBuf, u64)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_dir() {
walk(&path, into);
} else {
into.insert((path, metadata.len()));
}
}
}
let mut set = BTreeSet::new();
walk(dir, &mut set);
set
}
#[tokio::test]
async fn index_full_skips_invalid_utf8_files() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
assert!(
fs::write(fixture.root().join("binary.rs"), [0xff, 0xfe, 0xfd])
.await
.is_ok()
);
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config);
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
let stats = claudix.index_full(&mut ()).await;
assert!(stats.is_ok());
assert_eq!(
stats.ok().unwrap_or_else(|| unreachable!()),
IndexStats {
file_count: 2,
chunk_count: 3,
}
);
}
#[tokio::test]
async fn reindex_file_deletes_missing_file_chunks() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config);
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
assert!(claudix.index_full(&mut ()).await.is_ok());
assert!(
fs::remove_file(fixture.root().join("src/math.rs"))
.await
.is_ok()
);
let stats = claudix.reindex_file(Path::new("src/math.rs")).await;
assert!(stats.is_ok());
assert_eq!(
stats.ok().unwrap_or_else(|| unreachable!()),
IndexStats {
file_count: 1,
chunk_count: 2,
}
);
let rows = claudix.store.read_chunks().await;
assert!(rows.is_ok());
let rows = rows.ok().unwrap_or_else(|| unreachable!());
assert!(rows.iter().all(|row| row.file_path == "src/lib.rs"));
}
#[tokio::test]
async fn reindex_file_prunes_chunks_for_out_of_band_deletion() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config);
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
assert!(claudix.index_full(&mut ()).await.is_ok());
assert!(
fs::remove_file(fixture.root().join("src/math.rs"))
.await
.is_ok()
);
assert!(
fs::write(
fixture.root().join("src/lib.rs"),
b"pub fn greet() -> &'static str {\n \"hello\"\n}\n",
)
.await
.is_ok()
);
let stats = claudix.reindex_file(Path::new("src/lib.rs")).await;
assert!(stats.is_ok());
let rows = claudix.store.read_chunks().await;
assert!(rows.is_ok());
let rows = rows.ok().unwrap_or_else(|| unreachable!());
assert!(
rows.iter().all(|row| row.file_path != "src/math.rs"),
"chunks for a file deleted out-of-band must be pruned on the next per-file reindex"
);
assert!(
rows.iter().any(|row| row.file_path == "src/lib.rs"),
"the reindexed file's chunks must remain"
);
}
#[tokio::test]
async fn reindex_file_unchanged_prunes_out_of_band_deleted_chunks() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config)?;
claudix.index_full(&mut ()).await?;
fs::remove_file(fixture.root().join("src/math.rs")).await?;
claudix.reindex_file(Path::new("src/lib.rs")).await?;
let rows = claudix.store.read_chunks().await?;
assert!(
rows.iter().all(|row| row.file_path != "src/math.rs"),
"the hash-unchanged skip must prune chunks for a file deleted out-of-band"
);
assert!(
rows.iter().any(|row| row.file_path == "src/lib.rs"),
"the unchanged reindexed file's chunks must remain"
);
Ok(())
}
#[tokio::test]
async fn reindex_file_removes_stale_chunks_when_file_becomes_empty() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config);
assert!(claudix.is_ok());
let claudix = claudix.ok().unwrap_or_else(|| unreachable!());
assert!(claudix.index_full(&mut ()).await.is_ok());
assert!(
fs::write(fixture.root().join("src/math.rs"), b"")
.await
.is_ok()
);
let stats = claudix.reindex_file(Path::new("src/math.rs")).await;
assert!(stats.is_ok());
let rows = claudix.store.read_chunks().await;
assert!(rows.is_ok());
let rows = rows.ok().unwrap_or_else(|| unreachable!());
assert!(
rows.iter().all(|row| row.file_path != "src/math.rs"),
"stale chunks from emptied file must be removed"
);
}
#[tokio::test]
async fn reindex_file_skips_index_internal_paths() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config)?;
claudix.index_full(&mut ()).await?;
let baseline = claudix.store.read_chunks().await?;
let internal = fixture.root().join(".claudix").join("stray.rs");
if let Some(parent) = internal.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&internal, b"pub fn stray() -> u32 { 1 }\n").await?;
let stats = claudix.reindex_file(Path::new(".claudix/stray.rs")).await?;
assert_eq!(stats.chunk_count, baseline.len());
let after = claudix.store.read_chunks().await?;
assert!(
after
.iter()
.all(|row| !row.file_path.starts_with(".claudix")),
"no chunk under .claudix/ should be embedded"
);
Ok(())
}
fn claudix_with_rotating(
project_root: std::path::PathBuf,
mut config: Config,
vectors: Vec<Vec<f32>>,
) -> Result<Claudix> {
config.embedding.dimensions = vectors.first().map(|v| v.len() as u16).unwrap_or(8);
let store = Store::new(&project_root, &config)?;
let embedder: Arc<dyn Provider> = Arc::new(RotatingProvider::new(
Dimension(config.embedding.dimensions),
vectors,
));
Ok(Claudix {
config: Arc::new(config),
project_root,
embedder,
store,
})
}
async fn seed_chunk(
store: &Store,
config: &Config,
file_path: &str,
name: &str,
vector: Vec<f32>,
) -> Result<()> {
use crate::types::{ByteRange, ChunkId, ChunkKind, EmbeddedChunk, FileHash, LineRange};
let chunk = Chunk {
id: ChunkId(1),
file_path: RelativePath::new(file_path),
language: crate::types::Language::Rust,
kind: ChunkKind::Function,
name: Some(name.to_owned()),
line_range: LineRange { start: 1, end: 5 },
byte_range: ByteRange { start: 0, end: 50 },
file_hash: FileHash([0u8; 16]),
content: format!("pub fn {name}() {{}}"),
};
let embedded = EmbeddedChunk { chunk, vector };
store.replace_chunks(&[embedded], config).await?;
Ok(())
}
#[tokio::test]
async fn reindex_file_writes_change_neighbors_marker_for_near_duplicate() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let mut config = stub_config();
config.hooks.surface_related_on_edit = true;
config.hooks.related_top_k = 5;
config.hooks.related_min_similarity = 0.0;
let shared_vector = vec![1.0_f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let claudix = claudix_with_rotating(
fixture.root().to_path_buf(),
config.clone(),
vec![shared_vector.clone()],
)?;
claudix.store.ensure_layout()?;
seed_chunk(
&claudix.store,
claudix.config.as_ref(),
"src/other.rs",
"other_fn",
shared_vector,
)
.await?;
tokio::fs::write(
fixture.root().join("src/other.rs"),
b"pub fn other_fn() {}\n",
)
.await?;
tokio::fs::write(
fixture.root().join("src/lib.rs"),
b"pub fn greet(name: &str) -> String { format!(\"Hello, {name}!\") }\n",
)
.await?;
claudix.reindex_file(Path::new("src/lib.rs")).await?;
let marker_path = claudix.store.change_neighbors_marker_path();
assert!(
marker_path.exists(),
"change-neighbors marker must be written after editing a file with a near-duplicate"
);
let marker = cn_marker::read(&marker_path);
assert!(marker.is_some(), "marker must parse correctly");
let marker = marker.unwrap_or_else(|| unreachable!());
assert_eq!(marker.edited_path, "src/lib.rs");
assert!(
marker
.neighbors
.iter()
.any(|n| n.file_path == "src/other.rs"),
"near-duplicate src/other.rs must appear in marker neighbors"
);
assert!(
marker.neighbors.iter().all(|n| n.file_path != "src/lib.rs"),
"edited file must not appear in its own neighbor list"
);
Ok(())
}
#[tokio::test]
async fn reindex_file_no_marker_when_no_similar_code() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let mut config = stub_config();
config.hooks.surface_related_on_edit = true;
config.hooks.related_top_k = 5;
config.hooks.related_min_similarity = 1.1;
let claudix = claudix_with_rotating(
fixture.root().to_path_buf(),
config.clone(),
vec![vec![1.0_f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
)?;
claudix.store.ensure_layout()?;
seed_chunk(
&claudix.store,
claudix.config.as_ref(),
"src/other.rs",
"other_fn",
vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
)
.await?;
tokio::fs::write(fixture.root().join("src/lib.rs"), b"pub fn greet() {}\n").await?;
claudix.reindex_file(Path::new("src/lib.rs")).await?;
assert!(
!claudix.store.change_neighbors_marker_path().exists(),
"no marker must be written when nothing passes the similarity floor"
);
Ok(())
}
#[tokio::test]
async fn reindex_file_no_marker_when_feature_disabled() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let mut config = stub_config();
config.hooks.surface_related_on_edit = false;
config.hooks.related_min_similarity = 0.0;
let shared_vector = vec![1.0_f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let claudix = claudix_with_rotating(
fixture.root().to_path_buf(),
config.clone(),
vec![shared_vector.clone()],
)?;
claudix.store.ensure_layout()?;
seed_chunk(
&claudix.store,
claudix.config.as_ref(),
"src/other.rs",
"other_fn",
shared_vector,
)
.await?;
tokio::fs::write(fixture.root().join("src/lib.rs"), b"pub fn greet() {}\n").await?;
claudix.reindex_file(Path::new("src/lib.rs")).await?;
assert!(
!claudix.store.change_neighbors_marker_path().exists(),
"no marker must be written when surface_related_on_edit = false"
);
Ok(())
}
#[tokio::test]
async fn index_full_rebuilds_after_chunks_table_corruption() -> Result<()> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config.clone())?;
let first = claudix.index_full(&mut ()).await?;
assert!(
first.chunk_count > 0,
"fixture must produce at least one chunk"
);
claudix.store.drop_chunks_table_for_test().await?;
let matches = claudix.store.table_matches_manifest_chunk_count().await?;
assert!(
!matches,
"table_matches_manifest_chunk_count must return false when table is dropped"
);
let second = claudix.index_full(&mut ()).await?;
assert_eq!(
second.chunk_count, first.chunk_count,
"index_full must rebuild to the original chunk count after corruption"
);
let rows = claudix.store.read_chunks().await?;
assert_eq!(
rows.len(),
second.chunk_count,
"stored chunk rows must match the reported chunk_count after rebuild"
);
Ok(())
}
}