1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::UNIX_EPOCH;
4
5use serde::{Deserialize, Serialize};
6mod build;
7mod chunking;
8pub use chunking::*;
9mod coordinator;
10pub use coordinator::{SearchIndexBuildProgress, get_or_start_build};
11#[cfg(test)]
12mod tests;
13
14const MAX_BM25_FILES: usize = 5000;
15const CHUNK_COUNT_WARNING: usize = 50_000;
16const ZSTD_LEVEL: i32 = 9;
17
18const DEFAULT_BM25_IGNORES: &[&str] = &[
19 "vendor/**",
20 "dist/**",
21 "build/**",
22 "public/vendor/**",
23 "public/js/**",
24 "public/css/**",
25 "public/build/**",
26 ".next/**",
27 ".nuxt/**",
28 "__pycache__/**",
29 "*.min.js",
30 "*.min.css",
31 "*.bundle.js",
32 "*.chunk.js",
33];
34
35fn max_bm25_cache_bytes() -> u64 {
36 let mb = std::env::var("LEAN_CTX_BM25_MAX_CACHE_MB")
40 .ok()
41 .and_then(|v| v.parse::<u64>().ok())
42 .unwrap_or_else(|| crate::core::config::Config::load().bm25_max_cache_mb_effective());
43 mb * 1024 * 1024
44}
45
46pub fn persist_ceiling_bytes() -> u64 {
50 max_bm25_cache_bytes()
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum SaveOutcome {
58 Persisted { compressed_bytes: u64 },
60 SkippedTooLarge {
65 compressed_bytes: u64,
66 limit_bytes: u64,
67 },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct CodeChunk {
72 pub file_path: String,
73 pub symbol_name: String,
74 pub kind: ChunkKind,
75 pub start_line: usize,
76 pub end_line: usize,
77 pub content: String,
78 #[serde(default)]
79 pub tokens: Vec<String>,
80 pub token_count: usize,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub enum ChunkKind {
85 Function,
86 Struct,
87 Impl,
88 Module,
89 Class,
90 Method,
91 Other,
92 Issue,
94 PullRequest,
95 WikiPage,
96 DbSchema,
97 ApiEndpoint,
98 Ticket,
99 ExternalOther,
100}
101
102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
103pub struct IndexedFileState {
104 pub mtime_ms: u64,
105 pub size_bytes: u64,
106}
107
108impl IndexedFileState {
109 fn from_path(path: &Path) -> Option<Self> {
110 let meta = path.metadata().ok()?;
111 let size_bytes = meta.len();
112 let mtime_ms = meta
113 .modified()
114 .ok()
115 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
116 .map(|d| d.as_millis() as u64)?;
117 Some(Self {
118 mtime_ms,
119 size_bytes,
120 })
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct BM25Index {
126 pub chunks: Vec<CodeChunk>,
127 pub inverted: HashMap<String, Vec<(usize, f64)>>,
128 pub avg_doc_len: f64,
129 pub doc_count: usize,
130 pub doc_freqs: HashMap<String, usize>,
131 #[serde(default)]
132 pub files: HashMap<String, IndexedFileState>,
133 #[serde(default, skip)]
139 pub content_truncated: bool,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct SearchResult {
144 pub chunk_idx: usize,
145 pub score: f64,
146 pub file_path: String,
147 pub symbol_name: String,
148 pub kind: ChunkKind,
149 pub start_line: usize,
150 pub end_line: usize,
151 pub snippet: String,
152}
153
154const BM25_K1: f64 = 1.2;
155const BM25_B: f64 = 0.75;
156
157fn structural_query_boost(query: &str, chunk: &CodeChunk) -> f64 {
158 let raw_terms: std::collections::HashSet<String> = query
159 .split(|c: char| !(c.is_alphanumeric() || c == '_'))
160 .filter(|term| term.len() >= 3)
161 .map(str::to_ascii_lowercase)
162 .collect();
163 let path_terms: std::collections::HashSet<String> = tokenize(&chunk.file_path)
164 .into_iter()
165 .map(|term| term.to_ascii_lowercase())
166 .collect();
167
168 let mut boost = 0.0;
169 if raw_terms.contains(&chunk.symbol_name.to_ascii_lowercase()) {
170 boost += 32.0;
171 }
172 boost += raw_terms.intersection(&path_terms).count() as f64 * 8.0;
173
174 let has_identifier = query.split_whitespace().any(|term| {
175 let has_lower = term.chars().any(char::is_lowercase);
176 let has_upper = term.chars().any(char::is_uppercase);
177 (has_lower && has_upper) || term.contains('_') || term.contains('.')
178 });
179 if has_identifier {
180 let extension = std::path::Path::new(&chunk.file_path)
181 .extension()
182 .and_then(std::ffi::OsStr::to_str)
183 .unwrap_or_default()
184 .to_ascii_lowercase();
185 if matches!(extension.as_str(), "md" | "mdx" | "rst" | "txt" | "adoc") {
186 boost -= 8.0;
187 } else if !extension.is_empty() {
188 boost += 4.0;
189 }
190 }
191 boost
192}
193
194impl Default for BM25Index {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200impl BM25Index {
201 pub fn new() -> Self {
202 Self {
203 chunks: Vec::new(),
204 inverted: HashMap::new(),
205 avg_doc_len: 0.0,
206 doc_count: 0,
207 doc_freqs: HashMap::new(),
208 files: HashMap::new(),
209 content_truncated: false,
210 }
211 }
212
213 pub fn memory_usage_bytes(&self) -> usize {
215 let chunks_size: usize = self
216 .chunks
217 .iter()
218 .map(|c| {
219 c.content.len()
220 + c.file_path.len()
221 + c.symbol_name.len()
222 + c.tokens.iter().map(String::len).sum::<usize>()
223 + 64
224 })
225 .sum();
226 let inverted_size: usize = self
227 .inverted
228 .iter()
229 .map(|(k, v)| k.len() + v.len() * 16 + 32)
230 .sum();
231 let files_size: usize = self.files.keys().map(|k| k.len() + 24).sum();
232 let freqs_size: usize = self.doc_freqs.keys().map(|k| k.len() + 16).sum();
233 chunks_size + inverted_size + files_size + freqs_size
234 }
235
236 pub fn unload(&mut self) {
238 let usage = self.memory_usage_bytes();
239 self.chunks = Vec::new();
240 self.inverted = HashMap::new();
241 self.doc_freqs = HashMap::new();
242 self.files = HashMap::new();
243 self.avg_doc_len = 0.0;
244 self.doc_count = 0;
245 tracing::info!(
246 "[bm25] unloaded index, freed ~{:.1}MB",
247 usage as f64 / 1_048_576.0
248 );
249 }
250
251 pub fn shrink_resident_content_to_snippet(&mut self, keep_lines: usize) {
266 let before = self.memory_usage_bytes();
267 for chunk in &mut self.chunks {
268 if chunk.content.lines().nth(keep_lines).is_some() {
271 let trimmed: String = chunk
272 .content
273 .lines()
274 .take(keep_lines)
275 .collect::<Vec<_>>()
276 .join("\n");
277 chunk.content = trimmed;
278 chunk.content.shrink_to_fit();
280 }
281 }
282 self.content_truncated = true;
283 let after = self.memory_usage_bytes();
284 tracing::debug!(
285 "[bm25] shrank resident content to {keep_lines} lines/chunk, freed ~{:.1}MB",
286 before.saturating_sub(after) as f64 / 1_048_576.0
287 );
288 }
289
290 #[cfg(test)]
292 pub(crate) fn from_chunks_for_test(chunks: Vec<CodeChunk>) -> Self {
293 let mut index = Self::new();
294 for mut chunk in chunks {
295 if chunk.token_count == 0 {
296 chunk.token_count = tokenize(&chunk.content).len();
297 }
298 index.add_chunk(chunk);
299 }
300 index.finalize();
301 index
302 }
303
304 pub fn build_from_directory(root: &Path) -> Self {
305 Self::build_from_directory_inner(root, &HashMap::new())
306 }
307
308 pub fn build_with_content_hint(root: &Path, content_hint: &HashMap<String, String>) -> Self {
311 Self::build_from_directory_inner(root, content_hint)
312 }
313
314 fn build_from_directory_inner(root: &Path, content_hint: &HashMap<String, String>) -> Self {
315 let root_str = root.to_string_lossy();
316 if !super::graph_index::is_safe_scan_root_public(&root_str) {
317 tracing::warn!("[bm25: scan aborted for unsafe root {root_str}]");
318 return Self::new();
319 }
320 let files = list_code_files(root);
321
322 if files.len() >= build::PARALLEL_MIN_FILES
332 && !crate::core::memory_guard::is_under_pressure()
333 && !crate::core::memory_guard::abort_requested()
334 && crate::core::index_admission::admit_files(
335 crate::core::index_admission::BuildKind::Bm25,
336 root,
337 &files,
338 )
339 .parallel_ok
340 {
341 return Self::build_parallel(root, content_hint, &files);
342 }
343 Self::build_sequential(root, content_hint, &files)
344 }
345
346 fn group_prev_chunks_by_file(prev: &BM25Index) -> HashMap<String, Vec<CodeChunk>> {
351 let mut old_by_file: HashMap<String, Vec<CodeChunk>> = HashMap::new();
352 for c in &prev.chunks {
353 old_by_file
354 .entry(c.file_path.clone())
355 .or_default()
356 .push(c.clone());
357 }
358 for v in old_by_file.values_mut() {
359 v.sort_by(|a, b| {
360 a.start_line
361 .cmp(&b.start_line)
362 .then_with(|| a.end_line.cmp(&b.end_line))
363 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
364 });
365 }
366 old_by_file
367 }
368
369 pub fn rebuild_incremental(root: &Path, prev: &BM25Index) -> Self {
370 let old_by_file = Self::group_prev_chunks_by_file(prev);
371 let files = list_code_files(root);
372
373 if files.len() >= build::PARALLEL_MIN_FILES
383 && !crate::core::memory_guard::is_under_pressure()
384 && !crate::core::memory_guard::abort_requested()
385 && crate::core::index_admission::admit_files(
386 crate::core::index_admission::BuildKind::Bm25,
387 root,
388 &files,
389 )
390 .parallel_ok
391 {
392 return Self::rebuild_incremental_parallel(root, prev, &old_by_file, &files);
393 }
394 Self::rebuild_incremental_sequential(root, prev, &old_by_file, &files)
395 }
396
397 pub(crate) fn rebuild_incremental_sequential(
401 root: &Path,
402 prev: &BM25Index,
403 old_by_file: &HashMap<String, Vec<CodeChunk>>,
404 files: &[String],
405 ) -> Self {
406 let mut index = Self::new();
407 const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024;
408
409 for (i, rel) in files.iter().enumerate() {
410 if i.is_multiple_of(500) && crate::core::memory_guard::is_under_pressure() {
411 tracing::warn!(
412 "[bm25: stopping incremental rebuild at file {i}/{} due to memory pressure]",
413 files.len()
414 );
415 break;
416 }
417
418 let abs = root.join(rel);
419 let Some(state) = IndexedFileState::from_path(&abs) else {
420 continue;
421 };
422
423 let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
424 if unchanged
425 && let Some(chunks) = old_by_file.get(rel)
426 && chunks.first().is_some_and(|c| !c.content.is_empty())
427 {
428 for chunk in chunks {
429 index.add_chunk(chunk.clone());
430 }
431 index.files.insert(rel.clone(), state);
432 continue;
433 }
434
435 if state.size_bytes > MAX_FILE_SIZE_BYTES {
436 continue;
437 }
438 let content = if crate::core::extractors::is_binary_document(&abs) {
439 match std::fs::read(&abs) {
440 Ok(bytes) => crate::core::extractors::extract(&abs, &bytes).text,
441 Err(_) => continue,
442 }
443 } else {
444 match std::fs::read_to_string(&abs) {
445 Ok(c) => c,
446 Err(_) => continue,
447 }
448 };
449 if content.is_empty() {
450 continue;
451 }
452 let mut chunks = extract_chunks(rel, &content);
453 chunks.sort_by(|a, b| {
454 a.start_line
455 .cmp(&b.start_line)
456 .then_with(|| a.end_line.cmp(&b.end_line))
457 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
458 });
459 for chunk in chunks {
460 index.add_chunk(chunk);
461 }
462 index.files.insert(rel.clone(), state);
463 }
464
465 index.finalize();
466 index
467 }
468
469 fn add_chunk(&mut self, mut chunk: CodeChunk) {
470 let idx = self.chunks.len();
471
472 let enriched = enrich_for_bm25(&chunk);
473 let tokens = tokenize(&enriched);
474 for token in &tokens {
475 let lower = token.to_lowercase();
476 let postings = self.inverted.entry(lower.clone()).or_default();
477 if postings.last().map(|(last_idx, _)| *last_idx) != Some(idx) {
478 *self.doc_freqs.entry(lower).or_insert(0) += 1;
479 }
480 postings.push((idx, 1.0));
481 }
482
483 const SNIPPET_LINES: usize = 10;
486 if chunk.content.lines().nth(SNIPPET_LINES).is_some() {
487 chunk.content = chunk
488 .content
489 .lines()
490 .take(SNIPPET_LINES)
491 .collect::<Vec<_>>()
492 .join("\n");
493 chunk.content.shrink_to_fit();
494 }
495
496 self.chunks.push(CodeChunk {
497 token_count: tokens.len(),
498 tokens: Vec::new(),
499 ..chunk
500 });
501 }
502
503 fn finalize(&mut self) {
504 self.doc_count = self.chunks.len();
505 if self.doc_count == 0 {
506 return;
507 }
508
509 let total_len: usize = self.chunks.iter().map(|c| c.token_count).sum();
510 self.avg_doc_len = total_len as f64 / self.doc_count as f64;
511 }
512
513 pub fn search(&self, query: &str, top_k: usize) -> Vec<SearchResult> {
514 let query_tokens = tokenize(query);
515 if query_tokens.is_empty() || self.doc_count == 0 {
516 return Vec::new();
517 }
518
519 let n = self.chunks.len();
522 let mut scores = vec![0.0f64; n];
523 let mut touched = Vec::with_capacity(n.min(256));
524
525 for token in &query_tokens {
526 let lower = token.to_lowercase();
527 let df = *self.doc_freqs.get(&lower).unwrap_or(&0) as f64;
528 if df == 0.0 {
529 continue;
530 }
531
532 let idf = ((self.doc_count as f64 - df + 0.5) / (df + 0.5) + 1.0).ln();
533
534 if let Some(postings) = self.inverted.get(&lower) {
535 for &(idx, weight) in postings {
536 let doc_len = self.chunks[idx].token_count as f64;
537 let norm_len = doc_len / self.avg_doc_len.max(1.0);
538 let bm25 = idf * (weight * (BM25_K1 + 1.0))
539 / (weight + BM25_K1 * (1.0 - BM25_B + BM25_B * norm_len));
540
541 if scores[idx] == 0.0 {
542 touched.push(idx);
543 }
544 scores[idx] += bm25;
545 }
546 }
547 }
548
549 for &idx in &touched {
550 scores[idx] += structural_query_boost(query, &self.chunks[idx]);
551 }
552
553 let mut results: Vec<SearchResult> = touched
554 .iter()
555 .filter(|&&idx| scores[idx] > 0.0)
556 .map(|&idx| {
557 let chunk = &self.chunks[idx];
558 let snippet = chunk.content.lines().take(5).collect::<Vec<_>>().join("\n");
559 SearchResult {
560 chunk_idx: idx,
561 score: scores[idx],
562 file_path: chunk.file_path.clone(),
563 symbol_name: chunk.symbol_name.clone(),
564 kind: chunk.kind.clone(),
565 start_line: chunk.start_line,
566 end_line: chunk.end_line,
567 snippet,
568 }
569 })
570 .collect();
571
572 results.sort_by(|a, b| {
573 b.score
574 .partial_cmp(&a.score)
575 .unwrap_or(std::cmp::Ordering::Equal)
576 .then_with(|| a.file_path.cmp(&b.file_path))
577 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
578 .then_with(|| a.start_line.cmp(&b.start_line))
579 .then_with(|| a.end_line.cmp(&b.end_line))
580 });
581 results.truncate(top_k);
582 results
583 }
584
585 pub fn save(&self, root: &Path) -> std::io::Result<SaveOutcome> {
586 if self.chunks.len() > CHUNK_COUNT_WARNING {
587 tracing::warn!(
588 "[bm25] index has {} chunks (threshold {}), consider adding extra_ignore_patterns",
589 self.chunks.len(),
590 CHUNK_COUNT_WARNING
591 );
592 }
593
594 let dir = index_dir(root);
595 std::fs::create_dir_all(&dir)?;
596
597 let target = dir.join("bm25_index.bin.zst");
600 let tmp = dir.join("bm25_index.bin.zst.tmp");
601 {
602 let file = std::fs::File::create(&tmp)?;
603 let buf_writer = std::io::BufWriter::new(file);
604 let mut encoder = zstd::Encoder::new(buf_writer, ZSTD_LEVEL)
605 .map_err(|e| std::io::Error::other(format!("zstd encoder init: {e}")))?;
606 postcard::to_io(self, &mut encoder)
607 .map_err(|e| std::io::Error::other(e.to_string()))?;
608 encoder.finish()?;
609 }
610
611 let compressed_bytes = std::fs::metadata(&tmp)?.len();
612 let max_bytes = max_bm25_cache_bytes();
613 if compressed_bytes > max_bytes {
614 let _ = std::fs::remove_file(&tmp);
615 tracing::warn!(
616 "[bm25] compressed index too large ({:.1} MB, limit {:.0} MB), refusing to persist: {}",
617 compressed_bytes as f64 / 1_048_576.0,
618 max_bytes / (1024 * 1024),
619 dir.display()
620 );
621 return Ok(SaveOutcome::SkippedTooLarge {
622 compressed_bytes,
623 limit_bytes: max_bytes,
624 });
625 }
626
627 tracing::info!(
628 "[bm25] index: {:.1} MB zstd compressed",
629 compressed_bytes as f64 / 1_048_576.0,
630 );
631
632 std::fs::rename(&tmp, &target)?;
633
634 let _ = std::fs::remove_file(dir.join("bm25_index.bin"));
635 let _ = std::fs::remove_file(dir.join("bm25_index.json"));
636
637 let _ = std::fs::write(
638 dir.join("project_root.txt"),
639 root.to_string_lossy().as_bytes(),
640 );
641
642 Ok(SaveOutcome::Persisted { compressed_bytes })
643 }
644
645 pub fn load(root: &Path) -> Option<Self> {
646 let dir = index_dir(root);
647 let max_bytes = max_bm25_cache_bytes();
648
649 let zst_path = dir.join("bm25_index.bin.zst");
650 if zst_path.exists() {
651 let meta = std::fs::metadata(&zst_path).ok()?;
652 if meta.len() > max_bytes {
653 tracing::warn!(
654 "[bm25] compressed index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
655 meta.len() as f64 / 1_073_741_824.0,
656 max_bytes / (1024 * 1024),
657 zst_path.display()
658 );
659 let quarantined = zst_path.with_extension("zst.quarantined");
660 let _ = std::fs::rename(&zst_path, &quarantined);
661 return None;
662 }
663 let compressed = std::fs::read(&zst_path).ok()?;
664 let max_decompressed = max_bytes * 20; let data = bounded_zstd_decode(&compressed, max_decompressed)?;
666 let idx: Self = postcard::from_bytes(&data).ok()?;
667 return Some(idx);
668 }
669
670 let bin_path = dir.join("bm25_index.bin");
671 if bin_path.exists() {
672 let meta = std::fs::metadata(&bin_path).ok()?;
673 if meta.len() > max_bytes {
674 tracing::warn!(
675 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
676 meta.len() as f64 / 1_073_741_824.0,
677 max_bytes / (1024 * 1024),
678 bin_path.display()
679 );
680 let quarantined = bin_path.with_extension("bin.quarantined");
681 let _ = std::fs::rename(&bin_path, &quarantined);
682 return None;
683 }
684 let data = std::fs::read(&bin_path).ok()?;
685 let idx: Self = postcard::from_bytes(&data).ok()?;
686 if let Ok(compressed) = zstd::encode_all(data.as_slice(), ZSTD_LEVEL) {
688 let zst_tmp = zst_path.with_extension("zst.tmp");
689 if std::fs::write(&zst_tmp, &compressed).is_ok()
690 && std::fs::rename(&zst_tmp, &zst_path).is_ok()
691 {
692 tracing::info!(
693 "[bm25] migrated {:.1} MB → {:.1} MB zstd",
694 data.len() as f64 / 1_048_576.0,
695 compressed.len() as f64 / 1_048_576.0
696 );
697 let _ = std::fs::remove_file(&bin_path);
698 }
699 }
700 return Some(idx);
701 }
702
703 let json_path = dir.join("bm25_index.json");
704 if json_path.exists() {
705 let meta = std::fs::metadata(&json_path).ok()?;
706 if meta.len() > max_bytes {
707 tracing::warn!(
708 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
709 meta.len() as f64 / 1_073_741_824.0,
710 max_bytes / (1024 * 1024),
711 json_path.display()
712 );
713 let quarantined = json_path.with_extension("json.quarantined");
714 let _ = std::fs::rename(&json_path, &quarantined);
715 return None;
716 }
717 let data = std::fs::read_to_string(&json_path).ok()?;
718 return serde_json::from_str(&data).ok();
719 }
720
721 None
722 }
723
724 pub fn load_or_build(root: &Path) -> Self {
725 Self::load_or_build_inner(root, false)
726 }
727
728 pub fn load_or_build_fast(root: &Path) -> Self {
731 Self::load_or_build_inner(root, true)
732 }
733
734 fn load_or_build_inner(root: &Path, fast_stale: bool) -> Self {
735 if !is_safe_bm25_root(root) {
736 return Self::default();
737 }
738 if let Some(idx) = Self::load(root) {
739 let stale = if fast_stale {
740 bm25_index_looks_stale_fast(&idx, root)
741 } else {
742 bm25_index_looks_stale(&idx, root)
743 };
744 if !stale {
745 return idx;
746 }
747 tracing::debug!(
748 "[bm25_index: stale index detected for {}; rebuilding]",
749 root.display()
750 );
751 let rebuilt = if idx.files.is_empty() {
752 Self::build_from_directory(root)
753 } else {
754 Self::rebuild_incremental(root, &idx)
755 };
756 let _ = rebuilt.save(root);
757 return rebuilt;
758 }
759
760 let built = Self::build_from_directory(root);
761 let _ = built.save(root);
762 built
763 }
764
765 pub fn index_file_path(root: &Path) -> PathBuf {
766 let dir = index_dir(root);
767 let zst = dir.join("bm25_index.bin.zst");
768 if zst.exists() {
769 return zst;
770 }
771 let bin = dir.join("bm25_index.bin");
772 if bin.exists() {
773 return bin;
774 }
775 dir.join("bm25_index.json")
776 }
777
778 pub fn ingest_content_chunks(
782 &mut self,
783 chunks: impl IntoIterator<Item = super::content_chunk::ContentChunk>,
784 ) -> usize {
785 let mut count = 0usize;
786 for cc in chunks {
787 self.add_chunk(cc.into());
788 count += 1;
789 }
790 if count > 0 {
791 self.finalize();
792 }
793 count
794 }
795
796 pub fn external_chunk_count(&self) -> usize {
798 self.chunks
799 .iter()
800 .filter(|c| c.file_path.contains("://"))
801 .count()
802 }
803
804 pub fn remove_chunks_with_prefix(&mut self, prefix: &str) -> usize {
809 let before = self.chunks.len();
810 self.chunks.retain(|c| !c.file_path.starts_with(prefix));
811 let removed = before - self.chunks.len();
812 if removed > 0 {
813 self.finalize();
814 }
815 removed
816 }
817}
818
819fn is_safe_bm25_root(root: &Path) -> bool {
820 super::graph_index::is_safe_scan_root_public(&root.to_string_lossy())
821}
822
823fn bm25_index_looks_stale(index: &BM25Index, root: &Path) -> bool {
824 bm25_index_looks_stale_inner(index, root, false)
825}
826
827pub fn bm25_index_looks_stale_fast(index: &BM25Index, root: &Path) -> bool {
830 bm25_index_looks_stale_inner(index, root, true)
831}
832
833fn bm25_index_looks_stale_inner(index: &BM25Index, root: &Path, fast: bool) -> bool {
834 if index.chunks.is_empty() {
835 return false;
836 }
837
838 if index.files.is_empty() {
839 let mut seen = std::collections::HashSet::<&str>::new();
840 for chunk in &index.chunks {
841 let rel = chunk.file_path.trim_start_matches(['/', '\\']);
842 if rel.is_empty() {
843 continue;
844 }
845 if !seen.insert(rel) {
846 continue;
847 }
848 if !root.join(rel).exists() {
849 return true;
850 }
851 }
852 return false;
853 }
854
855 if fast {
856 let sample_size = index.files.len().min(SENTINEL_SAMPLE_SIZE);
857 let step = if index.files.len() > sample_size {
858 index.files.len() / sample_size
859 } else {
860 1
861 };
862 for (i, (rel, old_state)) in index.files.iter().enumerate() {
863 if i % step != 0 {
864 continue;
865 }
866 let abs = root.join(rel);
867 if !abs.exists() {
868 return true;
869 }
870 let Some(cur) = IndexedFileState::from_path(&abs) else {
871 return true;
872 };
873 if &cur != old_state {
874 return true;
875 }
876 }
877 return false;
878 }
879
880 for (rel, old_state) in &index.files {
881 let abs = root.join(rel);
882 if !abs.exists() {
883 return true;
884 }
885 let Some(cur) = IndexedFileState::from_path(&abs) else {
886 return true;
887 };
888 if &cur != old_state {
889 return true;
890 }
891 }
892
893 for rel in list_code_files(root) {
894 if !index.files.contains_key(&rel) {
895 return true;
896 }
897 }
898
899 false
900}
901
902const SENTINEL_SAMPLE_SIZE: usize = 10;
903
904fn bounded_zstd_decode(compressed: &[u8], max_bytes: u64) -> Option<Vec<u8>> {
905 use std::io::Read;
906 let mut decoder = zstd::Decoder::new(compressed).ok()?;
907 let mut buf = Vec::new();
908 let mut chunk = vec![0u8; 65536];
909 let mut total = 0u64;
910 loop {
911 let n = decoder.read(&mut chunk).ok()?;
912 if n == 0 {
913 break;
914 }
915 total += n as u64;
916 if total > max_bytes {
917 tracing::warn!(
918 "[bm25] decompressed index exceeds limit ({:.0} MB > {:.0} MB), aborting load",
919 total as f64 / (1024.0 * 1024.0),
920 max_bytes as f64 / (1024.0 * 1024.0)
921 );
922 return None;
923 }
924 buf.extend_from_slice(&chunk[..n]);
925 }
926 Some(buf)
927}
928
929fn index_dir(root: &Path) -> PathBuf {
930 crate::core::index_namespace::vectors_dir(root)
931}
932
933fn list_code_files(root: &Path) -> Vec<String> {
934 let cfg = crate::core::config::Config::load();
935 let filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
939
940 let walker = ignore::WalkBuilder::new(root)
941 .hidden(true)
942 .git_ignore(filter.respect_gitignore)
943 .git_global(filter.respect_gitignore)
944 .git_exclude(filter.respect_gitignore)
945 .require_git(false)
946 .max_depth(Some(20))
947 .filter_entry(crate::core::walk_filter::keep_entry)
948 .build();
949
950 let mut ignore_patterns: Vec<glob::Pattern> = DEFAULT_BM25_IGNORES
951 .iter()
952 .filter_map(|p| glob::Pattern::new(p).ok())
953 .collect();
954 ignore_patterns.extend(
955 cfg.extra_ignore_patterns
956 .iter()
957 .filter_map(|p| glob::Pattern::new(p).ok()),
958 );
959
960 let mut files: Vec<String> = Vec::new();
961 let mut filtered_out = 0usize;
962 for entry in walker.flatten() {
963 let path = entry.path();
964 if !path.is_file() {
965 continue;
966 }
967 if !crate::core::ingestion::is_ingestible(path) {
968 continue;
969 }
970 let rel = path
971 .strip_prefix(root)
972 .unwrap_or(path)
973 .to_string_lossy()
974 .to_string();
975 if rel.is_empty() {
976 continue;
977 }
978 if ignore_patterns.iter().any(|p| p.matches(&rel)) {
979 continue;
980 }
981 if filter.is_excluded(&rel.replace('\\', "/")) {
985 filtered_out += 1;
986 continue;
987 }
988 if files.len() >= MAX_BM25_FILES {
989 tracing::warn!(
990 "[bm25] file cap reached ({MAX_BM25_FILES}), skipping remaining files in {}",
991 root.display()
992 );
993 break;
994 }
995 files.push(rel);
996 }
997
998 if filtered_out > 0 {
999 tracing::info!(
1000 "[bm25] index filter excluded {filtered_out} files ({})",
1001 filter.summary().unwrap_or_default()
1002 );
1003 }
1004
1005 files.sort();
1006 files.dedup();
1007 files
1008}
1009
1010pub fn is_code_file(path: &Path) -> bool {
1011 let ext = path
1012 .extension()
1013 .and_then(|e| e.to_str())
1014 .unwrap_or("")
1015 .to_lowercase();
1016 matches!(
1017 ext.as_str(),
1018 "rs" | "ts"
1019 | "tsx"
1020 | "js"
1021 | "jsx"
1022 | "py"
1023 | "go"
1024 | "java"
1025 | "c"
1026 | "cc"
1027 | "cpp"
1028 | "h"
1029 | "hpp"
1030 | "rb"
1031 | "cs"
1032 | "kt"
1033 | "swift"
1034 | "php"
1035 | "scala"
1036 | "sql"
1037 | "ex"
1038 | "exs"
1039 | "zig"
1040 | "lua"
1041 | "dart"
1042 | "vue"
1043 | "svelte"
1044 )
1045}