use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::chunking::source::DESIRED_CHUNK_LENGTH_CHARS;
use crate::indexing::cache::{
compute_content_hash_from_paths, ensure_cache_dir, resolve_cache_dir, CacheLocation,
};
use crate::indexing::dense::SelectableBasicBackend;
use crate::indexing::dense::DEFAULT_MODEL_NAME;
use crate::indexing::file_walker::walk_files;
use crate::indexing::files::get_extensions;
use crate::indexing::files::get_max_file_bytes;
use crate::indexing::index::{
normalize_content, parse_manifest, read_chunks, CspIndex, IndexManifest, LoadOptions,
INDEX_SCHEMA_VERSION,
};
use crate::indexing::sparse::Bm25Index;
use crate::indexing::types::PreviousIndex;
use crate::types::ContentType;
use crate::utils::is_git_url;
#[derive(Debug, Clone, Default)]
pub struct LoadOrBuildOptions {
pub base_dir: Option<PathBuf>,
pub git_ref: Option<String>,
pub content: Option<Vec<ContentType>>,
pub model_path: Option<String>,
}
fn normalized_hash_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn collect_source_paths(root: &Path, content: &[ContentType]) -> Vec<(String, PathBuf)> {
let resolved = get_extensions(content, None);
let ext_refs: Vec<&str> = resolved.iter().map(String::as_str).collect();
let mut files = Vec::new();
let max_file_bytes = get_max_file_bytes();
for file_path in walk_files(root, &ext_refs, &[]) {
let Ok(meta) = std::fs::metadata(&file_path) else {
continue;
};
if meta.len() > max_file_bytes {
continue;
}
let rel = file_path.strip_prefix(root).unwrap_or(&file_path);
files.push((normalized_hash_path(rel), file_path));
}
files
}
pub fn source_fingerprint(source: &str, content: &[ContentType]) -> Option<String> {
if is_git_url(source) {
return None;
}
Some(compute_content_hash_from_paths(collect_source_paths(
Path::new(source),
content,
)))
}
pub fn load_or_build_index(source: &str, options: &LoadOrBuildOptions) -> Result<CspIndex, String> {
let content = normalize_content(options.content.clone());
let is_git = is_git_url(source);
let location = CacheLocation {
base_dir: options.base_dir.clone(),
git_ref: options.git_ref.clone(),
};
let cache_dir = resolve_cache_dir(source, &content, &location);
let base_only = CacheLocation {
base_dir: options.base_dir.clone(),
git_ref: None,
};
ensure_cache_dir(&cache_dir, &base_only)?;
let source_hash = source_fingerprint(source, &content);
let expected_model = options.model_path.as_deref().unwrap_or(DEFAULT_MODEL_NAME);
if let Some(cached) = try_reuse(&cache_dir, is_git, source_hash.as_deref(), expected_model) {
return Ok(cached);
}
let load_options = LoadOptions {
model_path: options.model_path.clone(),
content: Some(content.clone()),
};
let index = if is_git {
CspIndex::from_git(source, &load_options, options.git_ref.as_deref())?
} else {
let previous = load_previous_for_incremental(&cache_dir, expected_model, &content);
CspIndex::from_path_with_previous(Path::new(source), &load_options, previous)?
};
index.save(&cache_dir, source_hash.as_deref())?;
Ok(index)
}
fn read_manifest(cache_dir: &Path) -> Option<IndexManifest> {
let raw = std::fs::read_to_string(cache_dir.join("manifest.json")).ok()?;
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
parse_manifest(&value).ok()
}
fn manifest_compatible(manifest: &IndexManifest, expected_model: &str) -> bool {
if manifest.schema_version != INDEX_SCHEMA_VERSION {
return false;
}
if manifest.chunk_size != Some(DESIRED_CHUNK_LENGTH_CHARS as u32) {
return false;
}
if manifest.model_id != expected_model {
return false;
}
let (query_model, _) = crate::indexing::dense::load_model(Some(expected_model));
manifest.model_kind.as_deref() == Some(query_model.kind())
}
pub(crate) fn load_previous_for_incremental(
cache_dir: &Path,
expected_model: &str,
content: &[ContentType],
) -> Option<PreviousIndex> {
let manifest = read_manifest(cache_dir)?;
if !manifest_compatible(&manifest, expected_model) {
return None;
}
let manifest_content: BTreeSet<&str> = manifest.content.iter().map(|c| c.as_str()).collect();
let expected_content: BTreeSet<&str> = content.iter().map(|c| c.as_str()).collect();
if manifest_content != expected_content || manifest.files.is_empty() {
return None;
}
let chunks = read_chunks(cache_dir).ok()?;
let backend = SelectableBasicBackend::load(cache_dir).ok()?;
let (query_model, _) = crate::indexing::dense::load_model(Some(expected_model));
if !backend.vectors.is_empty() && backend.dim != query_model.dim() {
return None;
}
let vectors = backend.vectors;
let bm25_index = Bm25Index::load(cache_dir).ok()?;
PreviousIndex::try_new(chunks, vectors, manifest.files, bm25_index).ok()
}
fn try_reuse(
cache_dir: &Path,
is_git: bool,
source_hash: Option<&str>,
expected_model: &str,
) -> Option<CspIndex> {
let manifest = read_manifest(cache_dir)?;
if !manifest_compatible(&manifest, expected_model) {
return None;
}
if !is_git && Some(manifest.content_hash.as_str()) != source_hash {
return None;
}
CspIndex::load_from_disk(cache_dir).ok()
}
#[cfg(test)]
mod tests;