use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::debug;
use crate::config::Config;
use crate::git::{GitError, Repo};
use crate::path::RelPath;
#[cfg(feature = "code-search")]
use crate::scanner_code::PendingCodeBatch;
#[cfg(feature = "documents")]
use crate::scanner_docs::{PendingDocBatch, flush_document_batches};
use crate::scanner_file::{run_candidates, scanner_pool};
use crate::scanner_filter::{Filters, IndexFilter, ignore_walk_builder};
#[cfg(feature = "documents")]
use crate::scanner_lanes::LANE_DOC_REMOVALS;
use crate::scanner_lanes::{
LANE_BM25_STATS, LANE_CODE_BATCHES, LANE_CODE_REMOVALS, LANE_DOC_BATCHES, LANE_RESOLVE, run_optional_lane,
};
use crate::store::{FileEntry, Store, StoreError};
pub use crate::scanner_file::looks_binary;
const LARGE_SCAN_CANDIDATE_WARN: usize = 50_000;
#[derive(Clone)]
pub enum ScanSource<'a> {
WorkingTree,
Staged(&'a Repo),
Rev { repo: &'a Repo, sha: String },
}
impl<'a> ScanSource<'a> {
fn label(&self) -> String {
match self {
ScanSource::WorkingTree => "working tree".to_string(),
ScanSource::Staged(_) => "staged index".to_string(),
ScanSource::Rev { sha, .. } => format!("rev {}", &sha[..7.min(sha.len())]),
}
}
}
#[derive(Debug, Error)]
pub enum ScanError {
#[error("store error: {0}")]
Store(#[from] StoreError),
#[error("invalid glob in config: {0}")]
BadGlob(String),
#[error("git error: {0}")]
Git(#[from] GitError),
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ScanStats {
pub scanned: usize,
pub updated: usize,
pub updated_with_warnings: usize,
pub reused_extraction: usize,
pub skipped_unchanged: usize,
pub skipped_too_large: usize,
pub skipped_non_utf8: usize,
pub skipped_no_lang: usize,
pub skipped_binary: usize,
pub removed: usize,
pub read_failed: usize,
pub extract_failed: usize,
pub parse_timeouts: usize,
pub docs_indexed: usize,
}
#[derive(Debug, Clone)]
pub struct FileResult {
pub path: String,
pub status: FileStatus,
pub(crate) upsert: Option<FileEntry>,
#[cfg(feature = "documents")]
pub(crate) doc_batch: Option<PendingDocBatch>,
#[cfg(feature = "documents")]
pub(crate) doc_upsert: Option<crate::store::DocEntry>,
#[cfg(feature = "code-search")]
pub(crate) code_batch: Option<PendingCodeBatch>,
}
impl FileResult {
pub(crate) fn bare(path: String, status: FileStatus) -> Self {
Self {
path,
status,
upsert: None,
#[cfg(feature = "documents")]
doc_batch: None,
#[cfg(feature = "documents")]
doc_upsert: None,
#[cfg(feature = "code-search")]
code_batch: None,
}
}
}
#[derive(Debug, Clone)]
pub enum FileStatus {
Updated {
had_errors: bool,
error_count: u32,
reused: bool,
},
Unchanged,
Removed,
SkippedTooLarge {
size: u64,
},
SkippedNonUtf8,
SkippedNoLang,
SkippedBinary,
ReadFailed {
kind: std::io::ErrorKind,
msg: String,
},
ExtractFailed {
msg: String,
},
ParseTimedOut,
#[cfg(feature = "documents")]
DocIndexed {
chunk_count: usize,
embedding_dim: u16,
},
}
#[derive(Debug, Clone, Default)]
pub struct ScanReport {
pub results: Vec<FileResult>,
pub stats: ScanStats,
}
pub(crate) fn submodule_roots_for_source(root: &Path, source: &ScanSource<'_>) -> Vec<String> {
let paths = match source {
ScanSource::Staged(repo) | ScanSource::Rev { repo, .. } => repo.submodule_paths(),
ScanSource::WorkingTree => match Repo::discover(root) {
Ok(r) => r.submodule_paths(),
Err(_) => Vec::new(),
},
};
paths.into_iter().map(|p| p.to_str_lossy().into_owned()).collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum EmbedMode {
Inline,
Deferred,
}
pub fn scan(
root: &Path,
store: &mut Store,
config: &Config,
source: ScanSource<'_>,
embed: EmbedMode,
) -> Result<ScanReport, ScanError> {
let submodule_roots = submodule_roots_for_source(root, &source);
let filters = Filters::build(config, submodule_roots)?;
let candidates = candidates_for_source(root, config, &filters, &source)?;
debug!(count = candidates.len(), kind = source.label(), "scan candidates");
let scope = derive_scope(root, &source);
let outcomes: Vec<FileResult> = run_candidates(&candidates, root, &filters, store, &source, config, &scope, embed);
let seen: ahash::AHashSet<&str> = outcomes
.iter()
.filter_map(|r| match &r.status {
FileStatus::Updated { .. } | FileStatus::Unchanged => Some(r.path.as_str()),
_ => None,
})
.collect();
let stale: Vec<String> = store
.index
.files
.keys()
.filter(|k| !seen.contains(k.to_str_lossy().as_ref()))
.map(|k| k.to_str_lossy().into_owned())
.collect();
drop(seen);
#[cfg(feature = "documents")]
let doc_stale: Vec<String> = {
let doc_seen: ahash::AHashSet<&str> = outcomes
.iter()
.filter(|r| r.doc_batch.is_some() || matches!(r.status, FileStatus::Unchanged | FileStatus::Updated { .. }))
.map(|r| r.path.as_str())
.collect();
store
.index
.doc_files
.keys()
.filter(|k| !doc_seen.contains(k.to_str_lossy().as_ref()))
.map(|k| k.to_str_lossy().into_owned())
.collect()
};
let mut report = ScanReport::default();
let (doc_batches, code_batches) = apply_outcomes(store, &mut report, outcomes);
for k in &stale {
store.remove(k);
if let Some(idx) = store.index_db.as_ref() {
let mut w = idx.writer();
let rel = RelPath::from(k.as_str());
let res = w.remove_file(&rel).and_then(|()| w.remove_resolved_file(&rel));
#[cfg(feature = "code-search")]
let res = res.and_then(|()| w.remove_bm25_file(&rel));
let _ = res.and_then(|()| w.commit());
}
report.results.push(FileResult::bare(k.clone(), FileStatus::Removed));
report.stats.removed += 1;
}
flush_code_map(store)?;
if matches!(source, ScanSource::WorkingTree) {
let precise = config.code_intel.precise_resolution;
run_optional_lane(LANE_RESOLVE, || {
scanner_pool(config.resources.scan_threads)
.install(|| crate::intel::resolve_pass::resolve_pass(root, store, precise));
});
}
run_optional_lane(LANE_DOC_BATCHES, || {
flush_doc_batches_if_any(store, config, &scope, doc_batches);
});
run_optional_lane(LANE_CODE_BATCHES, || {
flush_code_batches_if_any(store, config, &scope, code_batches);
});
run_optional_lane(LANE_CODE_REMOVALS, || {
flush_code_removals_if_any(store, config, &scope, &stale);
});
#[cfg(feature = "documents")]
if !doc_stale.is_empty() {
run_optional_lane(LANE_DOC_REMOVALS, || {
flush_doc_removals_if_any(store, config, &scope, &doc_stale);
});
store.flush()?;
}
run_optional_lane(LANE_BM25_STATS, || finalize_bm25_stats_if_any(store, config));
Ok(report)
}
fn flush_code_map(store: &Store) -> Result<(), ScanError> {
store.flush()?;
Ok(())
}
pub fn scan_paths(
root: &Path,
store: &mut Store,
config: &Config,
paths: &[PathBuf],
embed: EmbedMode,
) -> Result<ScanReport, ScanError> {
let source = ScanSource::WorkingTree;
let filter = IndexFilter::new(root, config)?;
let mut rels: Vec<String> = Vec::with_capacity(paths.len());
let mut removed: Vec<String> = Vec::new();
#[cfg(feature = "documents")]
let mut doc_removed: Vec<String> = Vec::new();
for abs in paths {
let rel = match abs.strip_prefix(root) {
Ok(p) => {
let lossy = p.to_string_lossy();
#[cfg(windows)]
{
lossy.replace('\\', "/")
}
#[cfg(not(windows))]
{
lossy.into_owned()
}
}
Err(_) => continue,
};
if rel.is_empty() || rel.starts_with(crate::config::BASEMIND_DIR) {
continue;
}
if !abs.exists() {
if store.lookup(&rel).is_some() {
removed.push(rel);
continue;
}
#[cfg(feature = "documents")]
if store.lookup_doc(&rel).is_some() {
doc_removed.push(rel);
}
continue;
}
if !filter.is_indexable(abs) {
continue;
}
rels.push(rel);
}
rels.sort();
rels.dedup();
#[cfg(feature = "documents")]
let nothing_removed = removed.is_empty() && doc_removed.is_empty();
#[cfg(not(feature = "documents"))]
let nothing_removed = removed.is_empty();
if rels.is_empty() && nothing_removed {
return Ok(ScanReport::default());
}
let scope = derive_scope(root, &source);
let outcomes: Vec<FileResult> =
run_candidates(&rels, root, filter.filters(), store, &source, config, &scope, embed);
let mut report = ScanReport::default();
let (doc_batches, code_batches) = apply_outcomes(store, &mut report, outcomes);
for rel in &removed {
store.remove(rel);
if let Some(idx) = store.index_db.as_ref() {
let mut w = idx.writer();
let rel = RelPath::from(rel.as_str());
let res = w.remove_file(&rel).and_then(|()| w.remove_resolved_file(&rel));
#[cfg(feature = "code-search")]
let res = res.and_then(|()| w.remove_bm25_file(&rel));
let _ = res.and_then(|()| w.commit());
}
report.results.push(FileResult::bare(rel.clone(), FileStatus::Removed));
report.stats.removed += 1;
}
flush_code_map(store)?;
let precise = config.code_intel.precise_resolution;
run_optional_lane(LANE_RESOLVE, || {
scanner_pool(config.resources.scan_threads)
.install(|| crate::intel::resolve_pass::resolve_pass_incremental(root, store, &rels, precise));
});
run_optional_lane(LANE_DOC_BATCHES, || {
flush_doc_batches_if_any(store, config, &scope, doc_batches);
});
run_optional_lane(LANE_CODE_BATCHES, || {
flush_code_batches_if_any(store, config, &scope, code_batches);
});
run_optional_lane(LANE_CODE_REMOVALS, || {
flush_code_removals_if_any(store, config, &scope, &removed);
});
#[cfg(feature = "documents")]
if !doc_removed.is_empty() {
run_optional_lane(LANE_DOC_REMOVALS, || {
flush_doc_removals_if_any(store, config, &scope, &doc_removed);
});
store.flush()?;
}
run_optional_lane(LANE_BM25_STATS, || finalize_bm25_stats_if_any(store, config));
Ok(report)
}
#[cfg_attr(not(feature = "documents"), allow(clippy::needless_pass_by_ref_mut))]
fn apply_outcomes(
store: &mut Store,
report: &mut ScanReport,
outcomes: Vec<FileResult>,
) -> (Vec<PendingDocBatchOpt>, Vec<PendingCodeBatchOpt>) {
#[cfg_attr(not(feature = "documents"), allow(unused_mut))]
let mut doc_batches: Vec<PendingDocBatchOpt> = Vec::new();
#[cfg_attr(not(feature = "code-search"), allow(unused_mut))]
let mut code_batches: Vec<PendingCodeBatchOpt> = Vec::new();
for mut o in outcomes {
report.stats.scanned += 1;
match &o.status {
FileStatus::Updated {
had_errors,
error_count: _,
reused,
} => {
report.stats.updated += 1;
if *had_errors {
report.stats.updated_with_warnings += 1;
}
if *reused {
report.stats.reused_extraction += 1;
}
}
FileStatus::Unchanged => report.stats.skipped_unchanged += 1,
FileStatus::SkippedTooLarge { .. } => report.stats.skipped_too_large += 1,
FileStatus::SkippedNonUtf8 => report.stats.skipped_non_utf8 += 1,
FileStatus::SkippedNoLang => report.stats.skipped_no_lang += 1,
FileStatus::SkippedBinary => report.stats.skipped_binary += 1,
FileStatus::Removed => report.stats.removed += 1,
FileStatus::ReadFailed { .. } => report.stats.read_failed += 1,
FileStatus::ExtractFailed { .. } => report.stats.extract_failed += 1,
FileStatus::ParseTimedOut => {
report.stats.extract_failed += 1;
report.stats.parse_timeouts += 1;
}
#[cfg(feature = "documents")]
FileStatus::DocIndexed { .. } => {
report.stats.docs_indexed += 1;
}
}
if let Some(entry) = o.upsert.take() {
store.upsert(&o.path, entry);
}
#[cfg(feature = "documents")]
if let Some(entry) = o.doc_upsert.take() {
store.upsert_doc(&o.path, entry);
}
#[cfg(feature = "documents")]
if let Some(batch) = o.doc_batch.take() {
doc_batches.push(batch);
}
#[cfg(feature = "code-search")]
if let Some(batch) = o.code_batch.take() {
code_batches.push(batch);
}
let cleared = FileResult::bare(o.path, o.status);
report.results.push(cleared);
}
(doc_batches, code_batches)
}
#[cfg(feature = "documents")]
type PendingDocBatchOpt = PendingDocBatch;
#[cfg(not(feature = "documents"))]
type PendingDocBatchOpt = ();
#[cfg(feature = "code-search")]
type PendingCodeBatchOpt = PendingCodeBatch;
#[cfg(not(feature = "code-search"))]
type PendingCodeBatchOpt = ();
fn candidates_for_source(
root: &Path,
config: &Config,
filters: &Filters,
source: &ScanSource<'_>,
) -> Result<Vec<String>, ScanError> {
let raw = match source {
ScanSource::WorkingTree => walk_candidates(root, config, filters),
ScanSource::Staged(repo) => repo.list_paths_staged()?,
ScanSource::Rev { repo, sha } => repo.list_paths_rev(sha)?,
};
let mut out: Vec<String> = match source {
ScanSource::WorkingTree => raw,
_ => raw
.into_iter()
.filter(|rel| filters.allows(rel))
.filter(|rel| !rel.starts_with(crate::config::BASEMIND_DIR))
.collect(),
};
out.sort();
out.dedup();
Ok(out)
}
fn walk_candidates(root: &Path, config: &Config, filters: &Filters) -> Vec<String> {
let mut out = Vec::new();
let walker = ignore_walk_builder(root, config.scan.respect_gitignore, config.scan.follow_symlinks).build();
for dent in walker.flatten() {
if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = dent.path();
let rel = match path.strip_prefix(root) {
Ok(p) => p,
Err(_) => continue,
};
let Some(rel_str) = rel.to_str() else {
continue;
};
#[cfg(windows)]
let rel_owned = rel_str.replace('\\', "/");
#[cfg(windows)]
let rel_str = rel_owned.as_str();
if !filters.allows(rel_str) {
continue;
}
out.push(rel_str.to_string());
}
crate::scanner_filter::walk_extra_roots(root, config, filters, &mut out);
if out.len() > LARGE_SCAN_CANDIDATE_WARN {
tracing::warn!(
candidates = out.len(),
"scan candidate set is very large; check .gitignore / [scan] exclude globs for generated or vendored trees"
);
}
out
}
fn derive_scope(root: &Path, source: &ScanSource<'_>) -> String {
match source {
ScanSource::Staged(repo) | ScanSource::Rev { repo, .. } => crate::git::scope_key(repo),
ScanSource::WorkingTree => match Repo::discover(root) {
Ok(repo) => crate::git::scope_key(&repo),
Err(_) => format!("path:{}", root.display()),
},
}
}
#[cfg(feature = "documents")]
fn flush_doc_batches_if_any(store: &mut Store, config: &Config, scope: &str, batches: Vec<PendingDocBatchOpt>) {
if batches.is_empty() {
return;
}
let _ = flush_document_batches(store, scope, batches, &config.documents.embedding_preset);
}
#[cfg(not(feature = "documents"))]
fn flush_doc_batches_if_any(_store: &mut Store, _config: &Config, _scope: &str, _batches: Vec<PendingDocBatchOpt>) {}
#[cfg(feature = "documents")]
fn flush_doc_removals_if_any(store: &mut Store, config: &Config, scope: &str, stale: &[String]) {
crate::scanner_docs::delete_stale_documents(store, config, scope, stale);
}
#[cfg(feature = "code-search")]
fn flush_code_batches_if_any(store: &mut Store, config: &Config, scope: &str, batches: Vec<PendingCodeBatchOpt>) {
if batches.is_empty() {
return;
}
let _ = crate::scanner_code::flush_code_batches(store, scope, batches, &config.documents.embedding_preset);
}
#[cfg(not(feature = "code-search"))]
fn flush_code_batches_if_any(_store: &mut Store, _config: &Config, _scope: &str, _batches: Vec<PendingCodeBatchOpt>) {}
#[cfg(feature = "code-search")]
fn flush_code_removals_if_any(store: &mut Store, config: &Config, scope: &str, stale: &[String]) {
crate::scanner_code::delete_stale_code_chunks(store, config, scope, stale);
}
#[cfg(not(feature = "code-search"))]
fn flush_code_removals_if_any(_store: &mut Store, _config: &Config, _scope: &str, _stale: &[String]) {}
#[cfg(feature = "code-search")]
fn finalize_bm25_stats_if_any(store: &Store, config: &Config) {
if !crate::scanner_code::should_chunk(config) {
return;
}
if let Some(db) = store.index_db.as_ref()
&& let Err(error) = db.recompute_bm25_stats()
{
tracing::warn!(?error, "recompute bm25 stats failed; keyword search may be stale");
}
}
#[cfg(not(feature = "code-search"))]
fn finalize_bm25_stats_if_any(_store: &Store, _config: &Config) {}