use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use thiserror::Error;
use tracing::debug;
use crate::config::Config;
use crate::git::{GitError, Repo};
use crate::path::RelPath;
use crate::scan_evidence::{
PHASE_CANDIDATES, PHASE_EXTRACT, PHASE_FLUSH, PHASE_LANES, PHASE_PURGE, PHASE_RESOLVE, ScanBreadcrumb,
};
use crate::scanner_candidates::walk_candidates;
#[cfg(feature = "code-search")]
use crate::scanner_code::PendingCodeBatch;
#[cfg(feature = "documents")]
use crate::scanner_docs::{PendingDocBatch, flush_document_batches};
use crate::scanner_drive::{DriveOutcome, PendingCodeBatchOpt, PendingDocBatchOpt, ScanDrive, drive_scan};
use crate::scanner_file::scanner_pool;
use crate::scanner_filter::{Filters, IndexFilter};
#[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_EMBED_POLICY, LANE_RESOLVE,
run_optional_lane,
};
use crate::store::{FileEntry, Store, StoreError};
pub use crate::scanner_file::looks_binary;
pub use crate::scanner_candidates::{ALLOW_EXTRA_ROOTS_ENV, allow_extra_roots};
pub use crate::scanner_drive::{CollectObserver, NullObserver, ScanObserver};
#[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("invalid [languages] config: {0}")]
BadLanguage(String),
#[error("git error: {0}")]
Git(#[from] GitError),
#[error(
"scan aborted: {root} yielded at least {candidates} candidate files, over the [scan] \
max_candidates ceiling of {cap}. Largest contributors: {}. Add them to [scan] exclude or \
.gitignore, drop the [scan] extra_roots entry that named them, or raise [scan] \
max_candidates.",
render_top_dirs(.top_dirs)
)]
TooManyCandidates {
candidates: usize,
cap: usize,
root: String,
top_dirs: Vec<(String, usize)>,
},
#[error(
"scan aborted: the walk under {root} visited {visited} filesystem entries and kept only \
{candidates} of them, so it is enumerating a tree it will never finish. Largest \
contributors: {}. Prune them with directory-shaped patterns (`dir/**`) in [scan] exclude \
or .gitignore, point the scan at a narrower root, or raise [scan] max_candidates (the \
walk budget scales with it; {cap} today).",
render_top_dirs(.top_dirs)
)]
WalkTooLarge {
visited: usize,
candidates: usize,
cap: usize,
root: String,
top_dirs: Vec<(String, usize)>,
},
}
fn render_top_dirs(top_dirs: &[(String, usize)]) -> String {
if top_dirs.is_empty() {
return "(none)".to_string();
}
top_dirs
.iter()
.map(|(name, count)| format!("{name} ({count})"))
.collect::<Vec<_>>()
.join(", ")
}
#[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,
pub reused_doc_extraction: usize,
pub docs_degraded: usize,
pub doc_timeouts: usize,
pub refreshed_extraction: usize,
pub tier_migrated: usize,
}
impl ScanStats {
pub fn displaced_artifacts(&self) -> bool {
self.tier_migrated > 0 || self.refreshed_extraction > 0
}
}
#[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,
#[cfg(feature = "documents")]
DocUnchanged,
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,
reused: bool,
degraded: bool,
},
#[cfg(feature = "documents")]
DocTimedOut,
}
#[derive(Debug, Clone, Default)]
pub struct ScanReport {
pub stats: ScanStats,
pub cancelled: bool,
}
#[derive(Clone, Debug, Default)]
pub struct ScanCancel(Arc<AtomicBool>);
impl ScanCancel {
pub fn new() -> Self {
Self::default()
}
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
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> {
scan_with_cancel(root, store, config, source, embed, &ScanCancel::new())
}
pub fn scan_with_cancel(
root: &Path,
store: &mut Store,
config: &Config,
source: ScanSource<'_>,
embed: EmbedMode,
cancel: &ScanCancel,
) -> Result<ScanReport, ScanError> {
scan_with_observer(root, store, config, source, embed, cancel, &mut NullObserver)
}
#[allow(clippy::too_many_arguments)]
pub fn scan_with_observer(
root: &Path,
store: &mut Store,
config: &Config,
source: ScanSource<'_>,
embed: EmbedMode,
cancel: &ScanCancel,
observer: &mut dyn ScanObserver,
) -> Result<ScanReport, ScanError> {
let mut breadcrumb = ScanBreadcrumb::begin(root);
let _memory_log = crate::scan_evidence::MemoryLog::start(root);
let submodule_roots = submodule_roots_for_source(root, &source);
let mut filters = Filters::build(config, submodule_roots)?;
let policy = matches!(source, ScanSource::WorkingTree)
.then(|| crate::scanner_policy::detect(store, config))
.filter(|p| matches!(embed, EmbedMode::Inline) && p.changed);
filters.reflush_embeds = policy.as_ref().is_some_and(|p| p.reflush);
advance(&mut breadcrumb, PHASE_CANDIDATES, None);
let candidates = candidates_for_source(root, config, &filters, &source, cancel)?;
debug!(count = candidates.len(), kind = source.label(), "scan candidates");
advance(&mut breadcrumb, PHASE_EXTRACT, Some(candidates.len()));
let scope = derive_scope(root, &source);
let mut report = ScanReport::default();
let drive = ScanDrive {
root,
filters: &filters,
source: &source,
config,
scope: &scope,
embed,
cancel,
};
let driven = drive_scan(&drive, &candidates, store, &mut report.stats, observer);
if cancel.is_cancelled() {
report.cancelled = true;
flush_code_map(store)?;
run_optional_lane(LANE_DOC_BATCHES, || {
flush_doc_batches_if_any(store, config, driven.doc_batches);
});
run_optional_lane(LANE_CODE_BATCHES, || {
flush_code_batches_if_any(store, config, &scope, driven.code_batches);
});
return Ok(report);
}
advance(&mut breadcrumb, PHASE_PURGE, None);
let stale: Vec<String> = store
.index
.files
.keys()
.filter(|k| !driven.seen.contains(k.to_str_lossy().as_ref()))
.map(|k| k.to_str_lossy().into_owned())
.collect();
#[cfg(feature = "documents")]
let doc_stale: Vec<String> = store
.index
.doc_files
.keys()
.filter(|k| !driven.doc_seen.contains(k.to_str_lossy().as_ref()))
.map(|k| k.to_str_lossy().into_owned())
.collect();
#[cfg(feature = "documents")]
{
report.stats.tier_migrated = stale.iter().filter(|k| driven.doc_seen.contains(k.as_str())).count()
+ doc_stale.iter().filter(|k| driven.seen.contains(k.as_str())).count();
}
let DriveOutcome {
doc_batches,
code_batches,
..
} = driven;
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());
}
observer.on_file(FileResult::bare(k.clone(), FileStatus::Removed));
report.stats.removed += 1;
}
if let Some(change) = &policy {
let mut applied = false;
run_optional_lane(LANE_EMBED_POLICY, || {
applied = crate::scanner_policy::reconcile(store, config, &filters, &scope);
});
if applied {
crate::scanner_policy::record(store, change);
}
}
advance(&mut breadcrumb, PHASE_FLUSH, None);
flush_code_map(store)?;
if cancel.is_cancelled() {
report.cancelled = true;
run_optional_lane(LANE_DOC_BATCHES, || {
flush_doc_batches_if_any(store, config, doc_batches);
});
run_optional_lane(LANE_CODE_BATCHES, || {
flush_code_batches_if_any(store, config, &scope, code_batches);
});
return Ok(report);
}
if matches!(source, ScanSource::WorkingTree) {
advance(&mut breadcrumb, PHASE_RESOLVE, None);
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));
});
}
advance(&mut breadcrumb, PHASE_LANES, None);
run_optional_lane(LANE_DOC_BATCHES, || {
flush_doc_batches_if_any(store, config, 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 advance(breadcrumb: &mut Option<ScanBreadcrumb>, phase: &'static str, candidates: Option<usize>) {
if let Some(crumb) = breadcrumb.as_mut() {
crumb.advance(phase, candidates);
}
}
fn flush_code_map(store: &Store) -> Result<(), ScanError> {
store.flush()?;
store.write_status_sidecar();
Ok(())
}
pub fn scan_paths(
root: &Path,
store: &mut Store,
config: &Config,
paths: &[PathBuf],
embed: EmbedMode,
) -> Result<ScanReport, ScanError> {
scan_paths_with_cancel(root, store, config, paths, embed, &ScanCancel::new())
}
pub fn scan_paths_with_cancel(
root: &Path,
store: &mut Store,
config: &Config,
paths: &[PathBuf],
embed: EmbedMode,
cancel: &ScanCancel,
) -> Result<ScanReport, ScanError> {
scan_paths_with_observer(root, store, config, paths, embed, cancel, &mut NullObserver)
}
#[allow(clippy::too_many_arguments)]
pub fn scan_paths_with_observer(
root: &Path,
store: &mut Store,
config: &Config,
paths: &[PathBuf],
embed: EmbedMode,
cancel: &ScanCancel,
observer: &mut dyn ScanObserver,
) -> 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) {
if store.lookup(&rel).is_some() {
removed.push(rel.clone());
}
#[cfg(feature = "documents")]
if store.lookup_doc(&rel).is_some() {
doc_removed.push(rel);
}
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 mut report = ScanReport::default();
let drive = ScanDrive {
root,
filters: filter.filters(),
source: &source,
config,
scope: &scope,
embed,
cancel,
};
let DriveOutcome {
doc_batches,
code_batches,
seen,
#[cfg(feature = "documents")]
doc_seen,
} = drive_scan(&drive, &rels, store, &mut report.stats, observer);
if !cancel.is_cancelled() {
for rel in &rels {
if !seen.contains(rel) && store.lookup(rel).is_some() {
removed.push(rel.clone());
#[cfg(feature = "documents")]
if doc_seen.contains(rel) {
report.stats.tier_migrated += 1;
}
}
#[cfg(feature = "documents")]
if !doc_seen.contains(rel) && store.lookup_doc(rel).is_some() {
doc_removed.push(rel.clone());
if seen.contains(rel) {
report.stats.tier_migrated += 1;
}
}
}
}
removed.sort();
removed.dedup();
#[cfg(feature = "documents")]
{
doc_removed.sort();
doc_removed.dedup();
}
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());
}
observer.on_file(FileResult::bare(rel.clone(), FileStatus::Removed));
report.stats.removed += 1;
}
#[cfg(feature = "documents")]
for rel in &doc_removed {
observer.on_file(FileResult::bare(rel.clone(), FileStatus::Removed));
report.stats.removed += 1;
}
flush_code_map(store)?;
if cancel.is_cancelled() {
report.cancelled = true;
run_optional_lane(LANE_DOC_BATCHES, || {
flush_doc_batches_if_any(store, config, doc_batches);
});
run_optional_lane(LANE_CODE_BATCHES, || {
flush_code_batches_if_any(store, config, &scope, code_batches);
});
return Ok(report);
}
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, 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)
}
fn candidates_for_source(
root: &Path,
config: &Config,
filters: &Filters,
source: &ScanSource<'_>,
cancel: &ScanCancel,
) -> Result<Vec<String>, ScanError> {
let raw = match source {
ScanSource::WorkingTree => walk_candidates(root, config, filters, cancel)?,
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 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, batches: Vec<PendingDocBatchOpt>) {
if batches.is_empty() {
return;
}
crate::scanner_doc_links::flush_doc_links(store, config, &batches);
let _ = flush_document_batches(store, batches, &config.documents.embedding_preset);
}
#[cfg(not(feature = "documents"))]
fn flush_doc_batches_if_any(_store: &mut Store, _config: &Config, _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) {}