mod input;
mod install;
mod watch;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::Serialize;
use crate::config;
use crate::error::{ClaudixError, RecoveryHint, Result};
use crate::hooks::HookEvent;
use crate::search::SearchQuery;
use crate::search::duplicates::{self, LabeledChunk};
pub use crate::search::duplicates::{DuplicateChunk, DuplicatePair};
use crate::store::{IndexLockGuard, Store};
use crate::types::{RelativePath, path_prefix_matches};
use crate::{Claudix, IndexFileStatus, IndexProgress};
use input::{
parse_language_filter, parse_path_prefix, validate_search_query, validate_search_top_k,
};
const TOP_IDENTIFIERS_CAP: usize = 8;
pub(crate) const DEFAULT_MIN_SIMILARITY: f32 = 0.85;
pub(crate) const DEFAULT_DUPLICATE_LIMIT: usize = 50;
pub use install::{run_install, setup_state};
pub use watch::run_watch;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SearchHit {
pub repo: String,
pub file_path: String,
pub language: String,
pub kind: String,
pub name: Option<String>,
pub line_start: u32,
pub line_end: u32,
pub score: f32,
pub stale: bool,
pub snippet: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DirectoryGroup {
pub repo: String,
pub directory: String,
pub hits: Vec<SearchHit>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SearchOutput {
pub groups: Vec<DirectoryGroup>,
pub repo_errors: Vec<RepoError>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexOutput {
pub file_count: usize,
pub chunk_count: usize,
}
pub struct StderrIndexProgress;
impl IndexProgress for StderrIndexProgress {
fn file(&mut self, path: &RelativePath, status: IndexFileStatus) -> Result<()> {
let mut stderr = io::stderr().lock();
match status {
IndexFileStatus::Indexed => writeln!(stderr, "indexed {}", path.as_str())?,
IndexFileStatus::Verified => writeln!(stderr, "verified {}", path.as_str())?,
IndexFileStatus::Skipped(reason) => {
writeln!(stderr, "skipped {}: {reason}", path.as_str())?
}
}
stderr.flush()?;
Ok(())
}
}
struct FileIndexProgress {
writer: io::BufWriter<fs::File>,
}
impl FileIndexProgress {
fn try_open(log_dir: &Path) -> Option<Self> {
fs::create_dir_all(log_dir).ok()?;
fs::File::create(log_dir.join("index.log"))
.ok()
.map(|f| Self {
writer: io::BufWriter::new(f),
})
}
}
impl IndexProgress for FileIndexProgress {
fn file(&mut self, path: &RelativePath, status: IndexFileStatus) -> Result<()> {
match status {
IndexFileStatus::Indexed => writeln!(self.writer, "indexed {}", path.as_str())?,
IndexFileStatus::Verified => writeln!(self.writer, "verified {}", path.as_str())?,
IndexFileStatus::Skipped(reason) => {
writeln!(self.writer, "skipped {}: {reason}", path.as_str())?
}
}
self.writer.flush()?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ClearOutput {
pub cleared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StatusOutput {
pub chunk_count: usize,
pub file_count: usize,
pub model: Option<String>,
pub dimensions: Option<u16>,
pub last_full_index_at: Option<String>,
pub last_incremental_at: Option<String>,
pub stale: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DoctorOutput {
pub project_root: String,
pub index_present: bool,
pub chunk_count: usize,
pub file_count: usize,
pub model: Option<String>,
pub dimensions: Option<u16>,
pub embedding_provider: String,
pub embedding_healthy: bool,
pub embedding_error: Option<String>,
pub embedding_model_mismatch: bool,
pub development_mode: bool,
pub binary_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct InstallOutput {
pub plugin_root: String,
pub binary_path: String,
pub config_path: String,
pub wrote_config: bool,
pub embedding_healthy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SetupState {
Ready,
Missing(Vec<&'static str>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LanguageCount {
pub language: String,
pub chunk_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DirectoryRollup {
pub path: String,
pub file_count: usize,
pub chunk_count: usize,
pub languages: Vec<LanguageCount>,
pub top_identifiers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OverviewOutput {
pub directories: Vec<DirectoryRollup>,
pub file_count: usize,
pub chunk_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RepoError {
pub repo: String,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DuplicatesOutput {
pub pairs: Vec<DuplicatePair>,
pub repo_errors: Vec<RepoError>,
}
pub async fn run_overview(
project_root: impl AsRef<Path>,
path_prefix: Option<String>,
) -> Result<OverviewOutput> {
let project_root = canonical_project_root(project_root.as_ref())?;
let config = config::load(&project_root)?;
let store = Store::new(&project_root, &config)?;
let prefix: Option<RelativePath> = parse_path_prefix(path_prefix)?;
let chunks = store.read_chunks().await?;
let mut dir_files: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
let mut dir_chunks: HashMap<String, usize> = HashMap::new();
let mut dir_languages: HashMap<String, HashMap<String, usize>> = HashMap::new();
let mut dir_names: HashMap<String, HashMap<String, usize>> = HashMap::new();
for chunk in &chunks {
if let Some(ref prefix) = prefix
&& !path_prefix_matches(&chunk.file_path, prefix.as_str())
{
continue;
}
let dir = immediate_parent_dir(&chunk.file_path);
dir_files
.entry(dir.clone())
.or_default()
.insert(chunk.file_path.clone());
*dir_chunks.entry(dir.clone()).or_insert(0) += 1;
*dir_languages
.entry(dir.clone())
.or_default()
.entry(chunk.language.clone())
.or_insert(0) += 1;
if let Some(ref name) = chunk.name
&& !name.is_empty()
{
*dir_names
.entry(dir.clone())
.or_default()
.entry(name.clone())
.or_insert(0) += 1;
}
}
let mut directories: Vec<DirectoryRollup> = dir_files
.keys()
.map(|dir| {
let file_count = dir_files[dir].len();
let chunk_count = dir_chunks[dir];
let mut languages: Vec<LanguageCount> = dir_languages
.get(dir)
.map(|lang_map| {
lang_map
.iter()
.map(|(lang, count)| LanguageCount {
language: lang.clone(),
chunk_count: *count,
})
.collect()
})
.unwrap_or_default();
languages.sort_by(|a, b| {
b.chunk_count
.cmp(&a.chunk_count)
.then_with(|| a.language.cmp(&b.language))
});
let top_identifiers: Vec<String> = dir_names
.get(dir)
.map(|name_map| {
let mut pairs: Vec<(&String, usize)> =
name_map.iter().map(|(n, c)| (n, *c)).collect();
pairs.sort_by(|(a_name, a_count), (b_name, b_count)| {
b_count.cmp(a_count).then_with(|| a_name.cmp(b_name))
});
pairs.truncate(TOP_IDENTIFIERS_CAP);
pairs.into_iter().map(|(name, _)| name.clone()).collect()
})
.unwrap_or_default();
DirectoryRollup {
path: dir.clone(),
file_count,
chunk_count,
languages,
top_identifiers,
}
})
.collect();
directories.sort_by(|a, b| a.path.cmp(&b.path));
let file_count: usize = directories.iter().map(|d| d.file_count).sum();
let chunk_count: usize = directories.iter().map(|d| d.chunk_count).sum();
Ok(OverviewOutput {
directories,
file_count,
chunk_count,
})
}
async fn peek_manifest_identity(repo_path: &str) -> std::result::Result<(String, u16), RepoError> {
let config = config::load(std::path::Path::new(repo_path)).map_err(|e| RepoError {
repo: repo_path.to_owned(),
error: e.to_string(),
})?;
let store = Store::new(repo_path, &config).map_err(|e| RepoError {
repo: repo_path.to_owned(),
error: e.to_string(),
})?;
let manifest = store.read_manifest().map_err(|e| RepoError {
repo: store.project_root().display().to_string(),
error: e.to_string(),
})?;
match manifest {
Some(m) if m.chunk_count > 0 => Ok((m.embedding_model, m.dimensions)),
_ => Err(RepoError {
repo: repo_path.to_owned(),
error: "not indexed".to_owned(),
}),
}
}
pub(crate) async fn load_repo_chunks_readonly(
repo_path: &str,
ref_model: &str,
ref_dims: u16,
) -> std::result::Result<(String, Vec<crate::store::StoredChunk>), RepoError> {
let config = config::load(std::path::Path::new(repo_path)).map_err(|e| RepoError {
repo: repo_path.to_owned(),
error: e.to_string(),
})?;
let store = Store::new(repo_path, &config).map_err(|e| RepoError {
repo: repo_path.to_owned(),
error: e.to_string(),
})?;
let canonical_repo = store.project_root().display().to_string();
let manifest = store
.validate_manifest_compatibility(ref_model, ref_dims)
.map_err(|e| RepoError {
repo: canonical_repo.clone(),
error: e.to_string(),
})?;
let Some(manifest) = manifest else {
return Err(RepoError {
repo: canonical_repo,
error: "not indexed".to_owned(),
});
};
if manifest.chunk_count == 0 {
return Err(RepoError {
repo: canonical_repo,
error: "not indexed".to_owned(),
});
}
let chunks = store.read_chunks().await.map_err(|e| RepoError {
repo: canonical_repo.clone(),
error: e.to_string(),
})?;
if chunks.is_empty() {
return Err(RepoError {
repo: canonical_repo,
error: "index chunks missing".to_owned(),
});
}
Ok((canonical_repo, chunks))
}
pub async fn run_find_duplicates(
project_root: impl AsRef<Path>,
min_similarity: Option<f32>,
limit: Option<usize>,
repos: Option<Vec<String>>,
) -> Result<DuplicatesOutput> {
let project_root = canonical_project_root(project_root.as_ref())?;
let min_similarity = min_similarity.unwrap_or(DEFAULT_MIN_SIMILARITY);
if !min_similarity.is_finite() || !(0.0..=1.0).contains(&min_similarity) {
return Err(ClaudixError::ConfigInvalid {
message: "min_similarity must be between 0 and 1".to_owned(),
recovery: RecoveryHint("Use a finite min_similarity between 0 and 1"),
});
}
let limit = limit.unwrap_or(DEFAULT_DUPLICATE_LIMIT);
if limit == 0 {
return Err(ClaudixError::ConfigInvalid {
message: "limit must be at least 1".to_owned(),
recovery: RecoveryHint("Use a positive limit"),
});
}
let repo_paths: Vec<String> = match repos {
Some(list) if !list.is_empty() => list,
_ => vec![project_root.display().to_string()],
};
let mut all_chunks: Vec<crate::store::StoredChunk> = Vec::new();
let mut repo_labels: Vec<String> = Vec::new();
let mut repo_errors: Vec<RepoError> = Vec::new();
let mut ref_identity: Option<(String, u16)> = None;
for path in &repo_paths {
if ref_identity.is_none() {
match peek_manifest_identity(path).await {
Ok(identity) => ref_identity = Some(identity),
Err(err) => {
repo_errors.push(err);
continue;
}
}
}
let Some((ref_model, ref_dims)) = ref_identity.as_ref() else {
continue;
};
match load_repo_chunks_readonly(path, ref_model, *ref_dims).await {
Ok((canonical, chunks)) => {
for _ in &chunks {
repo_labels.push(canonical.clone());
}
all_chunks.extend(chunks);
}
Err(err) => {
repo_errors.push(err);
}
}
}
if all_chunks.is_empty() {
return Ok(DuplicatesOutput {
pairs: Vec::new(),
repo_errors,
});
}
let pairs = tokio::task::spawn_blocking(move || {
let labeled: Vec<LabeledChunk<'_>> = all_chunks
.iter()
.zip(repo_labels.iter())
.map(|(chunk, repo)| LabeledChunk {
repo: repo.as_str(),
chunk,
})
.collect();
duplicates::find_duplicates(&labeled, min_similarity, limit)
})
.await
.map_err(|e| ClaudixError::Store(format!("duplicate scan task failed: {e}")))?;
Ok(DuplicatesOutput { pairs, repo_errors })
}
pub(crate) fn immediate_parent_dir(file_path: &str) -> String {
match file_path.rfind('/') {
Some(pos) => file_path[..pos].to_owned(),
None => ".".to_owned(),
}
}
pub async fn run_search(
project_root: impl AsRef<Path>,
query: String,
top_k: Option<usize>,
language_filter: Option<Vec<String>>,
path_prefix: Option<String>,
repos: Option<Vec<String>>,
) -> Result<SearchOutput> {
validate_search_query(&query)?;
let project_root = canonical_project_root(project_root.as_ref())?;
let config = config::load(&project_root)?;
let top_k = top_k.unwrap_or(config.search.top_k);
validate_search_top_k(top_k)?;
let repos = effective_cross_repos(&config.search.cross_repos, repos);
let claudix = Claudix::new(project_root, Arc::new(config)).await?;
run_search_with_claudix(&claudix, query, top_k, language_filter, path_prefix, repos).await
}
fn effective_cross_repos(cross_repos: &[String], repos: Option<Vec<String>>) -> Vec<String> {
let mut seen = HashSet::new();
cross_repos
.iter()
.cloned()
.chain(repos.into_iter().flatten())
.filter(|repo| seen.insert(repo.clone()))
.collect()
}
pub async fn run_index(project_root: impl AsRef<Path>, progress: bool) -> Result<IndexOutput> {
let session = IndexSession::new(project_root).await?;
let log_dir = session
.claudix
.project_root()
.join(&session.claudix.config().paths.log_dir);
let mut stderr_progress = StderrIndexProgress;
let mut file_progress = (!progress)
.then(|| FileIndexProgress::try_open(&log_dir))
.flatten();
let progress: &mut dyn IndexProgress = if progress {
&mut stderr_progress
} else if let Some(ref mut fp) = file_progress {
fp
} else {
&mut ()
};
let stats = session.claudix.index_full(progress).await?;
Ok(IndexOutput {
file_count: stats.file_count,
chunk_count: stats.chunk_count,
})
}
struct IndexSession {
claudix: Claudix,
_lock: IndexLockGuard,
}
impl IndexSession {
async fn new(project_root: impl AsRef<Path>) -> Result<Self> {
let project_root = canonical_project_root(project_root.as_ref())?;
require_git_repo(&project_root)?;
let config = config::load(&project_root)?;
let store = Store::new(&project_root, &config)?;
let lock = store
.acquire_index_lock()
.ok_or_else(|| crate::error::ClaudixError::Store("index already running".to_owned()))?;
let claudix = match Claudix::new(project_root.clone(), Arc::new(config.clone())).await {
Ok(claudix) => claudix,
Err(error) if requires_clean_reindex(&error) => {
store.clear_chunks(&config).await?;
Claudix::new(project_root, Arc::new(config)).await?
}
Err(error) => return Err(error),
};
Ok(Self {
claudix,
_lock: lock,
})
}
}
pub async fn run_status(project_root: impl AsRef<Path>) -> Result<StatusOutput> {
let project_root = canonical_project_root(project_root.as_ref())?;
let config = config::load(&project_root)?;
let store = Store::new(&project_root, &config)?;
status_from_store(&store, &config).await
}
pub async fn run_reindex_file(
project_root: impl AsRef<Path>,
path: impl AsRef<Path>,
) -> Result<IndexOutput> {
let project_root = canonical_project_root(project_root.as_ref())?;
let config = config::load(&project_root)?;
let store = Store::new(&project_root, &config)?;
let _reindex_lock = store.acquire_reindex_lock()?;
let claudix = Claudix::new(project_root, Arc::new(config)).await?;
let stats = claudix.reindex_file(path.as_ref()).await?;
Ok(IndexOutput {
file_count: stats.file_count,
chunk_count: stats.chunk_count,
})
}
pub async fn run_doctor(project_root: impl AsRef<Path>) -> Result<DoctorOutput> {
let project_root = canonical_project_root(project_root.as_ref())?;
let config = config::load(&project_root)?;
let store = Store::new(&project_root, &config)?;
let status = status_from_store(&store, &config).await?;
let claudix = Claudix::new(project_root.clone(), Arc::new(config.clone())).await;
let (embedding_healthy, embedding_model_mismatch, embedding_error) = match claudix {
Ok(claudix) => match claudix.embedder_health_check().await {
Ok(()) => (true, false, None),
Err(error) => (false, false, Some(error.to_string())),
},
Err(ClaudixError::EmbeddingModelMismatch { .. }) => (false, true, None),
Err(error) => (false, false, Some(error.to_string())),
};
Ok(DoctorOutput {
project_root: project_root.display().to_string(),
index_present: status.chunk_count > 0 || status.model.is_some(),
chunk_count: status.chunk_count,
file_count: status.file_count,
model: status.model,
dimensions: status.dimensions,
embedding_provider: match config.embedding.provider {
config::EmbeddingProvider::Bundled => "bundled".to_owned(),
config::EmbeddingProvider::Http => "http".to_owned(),
},
embedding_healthy,
embedding_error,
embedding_model_mismatch,
development_mode: config.development_mode,
binary_path: std::env::current_exe()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "<unknown>".to_owned()),
})
}
pub async fn run_clear_index(project_root: impl AsRef<Path>) -> Result<ClearOutput> {
let project_root = canonical_project_root(project_root.as_ref())?;
let config = config::load(&project_root)?;
let store = Store::new(&project_root, &config)?;
store.clear_chunks(&config).await?;
Ok(ClearOutput { cleared: true })
}
fn requires_clean_reindex(error: &ClaudixError) -> bool {
matches!(
error,
ClaudixError::SchemaMismatch { .. }
| ClaudixError::EmbeddingModelMismatch { .. }
| ClaudixError::DimensionMismatch { .. }
)
}
pub fn parse_hook_event(value: &str) -> Result<HookEvent> {
match value {
"SessionStart" => Ok(HookEvent::SessionStart),
"PostToolUse" => Ok(HookEvent::PostToolUse),
"PreToolUse" => Ok(HookEvent::PreToolUse),
"UserPromptSubmit" => Ok(HookEvent::UserPromptSubmit),
_ => Err(ClaudixError::ConfigInvalid {
message: format!("unknown hook event: {value}"),
recovery: RecoveryHint(
"Use one of: SessionStart, PostToolUse, PreToolUse, UserPromptSubmit",
),
}),
}
}
async fn run_search_with_claudix(
claudix: &Claudix,
query: String,
top_k: usize,
language_filter: Option<Vec<String>>,
path_prefix: Option<String>,
repos: Vec<String>,
) -> Result<SearchOutput> {
let query = SearchQuery {
query,
top_k,
language_filter: parse_language_filter(language_filter)?,
path_prefix: parse_path_prefix(path_prefix)?,
repos,
};
let found = claudix.search(query).await?;
let mut group_index: Vec<(String, String)> = Vec::new();
let mut grouped: HashMap<(String, String), Vec<SearchHit>> = HashMap::new();
for result in found.results {
let dir = immediate_parent_dir(result.chunk.file_path.as_str());
let key = (result.repo.clone(), dir);
let hit = SearchHit {
repo: result.repo,
file_path: result.chunk.file_path.to_string(),
language: result.chunk.language.to_string(),
kind: result.chunk.kind.to_string(),
name: result.chunk.name,
line_start: result.chunk.line_range.start,
line_end: result.chunk.line_range.end,
score: result.score,
stale: result.stale,
snippet: result.chunk.content,
};
if !grouped.contains_key(&key) {
group_index.push(key.clone());
}
grouped.entry(key).or_default().push(hit);
}
let groups = group_index
.into_iter()
.filter_map(|key| {
let hits = grouped.remove(&key)?;
let (repo, directory) = key;
Some(DirectoryGroup {
repo,
directory,
hits,
})
})
.collect();
Ok(SearchOutput {
groups,
repo_errors: found.repo_errors,
})
}
async fn status_from_store(store: &Store, config: &crate::config::Config) -> Result<StatusOutput> {
let manifest = store.read_manifest()?;
let chunk_count = manifest
.as_ref()
.map(|m| m.chunk_count as usize)
.unwrap_or(0);
let file_count = manifest
.as_ref()
.map(|m| m.file_count as usize)
.unwrap_or(0);
let stale = manifest
.as_ref()
.map(|m| m.is_stale(config))
.unwrap_or(true);
Ok(StatusOutput {
chunk_count,
file_count,
model: manifest
.as_ref()
.map(|manifest| manifest.embedding_model.clone()),
dimensions: manifest.as_ref().map(|manifest| manifest.dimensions),
last_full_index_at: manifest
.as_ref()
.and_then(|manifest| manifest.last_full_index_at.clone()),
last_incremental_at: manifest
.as_ref()
.and_then(|manifest| manifest.last_incremental_at.clone()),
stale,
})
}
fn require_git_repo(project_root: &Path) -> Result<()> {
if !crate::enumeration::is_git_repo(project_root) {
return Err(ClaudixError::NotAGitRepository {
path: project_root.to_path_buf(),
recovery: RecoveryHint("Run claudix index from inside a git repository"),
});
}
Ok(())
}
pub(super) fn canonical_project_root(project_root: &Path) -> Result<PathBuf> {
project_root.canonicalize().map_err(ClaudixError::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::embedding::{Provider, StubProvider};
use crate::store::{Manifest, Store};
use crate::types::Dimension;
mod fixture {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/fixture.rs"
));
}
mod test_support {
use crate as claudix;
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/test_support.rs"
));
}
use fixture::TestFixture;
use test_support::{index_fixture, stub_config};
struct CliHarness {
_fixture: TestFixture,
claudix: Claudix,
store: Store,
}
fn test_claudix(project_root: PathBuf, config: Config) -> Result<Claudix> {
let store = Store::new(&project_root, &config)?;
let config = Arc::new(config);
let embedder: Arc<dyn Provider> = Arc::new(StubProvider::with_model_id(
config.embedding.model.clone(),
Dimension(config.embedding.dimensions),
));
Ok(Claudix::from_parts(project_root, config, embedder, store))
}
async fn cli_harness() -> Result<CliHarness> {
let fixture = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config.clone())?;
index_fixture(
&claudix.store,
claudix.embedder.as_ref(),
claudix.project_root(),
&config,
)
.await?;
let store = Store::new(fixture.root(), &config)?;
Ok(CliHarness {
_fixture: fixture,
claudix,
store,
})
}
#[test]
fn parse_hook_event_accepts_known_values() {
let event = parse_hook_event("SessionStart");
assert!(matches!(event, Ok(HookEvent::SessionStart)));
let event = parse_hook_event("PostToolUse");
assert!(matches!(event, Ok(HookEvent::PostToolUse)));
let event = parse_hook_event("PreToolUse");
assert!(matches!(event, Ok(HookEvent::PreToolUse)));
let event = parse_hook_event("UserPromptSubmit");
assert!(matches!(event, Ok(HookEvent::UserPromptSubmit)));
}
#[test]
fn parse_hook_event_rejects_unknown_values() {
let result = parse_hook_event("Unknown");
assert!(matches!(result, Err(ClaudixError::ConfigInvalid { .. })));
}
#[tokio::test]
async fn run_search_returns_ranked_hits() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_search_with_claudix(
&harness.claudix,
"add".to_owned(),
5,
None,
None,
Vec::new(),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(!output.groups.is_empty());
let top_hit = &output.groups[0].hits[0];
assert_eq!(top_hit.name.as_deref(), Some("add"));
assert_eq!(top_hit.file_path, "src/math.rs");
}
#[tokio::test]
async fn run_search_applies_filters() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_search_with_claudix(
&harness.claudix,
"add".to_owned(),
5,
Some(vec!["rust".to_owned()]),
Some(RelativePath::new("src/math").to_string()),
Vec::new(),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert_eq!(output.groups.len(), 1);
assert_eq!(output.groups[0].hits.len(), 1);
assert_eq!(output.groups[0].hits[0].file_path, "src/math.rs");
}
#[test]
fn clean_reindex_required_for_manifest_compatibility_errors() {
assert!(requires_clean_reindex(&ClaudixError::SchemaMismatch {
store: 0,
binary: 1,
recovery: RecoveryHint("reindex"),
}));
assert!(requires_clean_reindex(
&ClaudixError::EmbeddingModelMismatch {
store_model: "old".to_owned(),
active_model: "new".to_owned(),
recovery: RecoveryHint("reindex"),
}
));
assert!(requires_clean_reindex(&ClaudixError::DimensionMismatch {
store_dim: 384,
model_dim: 768,
recovery: RecoveryHint("reindex"),
}));
assert!(!requires_clean_reindex(&ClaudixError::Store(
"index already running".to_owned()
)));
}
#[tokio::test]
async fn run_index_clears_model_mismatch_and_reindexes() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claude_dir = fixture.root().join(".claude");
assert!(std::fs::create_dir_all(&claude_dir).is_ok());
let config_text = toml::to_string(&config);
assert!(config_text.is_ok());
assert!(
std::fs::write(
claude_dir.join("claudix.toml"),
config_text.ok().unwrap_or_default(),
)
.is_ok()
);
let store = Store::new(fixture.root(), &config);
assert!(store.is_ok());
let store = store.ok().unwrap_or_else(|| unreachable!());
let old_manifest = Manifest::new("old-model", config.embedding.dimensions);
assert!(store.write_manifest(&old_manifest).is_ok());
let output = run_index(fixture.root(), false).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(output.chunk_count > 0);
let manifest = store.read_manifest();
assert!(manifest.is_ok());
let manifest = manifest.ok().unwrap_or_else(|| unreachable!());
let manifest = manifest.unwrap_or_else(|| unreachable!());
assert_eq!(manifest.embedding_model, config.embedding.model);
assert_eq!(manifest.dimensions, config.embedding.dimensions);
assert_eq!(manifest.chunk_count as usize, output.chunk_count);
}
#[tokio::test]
async fn run_status_reports_manifest_and_counts() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let status = status_from_store(&harness.store, &config).await;
assert!(status.is_ok());
let status = status.ok().unwrap_or_else(|| unreachable!());
assert_eq!(status.chunk_count, 3);
assert_eq!(status.file_count, 2);
assert_eq!(status.model.as_deref(), Some("stub-v1"));
assert_eq!(status.dimensions, Some(8));
assert!(!status.stale, "freshly indexed should not be stale");
}
#[tokio::test]
async fn run_doctor_reports_index_and_embedding_health() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claude_dir = harness.claudix.project_root().join(".claude");
assert!(std::fs::create_dir_all(&claude_dir).is_ok());
let config_text = toml::to_string(&config);
assert!(config_text.is_ok());
assert!(
std::fs::write(
claude_dir.join("claudix.toml"),
config_text.ok().unwrap_or_default(),
)
.is_ok()
);
let output = run_doctor(harness.claudix.project_root()).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(output.index_present);
assert_eq!(output.chunk_count, 3);
assert_eq!(output.file_count, 2);
assert_eq!(output.model.as_deref(), Some("stub-v1"));
assert_eq!(output.embedding_provider, "bundled");
assert!(output.embedding_healthy);
}
#[tokio::test]
async fn run_overview_returns_src_directory_rollup() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_overview(harness.claudix.project_root(), None).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
let src = output.directories.iter().find(|d| d.path == "src");
assert!(src.is_some(), "expected a 'src' directory in the rollup");
let src = src.unwrap_or_else(|| unreachable!());
assert_eq!(src.file_count, 2);
assert_eq!(src.chunk_count, 3);
assert!(
src.languages.iter().any(|l| l.language == "rust"),
"expected 'rust' among languages in src/"
);
}
#[tokio::test]
async fn run_overview_top_identifiers_include_known_fixture_name() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_overview(harness.claudix.project_root(), None).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
let src = output.directories.iter().find(|d| d.path == "src");
assert!(src.is_some());
let src = src.unwrap_or_else(|| unreachable!());
assert!(
src.top_identifiers.iter().any(|name| name == "add"),
"expected 'add' in top_identifiers for src/; got: {:?}",
src.top_identifiers,
);
}
#[tokio::test]
async fn run_overview_path_prefix_narrows_to_subtree() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output =
run_overview(harness.claudix.project_root(), Some("src/math".to_owned())).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(output.file_count < 2, "prefix should exclude src/lib.rs");
assert!(
output.chunk_count > 0,
"at least one chunk from src/math.rs"
);
for dir in &output.directories {
assert!(
path_prefix_matches(&format!("{}/file.rs", dir.path), "src/math")
|| dir.path == "src",
"unexpected directory outside prefix: {}",
dir.path,
);
}
}
#[tokio::test]
async fn run_overview_directories_sorted_ascending() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_overview(harness.claudix.project_root(), None).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
let paths: Vec<&str> = output.directories.iter().map(|d| d.path.as_str()).collect();
let mut sorted = paths.clone();
sorted.sort();
assert_eq!(
paths, sorted,
"directories must be sorted ascending by path"
);
}
#[test]
fn immediate_parent_dir_returns_dot_for_root_level_file() {
assert_eq!(immediate_parent_dir("Cargo.toml"), ".");
assert_eq!(immediate_parent_dir("lib.rs"), ".");
}
#[test]
fn immediate_parent_dir_extracts_parent_segment() {
assert_eq!(immediate_parent_dir("src/math.rs"), "src");
assert_eq!(immediate_parent_dir("src/hooks/mod.rs"), "src/hooks");
}
#[tokio::test]
async fn run_doctor_surfaces_specific_embedding_failure() {
let fixture = TestFixture::new("small_rust");
assert!(fixture.is_ok());
let fixture = fixture.ok().unwrap_or_else(|| unreachable!());
let mut config = stub_config();
config.embedding.provider = config::EmbeddingProvider::Http;
config.embedding.endpoint = "http://127.0.0.1:1".to_owned();
config.embedding.model = "no-fallback-model".to_owned();
config.embedding.timeout_ms = 100;
let claude_dir = fixture.root().join(".claude");
assert!(std::fs::create_dir_all(&claude_dir).is_ok());
let config_text = toml::to_string(&config);
assert!(config_text.is_ok());
assert!(
std::fs::write(
claude_dir.join("claudix.toml"),
config_text.ok().unwrap_or_default(),
)
.is_ok()
);
let output = run_doctor(fixture.root()).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(!output.embedding_healthy);
assert!(!output.embedding_model_mismatch);
assert!(
output
.embedding_error
.is_some_and(|reason| reason.contains("http://127.0.0.1:1"))
);
}
#[tokio::test]
async fn search_groups_hits_by_directory_ordered_by_best_score() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_search_with_claudix(
&harness.claudix,
"add greet".to_owned(),
10,
None,
None,
Vec::new(),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(!output.groups.is_empty(), "expected at least one group");
for group in &output.groups {
for hit in &group.hits {
assert_eq!(
immediate_parent_dir(&hit.file_path),
group.directory,
"hit {} belongs in wrong group",
hit.file_path,
);
}
}
for group in &output.groups {
let scores: Vec<f32> = group.hits.iter().map(|h| h.score).collect();
let mut sorted = scores.clone();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
assert_eq!(
scores, sorted,
"hits in group '{}' not score-desc",
group.directory
);
}
}
#[tokio::test]
async fn search_grouping_preserves_top_hit_ranking() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_search_with_claudix(
&harness.claudix,
"add".to_owned(),
5,
None,
None,
Vec::new(),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(!output.groups.is_empty());
let top = &output.groups[0].hits[0];
let all_scores: Vec<f32> = output
.groups
.iter()
.flat_map(|g| g.hits.iter().map(|h| h.score))
.collect();
let global_max = all_scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert_eq!(
top.score, global_max,
"top hit in groups[0] must have the globally highest score"
);
}
async fn dup_harness() -> Result<(TestFixture, Store)> {
let fixture = TestFixture::new("small_rust")?;
let dup_content = std::fs::read_to_string(fixture.root().join("src").join("math.rs"))
.map_err(ClaudixError::from)?;
std::fs::write(fixture.root().join("src").join("math_copy.rs"), dup_content)
.map_err(ClaudixError::from)?;
let config = stub_config();
let claudix = test_claudix(fixture.root().to_path_buf(), config.clone())?;
index_fixture(
&claudix.store,
claudix.embedder.as_ref(),
claudix.project_root(),
&config,
)
.await?;
let store = Store::new(fixture.root(), &config)?;
Ok((fixture, store))
}
#[tokio::test]
async fn find_duplicates_returns_pair_for_identical_content() {
let result = dup_harness().await;
assert!(result.is_ok());
let (fixture, _store) = result.ok().unwrap_or_else(|| unreachable!());
let output = run_find_duplicates(fixture.root(), None, None, None).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(
!output.pairs.is_empty(),
"expected at least one pair from identical file content"
);
let pair = &output.pairs[0];
let paths = [pair.a.file_path.as_str(), pair.b.file_path.as_str()];
assert!(
paths.iter().any(|p| p.contains("math.rs")),
"expected math.rs in the pair; got {:?}",
paths
);
assert!(
paths.iter().any(|p| p.contains("math_copy.rs")),
"expected math_copy.rs in the pair; got {:?}",
paths
);
assert!(
pair.similarity > 0.99,
"similarity should be ~1.0 for identical content"
);
}
#[tokio::test]
async fn find_duplicates_returns_empty_for_unique_repo() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let output = run_find_duplicates(harness.claudix.project_root(), None, None, None).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(
output.pairs.is_empty(),
"distinct-content repo should have no duplicates"
);
}
#[tokio::test]
async fn find_duplicates_threshold_sensitivity() {
let result = dup_harness().await;
assert!(result.is_ok());
let (fixture, _store) = result.ok().unwrap_or_else(|| unreachable!());
let high = run_find_duplicates(fixture.root(), Some(0.99), None, None).await;
assert!(high.is_ok());
let high = high.ok().unwrap_or_else(|| unreachable!());
assert!(
!high.pairs.is_empty(),
"threshold 0.99 should still find identical pair"
);
let ceiling = run_find_duplicates(fixture.root(), Some(1.01), None, None).await;
assert!(
ceiling.is_err(),
"threshold > 1.0 must be rejected before scanning"
);
}
#[tokio::test]
async fn find_duplicates_rejects_invalid_threshold_and_limit() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let nan =
run_find_duplicates(harness.claudix.project_root(), Some(f32::NAN), None, None).await;
assert!(nan.is_err(), "NaN threshold must be rejected");
let negative =
run_find_duplicates(harness.claudix.project_root(), Some(-0.1), None, None).await;
assert!(negative.is_err(), "negative threshold must be rejected");
let zero_limit =
run_find_duplicates(harness.claudix.project_root(), None, Some(0), None).await;
assert!(zero_limit.is_err(), "zero limit must be rejected");
}
#[tokio::test]
async fn find_duplicates_same_file_not_reported() {
let result = dup_harness().await;
assert!(result.is_ok());
let (fixture, _store) = result.ok().unwrap_or_else(|| unreachable!());
let output = run_find_duplicates(fixture.root(), Some(0.0), None, None).await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
for pair in &output.pairs {
assert!(
pair.a.file_path != pair.b.file_path || pair.a.repo != pair.b.repo,
"same-file pair must not be reported: {} == {}",
pair.a.file_path,
pair.b.file_path,
);
}
}
#[tokio::test]
async fn find_duplicates_partial_success_on_unindexed_path() {
let result = dup_harness().await;
assert!(result.is_ok());
let (fixture, _store) = result.ok().unwrap_or_else(|| unreachable!());
let indexed = fixture.root().display().to_string();
let unindexed = "/tmp/nonexistent-claudix-test-repo-12345".to_owned();
let output = run_find_duplicates(
fixture.root(),
None,
None,
Some(vec![indexed, unindexed.clone()]),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(
output.repo_errors.iter().any(|e| e.repo == unindexed
|| e.error.contains("not indexed")
|| e.error.contains("No such")),
"expected a repo_error for the unindexed path; got: {:?}",
output.repo_errors,
);
assert!(
!output.pairs.is_empty(),
"indexed repo should still produce pairs despite the error"
);
}
#[tokio::test]
async fn load_repo_chunks_readonly_rejects_dimension_mismatch() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let root = harness.claudix.project_root().display().to_string();
let result = load_repo_chunks_readonly(&root, "stub-v1", 999).await;
assert!(
result.is_err(),
"mismatched dimensions must produce a RepoError"
);
let err = result.err().unwrap_or_else(|| unreachable!());
assert!(
err.error.contains("mismatch"),
"error should mention mismatch; got: {}",
err.error,
);
}
#[tokio::test]
async fn load_repo_chunks_readonly_rejects_model_mismatch() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let root = harness.claudix.project_root().display().to_string();
let result = load_repo_chunks_readonly(&root, "different-model", 8).await;
assert!(result.is_err(), "mismatched model must produce a RepoError");
let err = result.err().unwrap_or_else(|| unreachable!());
assert!(
err.error.contains("mismatch"),
"error should mention mismatch; got: {}",
err.error,
);
}
#[tokio::test]
async fn load_repo_chunks_readonly_rejects_missing_chunk_table() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let index_dir = harness.claudix.store.state_dir_path().join("index");
let remove = std::fs::remove_dir_all(&index_dir);
assert!(remove.is_ok());
let root = harness.claudix.project_root().display().to_string();
let result = load_repo_chunks_readonly(&root, "stub-v1", 8).await;
assert!(
result.is_err(),
"manifest claiming chunks without a chunks table must produce a RepoError"
);
let err = result.err().unwrap_or_else(|| unreachable!());
assert!(
err.error.contains("chunks missing"),
"error should mention missing chunks; got: {}",
err.error,
);
}
#[tokio::test]
async fn find_duplicates_multi_repo_detects_cross_repo_pair() {
let fixture_a = TestFixture::new("small_rust");
assert!(fixture_a.is_ok());
let fixture_a = fixture_a.ok().unwrap_or_else(|| unreachable!());
let fixture_b = TestFixture::new("small_rust");
assert!(fixture_b.is_ok());
let fixture_b = fixture_b.ok().unwrap_or_else(|| unreachable!());
let config = stub_config();
let claudix_a = test_claudix(fixture_a.root().to_path_buf(), config.clone());
assert!(claudix_a.is_ok());
let claudix_a = claudix_a.ok().unwrap_or_else(|| unreachable!());
let index_a = index_fixture(
&claudix_a.store,
claudix_a.embedder.as_ref(),
claudix_a.project_root(),
&config,
)
.await;
assert!(index_a.is_ok());
let claudix_b = test_claudix(fixture_b.root().to_path_buf(), config.clone());
assert!(claudix_b.is_ok());
let claudix_b = claudix_b.ok().unwrap_or_else(|| unreachable!());
let index_b = index_fixture(
&claudix_b.store,
claudix_b.embedder.as_ref(),
claudix_b.project_root(),
&config,
)
.await;
assert!(index_b.is_ok());
let repo_a = fixture_a.root().display().to_string();
let repo_b = fixture_b.root().display().to_string();
let output = run_find_duplicates(
fixture_a.root(),
Some(0.99),
None,
Some(vec![repo_a.clone(), repo_b.clone()]),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(
output.repo_errors.is_empty(),
"both repos are indexed; no errors expected"
);
assert!(
!output.pairs.is_empty(),
"identical fixture content across repos must produce cross-repo pairs"
);
let has_cross_repo = output.pairs.iter().any(|p| p.a.repo != p.b.repo);
assert!(
has_cross_repo,
"at least one pair must span two different repos"
);
}
fn write_fixture_config(project_root: &Path, config: &Config) -> Result<()> {
let claude_dir = project_root.join(".claude");
fs::create_dir_all(&claude_dir).map_err(ClaudixError::from)?;
let text = toml::to_string(config)
.map_err(|e| ClaudixError::Store(format!("serialize stub config: {e}")))?;
fs::write(claude_dir.join("claudix.toml"), text).map_err(ClaudixError::from)?;
Ok(())
}
async fn dual_repo_harness() -> Result<(TestFixture, TestFixture, String, String)> {
let fixture_a = TestFixture::new("small_rust")?;
let fixture_b = TestFixture::new("small_rust")?;
let config = stub_config();
let claudix_a = test_claudix(fixture_a.root().to_path_buf(), config.clone())?;
index_fixture(
&claudix_a.store,
claudix_a.embedder.as_ref(),
claudix_a.project_root(),
&config,
)
.await?;
write_fixture_config(fixture_a.root(), &config)?;
let claudix_b = test_claudix(fixture_b.root().to_path_buf(), config.clone())?;
index_fixture(
&claudix_b.store,
claudix_b.embedder.as_ref(),
claudix_b.project_root(),
&config,
)
.await?;
write_fixture_config(fixture_b.root(), &config)?;
let repo_a = fixture_a.root().display().to_string();
let repo_b = fixture_b.root().display().to_string();
Ok((fixture_a, fixture_b, repo_a, repo_b))
}
#[tokio::test]
async fn search_spans_active_and_listed_repo_with_correct_labels() {
let setup = dual_repo_harness().await;
assert!(setup.is_ok());
let (fixture_a, _fixture_b, repo_a, repo_b) = setup.ok().unwrap_or_else(|| unreachable!());
let output = run_search(
fixture_a.root(),
"add".to_owned(),
Some(10),
None,
None,
Some(vec![repo_b.clone()]),
)
.await;
assert!(output.is_ok(), "cross-repo search failed: {output:?}");
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(output.repo_errors.is_empty(), "no errors expected");
let from_a = output
.groups
.iter()
.flat_map(|g| g.hits.iter())
.any(|h| h.repo == repo_a);
let from_b = output
.groups
.iter()
.flat_map(|g| g.hits.iter())
.any(|h| h.repo == repo_b);
assert!(
from_a,
"expected at least one hit from active repo {repo_a}"
);
assert!(from_b, "expected at least one hit from extra repo {repo_b}");
for group in &output.groups {
for hit in &group.hits {
assert_eq!(
hit.repo, group.repo,
"hit/group repo mismatch in directory {}",
group.directory
);
}
}
}
#[tokio::test]
async fn search_partial_success_on_unindexed_repo() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let write = write_fixture_config(harness.claudix.project_root(), &stub_config());
assert!(write.is_ok());
let unindexed = "/tmp/nonexistent-claudix-cross-repo-search-9999".to_owned();
let output = run_search(
harness.claudix.project_root(),
"add".to_owned(),
Some(10),
None,
None,
Some(vec![unindexed.clone()]),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(
output.repo_errors.iter().any(|e| e.repo == unindexed
|| e.error.contains("not indexed")
|| e.error.contains("No such")),
"expected RepoError for unindexed path; got: {:?}",
output.repo_errors,
);
assert!(
!output.groups.is_empty(),
"active repo must still produce hits despite the error"
);
}
#[tokio::test]
async fn search_partial_success_on_model_mismatch() {
let fixture_a = TestFixture::new("small_rust");
assert!(fixture_a.is_ok());
let fixture_a = fixture_a.ok().unwrap_or_else(|| unreachable!());
let fixture_b = TestFixture::new("small_rust");
assert!(fixture_b.is_ok());
let fixture_b = fixture_b.ok().unwrap_or_else(|| unreachable!());
let config_a = stub_config();
let config_b = {
let mut c = stub_config();
c.embedding.model = "stub-v2".to_owned();
c
};
let claudix_a = test_claudix(fixture_a.root().to_path_buf(), config_a.clone());
assert!(claudix_a.is_ok());
let claudix_a = claudix_a.ok().unwrap_or_else(|| unreachable!());
let _ = index_fixture(
&claudix_a.store,
claudix_a.embedder.as_ref(),
claudix_a.project_root(),
&config_a,
)
.await;
let write_a = write_fixture_config(fixture_a.root(), &config_a);
assert!(write_a.is_ok());
let claudix_b = test_claudix(fixture_b.root().to_path_buf(), config_b.clone());
assert!(claudix_b.is_ok());
let claudix_b = claudix_b.ok().unwrap_or_else(|| unreachable!());
let _ = index_fixture(
&claudix_b.store,
claudix_b.embedder.as_ref(),
claudix_b.project_root(),
&config_b,
)
.await;
let write_b = write_fixture_config(fixture_b.root(), &config_b);
assert!(write_b.is_ok());
let repo_b = fixture_b.root().display().to_string();
let output = run_search(
fixture_a.root(),
"add".to_owned(),
Some(10),
None,
None,
Some(vec![repo_b.clone()]),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
assert!(
output
.repo_errors
.iter()
.any(|e| e.error.contains("mismatch")),
"expected mismatch error for extra repo; got: {:?}",
output.repo_errors,
);
assert!(
!output.groups.is_empty(),
"active repo hits must survive a sibling repo's mismatch"
);
}
#[tokio::test]
async fn search_dedupes_active_when_listed_in_repos() {
let harness = cli_harness().await;
assert!(harness.is_ok());
let harness = harness.ok().unwrap_or_else(|| unreachable!());
let write = write_fixture_config(harness.claudix.project_root(), &stub_config());
assert!(write.is_ok());
let baseline = run_search(
harness.claudix.project_root(),
"add".to_owned(),
Some(10),
None,
None,
None,
)
.await;
assert!(baseline.is_ok());
let baseline = baseline.ok().unwrap_or_else(|| unreachable!());
let baseline_hits: usize = baseline.groups.iter().map(|g| g.hits.len()).sum();
let active_path = harness.claudix.project_root().display().to_string();
let echoed = run_search(
harness.claudix.project_root(),
"add".to_owned(),
Some(10),
None,
None,
Some(vec![active_path]),
)
.await;
assert!(echoed.is_ok());
let echoed = echoed.ok().unwrap_or_else(|| unreachable!());
let echoed_hits: usize = echoed.groups.iter().map(|g| g.hits.len()).sum();
assert_eq!(
baseline_hits, echoed_hits,
"listing active path must not duplicate hits"
);
assert!(echoed.repo_errors.is_empty());
}
#[tokio::test]
async fn search_groups_separate_same_named_dirs_per_repo() {
let setup = dual_repo_harness().await;
assert!(setup.is_ok());
let (fixture_a, _fixture_b, _repo_a, repo_b) = setup.ok().unwrap_or_else(|| unreachable!());
let output = run_search(
fixture_a.root(),
"add".to_owned(),
Some(20),
None,
None,
Some(vec![repo_b]),
)
.await;
assert!(output.is_ok());
let output = output.ok().unwrap_or_else(|| unreachable!());
let src_groups: Vec<&DirectoryGroup> = output
.groups
.iter()
.filter(|g| g.directory == "src")
.collect();
assert!(
src_groups.len() >= 2,
"expected two distinct 'src' groups (one per repo), got {}",
src_groups.len(),
);
let repos: HashSet<&str> = src_groups.iter().map(|g| g.repo.as_str()).collect();
assert!(
repos.len() >= 2,
"src groups must come from distinct repos, got: {:?}",
repos,
);
}
#[tokio::test]
async fn search_does_not_write_into_extra_repo() {
let setup = dual_repo_harness().await;
assert!(setup.is_ok());
let (fixture_a, fixture_b, _repo_a, repo_b) = setup.ok().unwrap_or_else(|| unreachable!());
let state_dir = fixture_b.root().join(".claudix");
let before = snapshot_paths(&state_dir);
let output = run_search(
fixture_a.root(),
"add".to_owned(),
Some(10),
None,
None,
Some(vec![repo_b]),
)
.await;
assert!(output.is_ok());
let after = snapshot_paths(&state_dir);
assert_eq!(
before, after,
"cross-repo search must not write into the extra repo's .claudix dir",
);
}
fn snapshot_paths(dir: &Path) -> std::collections::BTreeSet<(PathBuf, u64)> {
fn walk(dir: &Path, into: &mut std::collections::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 = std::collections::BTreeSet::new();
walk(dir, &mut set);
set
}
#[test]
fn effective_cross_repos_orders_config_then_call_args() {
let cfg = vec!["/cfg/a".to_owned(), "/cfg/b".to_owned()];
let call = Some(vec!["/cfg/b".to_owned(), "/call/c".to_owned()]);
let merged = effective_cross_repos(&cfg, call);
assert_eq!(merged, vec!["/cfg/a", "/cfg/b", "/call/c"]);
}
#[test]
fn effective_cross_repos_empty_when_both_empty() {
let merged = effective_cross_repos(&[], None);
assert!(merged.is_empty());
}
}