use std::path::{Path, PathBuf};
use std::time::SystemTime;
use rayon::prelude::*;
use thiserror::Error;
use tracing::debug;
use crate::config::Config;
use crate::extract::{self, ExtractError, FileMapL1, FileMapL2};
use crate::git::{GitError, Repo};
use crate::hashing;
use crate::index::{IndexDb, writer::IndexWriter};
use crate::lang;
use crate::path::RelPath;
#[cfg(feature = "code-search")]
use crate::scanner_code::PendingCodeBatch;
#[cfg(feature = "documents")]
use crate::scanner_docs::{PendingDocBatch, extract_and_persist_doc, flush_document_batches, should_extract_document};
use crate::scanner_filter::{Filters, IndexFilter, ignore_walk_builder};
use crate::store::{FileEntry, Store, StoreError};
const INDEX_COMMIT_BATCH: usize = 256;
const LARGE_SCAN_CANDIDATE_WARN: usize = 50_000;
struct WorkerIndexBatch<'a> {
index: Option<&'a IndexDb>,
writer: Option<IndexWriter>,
staged: usize,
results: Vec<FileResult>,
}
impl<'a> WorkerIndexBatch<'a> {
fn new(store: &'a Store) -> Self {
Self {
index: store.index_db.as_ref(),
writer: None,
staged: 0,
results: Vec::new(),
}
}
fn stage(&mut self, rel: &RelPath, l1: &FileMapL1, l2: Option<&FileMapL2>) -> bool {
let Some(index) = self.index else {
return true;
};
let writer = self.writer.get_or_insert_with(|| index.writer());
if writer.upsert_file(rel, l1, l2).is_err() {
return false;
}
self.staged += 1;
if self.staged >= INDEX_COMMIT_BATCH {
self.commit();
}
true
}
#[cfg(feature = "code-search")]
fn stage_bm25(&mut self, rel: &RelPath, postings: &[crate::search::bm25::ChunkPosting]) {
let Some(index) = self.index else {
return;
};
let writer = self.writer.get_or_insert_with(|| index.writer());
if writer.upsert_bm25_file(rel, postings).is_err() {
tracing::warn!(rel = %rel, "bm25 upsert failed; keyword search may be incomplete");
}
}
fn commit(&mut self) {
if let Some(writer) = self.writer.take()
&& writer.commit().is_err()
{
tracing::warn!("index batch commit failed; reference search may be incomplete");
}
self.staged = 0;
}
fn finish(mut self) -> Vec<FileResult> {
self.commit();
self.results
}
}
#[allow(clippy::too_many_arguments)]
const SCANNER_STACK_SIZE: usize = 256 * 1024 * 1024;
fn scanner_pool() -> &'static rayon::ThreadPool {
static POOL: std::sync::OnceLock<rayon::ThreadPool> = std::sync::OnceLock::new();
POOL.get_or_init(|| {
rayon::ThreadPoolBuilder::new()
.stack_size(SCANNER_STACK_SIZE)
.thread_name(|i| format!("bm-scan-{i}"))
.build()
.expect("build scanner rayon pool")
})
}
#[allow(clippy::too_many_arguments)]
fn run_candidates(
candidates: &[String],
root: &Path,
filters: &Filters,
store: &Store,
source: &ScanSource<'_>,
config: &Config,
scope: &str,
embed: EmbedMode,
) -> Vec<FileResult> {
scanner_pool().install(|| {
candidates
.par_iter()
.fold(
|| WorkerIndexBatch::new(store),
|mut batch, rel| {
let result = process_file(root, rel, filters, store, source, config, scope, &mut batch, embed);
batch.results.push(result);
batch
},
)
.map(WorkerIndexBatch::finish)
.reduce(Vec::new, |mut a, mut b| {
a.append(&mut b);
a
})
})
}
#[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 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 {
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,
},
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;
}
if matches!(source, ScanSource::WorkingTree) {
scanner_pool().install(|| crate::intel::resolve_pass::resolve_pass(root, store));
}
flush_doc_batches_if_any(store, config, &scope, doc_batches);
flush_code_batches_if_any(store, config, &scope, code_batches);
flush_code_removals_if_any(store, config, &scope, &stale);
#[cfg(feature = "documents")]
flush_doc_removals_if_any(store, config, &scope, &doc_stale);
finalize_bm25_stats_if_any(store, config);
store.flush()?;
Ok(report)
}
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) => p.to_string_lossy().replace('\\', "/"),
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;
}
scanner_pool().install(|| crate::intel::resolve_pass::resolve_pass_incremental(root, store, &rels));
flush_doc_batches_if_any(store, config, &scope, doc_batches);
flush_code_batches_if_any(store, config, &scope, code_batches);
flush_code_removals_if_any(store, config, &scope, &removed);
#[cfg(feature = "documents")]
flush_doc_removals_if_any(store, config, &scope, &doc_removed);
finalize_bm25_stats_if_any(store, config);
store.flush()?;
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: _,
} => {
report.stats.updated += 1;
if *had_errors {
report.stats.updated_with_warnings += 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, false).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 extraction_sidecars_present(store: &Store, config: &Config, hash_hex: &str) -> bool {
#[cfg(not(feature = "code-search"))]
let _ = config;
if !store.blob_path_fm_hex(hash_hex).exists() {
return false;
}
#[cfg(feature = "code-search")]
if crate::scanner_code::should_chunk(config) && !store.blob_path_chunk_hex(hash_hex).exists() {
return false;
}
true
}
#[allow(clippy::too_many_arguments)]
fn process_file(
root: &Path,
rel: &str,
filters: &Filters,
store: &Store,
source: &ScanSource<'_>,
config: &Config,
scope: &str,
index_batch: &mut WorkerIndexBatch<'_>,
embed: EmbedMode,
) -> FileResult {
#[cfg(not(feature = "documents"))]
{
let _ = (config, scope);
}
#[cfg(not(any(feature = "documents", feature = "code-search")))]
{
let _ = embed;
}
let lang = match lang::detect(Path::new(rel)) {
Some(l) => l,
None => {
#[cfg(feature = "documents")]
{
if matches!(source, ScanSource::WorkingTree) {
return process_doc(root, rel, filters, store, config, scope, embed);
}
}
return FileResult::bare(rel.to_string(), FileStatus::SkippedNoLang);
}
};
if matches!(source, ScanSource::WorkingTree)
&& let Some(existing) = store.lookup(rel)
&& existing.mtime != 0
&& let Ok(meta) = std::fs::metadata(root.join(rel))
{
let mtime = mtime_nanos(&meta);
if meta.len() == existing.size_bytes
&& mtime == existing.mtime
&& extraction_sidecars_present(store, config, &existing.hash_hex)
{
return FileResult::bare(rel.to_string(), FileStatus::Unchanged);
}
}
let (bytes, size_bytes, mtime) = match source {
ScanSource::WorkingTree => match read_working_tree(root, rel, filters) {
Ok(triple) => triple,
Err(status) => {
return FileResult::bare(rel.to_string(), status);
}
},
ScanSource::Staged(repo) => match read_via_git(filters, repo.read_blob_staged(rel)) {
Ok(triple) => triple,
Err(status) => {
return FileResult::bare(rel.to_string(), status);
}
},
ScanSource::Rev { repo, sha } => match read_via_git(filters, repo.read_blob_at_rev(sha, rel)) {
Ok(triple) => triple,
Err(status) => {
return FileResult::bare(rel.to_string(), status);
}
},
};
if looks_binary(&bytes) {
return FileResult::bare(rel.to_string(), FileStatus::SkippedBinary);
}
if std::str::from_utf8(&bytes).is_err() {
return FileResult::bare(rel.to_string(), FileStatus::SkippedNonUtf8);
}
let hash = hashing::hash_bytes(&bytes);
let hex_buf = hashing::hex_buf(&hash);
let hash_hex_str = hashing::hex_str(&hex_buf);
if let Some(existing) = store.lookup(rel)
&& existing.hash_hex == hash_hex_str
&& extraction_sidecars_present(store, config, hash_hex_str)
{
return FileResult::bare(rel.to_string(), FileStatus::Unchanged);
}
let want_l2 = filters.eager_l2 && store.index_db.is_some();
let (l1, l2_opt): (FileMapL1, Option<FileMapL2>) = match extract::extract_l1_l2(lang, &bytes, want_l2) {
Ok(pair) => pair,
Err(ExtractError::ParseTimeout(_)) => {
return FileResult::bare(rel.to_string(), FileStatus::ParseTimedOut);
}
Err(source) => {
return FileResult::bare(
rel.to_string(),
FileStatus::ExtractFailed {
msg: format_extract_err(&source),
},
);
}
};
let l2: Option<FileMapL2> = l2_opt;
if let Err(e) = store.write_filemap_hex(hash_hex_str, &l1, l2.as_ref()) {
return FileResult::bare(rel.to_string(), FileStatus::ExtractFailed { msg: e.to_string() });
}
let rel_path = RelPath::from(rel);
if !index_batch.stage(&rel_path, &l1, l2.as_ref()) {
tracing::warn!(rel, "index upsert failed; reference search may be incomplete");
}
#[cfg(feature = "code-search")]
let code_batch = if crate::scanner_code::should_chunk(config) {
match crate::scanner_code::chunk_and_embed(store, rel, &bytes, &l1, l2.as_ref(), hash_hex_str, config, embed) {
Ok(batch) => batch,
Err(error) => {
tracing::debug!(
rel,
?error,
"code chunk/embed failed; skipping code-search for this file"
);
None
}
}
} else {
None
};
#[cfg(feature = "code-search")]
if let Some(batch) = &code_batch {
index_batch.stage_bm25(&rel_path, &batch.bm25);
}
let entry = FileEntry {
hash_hex: hash_hex_str.to_string(),
language: lang.to_string(),
size_bytes,
mtime,
};
FileResult {
path: rel.to_string(),
status: FileStatus::Updated {
had_errors: l1.had_errors,
error_count: l1.error_count,
},
upsert: Some(entry),
#[cfg(feature = "documents")]
doc_batch: None,
#[cfg(feature = "documents")]
doc_upsert: None,
#[cfg(feature = "code-search")]
code_batch,
}
}
#[cfg(feature = "documents")]
fn process_doc(
root: &Path,
rel: &str,
filters: &Filters,
store: &Store,
config: &Config,
scope: &str,
embed: EmbedMode,
) -> FileResult {
let abs = root.join(rel);
let effective_scope = crate::scanner_docs::doc_scope_for(rel, scope, config);
let scope = effective_scope.as_ref();
let Some(mime_type) = should_extract_document(&abs, &config.documents) else {
return FileResult::bare(rel.to_string(), FileStatus::SkippedNoLang);
};
let (bytes, size_bytes, mtime) = match read_working_tree(root, rel, filters) {
Ok(triple) => triple,
Err(status) => return FileResult::bare(rel.to_string(), status),
};
let hash = hashing::hash_bytes(&bytes);
let hex_buf = hashing::hex_buf(&hash);
let hash_hex = hashing::hex_str(&hex_buf);
if let Some(existing) = store.lookup_doc(rel)
&& existing.hash_hex == hash_hex
&& existing.embedding_preset == config.documents.embedding_preset
&& store.blob_path_doc_hex(hash_hex).exists()
{
return FileResult::bare(rel.to_string(), FileStatus::Unchanged);
}
let doc_entry = crate::store::DocEntry {
hash_hex: hash_hex.to_string(),
embedding_preset: config.documents.embedding_preset.clone(),
size_bytes,
mtime,
};
match extract_and_persist_doc(
store,
rel,
&abs,
&hash,
&mime_type,
&config.documents,
&config.llm,
scope,
embed,
) {
Ok(Some(batch)) => {
let status = FileStatus::DocIndexed {
chunk_count: batch.chunk_count,
embedding_dim: batch.embedding_dim,
};
let doc_upsert = match embed {
EmbedMode::Inline => Some(doc_entry),
EmbedMode::Deferred => None,
};
FileResult {
path: rel.to_string(),
status,
upsert: None,
doc_batch: Some(batch),
doc_upsert,
#[cfg(feature = "code-search")]
code_batch: None,
}
}
Ok(None) => FileResult::bare(rel.to_string(), FileStatus::SkippedNoLang),
Err(error) => {
let msg = format!("document extract: {error:#}");
if is_unsupported_format_error(&msg) {
tracing::debug!(path = rel, reason = %msg, "skipping file: not an extractable document");
FileResult::bare(rel.to_string(), FileStatus::SkippedNoLang)
} else {
tracing::debug!(path = rel, error = %msg, "document extraction failed");
FileResult::bare(rel.to_string(), FileStatus::ExtractFailed { msg })
}
}
}
}
#[cfg(feature = "documents")]
fn is_unsupported_format_error(msg: &str) -> bool {
msg.to_ascii_lowercase().contains("unsupported format")
}
fn mtime_nanos(metadata: &std::fs::Metadata) -> i64 {
metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
fn read_working_tree(root: &Path, rel: &str, filters: &Filters) -> Result<(Vec<u8>, u64, i64), FileStatus> {
let abs = root.join(rel);
let metadata = std::fs::metadata(&abs).map_err(|e| FileStatus::ReadFailed {
kind: e.kind(),
msg: e.to_string(),
})?;
if metadata.len() > filters.max_file_bytes {
return Err(FileStatus::SkippedTooLarge { size: metadata.len() });
}
let bytes = std::fs::read(&abs).map_err(|e| FileStatus::ReadFailed {
kind: e.kind(),
msg: e.to_string(),
})?;
let mtime = mtime_nanos(&metadata);
let size = metadata.len();
Ok((bytes, size, mtime))
}
fn read_via_git(filters: &Filters, blob: Result<Option<Vec<u8>>, GitError>) -> Result<(Vec<u8>, u64, i64), FileStatus> {
let blob = blob.map_err(|e| FileStatus::ReadFailed {
kind: std::io::ErrorKind::Other,
msg: e.to_string(),
})?;
let bytes = blob.ok_or(FileStatus::ReadFailed {
kind: std::io::ErrorKind::NotFound,
msg: "blob not present in this git source".to_string(),
})?;
if bytes.len() as u64 > filters.max_file_bytes {
return Err(FileStatus::SkippedTooLarge {
size: bytes.len() as u64,
});
}
let size = bytes.len() as u64;
Ok((bytes, size, 0))
}
fn format_extract_err(e: &ExtractError) -> String {
e.to_string()
}
pub fn looks_binary(bytes: &[u8]) -> bool {
let probe = &bytes[..bytes.len().min(8 * 1024)];
memchr::memchr(0, probe).is_some()
}
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) {}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "documents")]
#[test]
fn unsupported_format_error_is_a_skip_not_a_failure() {
assert!(is_unsupported_format_error(
"document extract: Unsupported format: application/x-wais-source"
));
assert!(is_unsupported_format_error("Unsupported Format: text/x-foo"));
assert!(!is_unsupported_format_error(
"document extract: failed to parse PDF: corrupt xref table"
));
assert!(!is_unsupported_format_error(
"document extract: OCR engine returned no text"
));
}
#[test]
fn looks_binary_detects_nul_in_first_kib() {
let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
data.extend_from_slice(&[0; 32]);
assert!(looks_binary(&data));
}
#[test]
fn looks_binary_accepts_plain_source() {
assert!(!looks_binary(b"pub fn hello() {}\n"));
assert!(!looks_binary(b"")); }
#[test]
fn looks_binary_ignores_nul_past_probe_window() {
let mut data = vec![b'/'; 8 * 1024];
data.push(0);
assert!(!looks_binary(&data));
}
}