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 let root_key = root.to_string_lossy().to_string();
409 let total = files.len() as u64;
410 crate::core::index_progress::report_bm25(&root_key, 0, total);
411
412 for (i, rel) in files.iter().enumerate() {
413 if i.is_multiple_of(16) || i + 1 == files.len() {
414 crate::core::index_progress::report_bm25(&root_key, (i + 1) as u64, total);
415 }
416 if i.is_multiple_of(500) && crate::core::memory_guard::is_under_pressure() {
417 tracing::warn!(
418 "[bm25: stopping incremental rebuild at file {i}/{} due to memory pressure]",
419 files.len()
420 );
421 break;
422 }
423
424 let abs = root.join(rel);
425 let Some(state) = IndexedFileState::from_path(&abs) else {
426 continue;
427 };
428
429 let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
430 if unchanged
431 && let Some(chunks) = old_by_file.get(rel)
432 && chunks.first().is_some_and(|c| !c.content.is_empty())
433 {
434 for chunk in chunks {
435 index.add_chunk(chunk.clone());
436 }
437 index.files.insert(rel.clone(), state);
438 continue;
439 }
440
441 if state.size_bytes > MAX_FILE_SIZE_BYTES {
442 continue;
443 }
444 let content = if crate::core::extractors::is_binary_document(&abs) {
445 match std::fs::read(&abs) {
446 Ok(bytes) => crate::core::extractors::extract(&abs, &bytes).text,
447 Err(_) => continue,
448 }
449 } else {
450 match std::fs::read_to_string(&abs) {
451 Ok(c) => c,
452 Err(_) => continue,
453 }
454 };
455 if content.is_empty() {
456 continue;
457 }
458 let mut chunks = extract_chunks(rel, &content);
459 chunks.sort_by(|a, b| {
460 a.start_line
461 .cmp(&b.start_line)
462 .then_with(|| a.end_line.cmp(&b.end_line))
463 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
464 });
465 for chunk in chunks {
466 index.add_chunk(chunk);
467 }
468 index.files.insert(rel.clone(), state);
469 }
470
471 index.finalize();
472 index
473 }
474
475 fn add_chunk(&mut self, mut chunk: CodeChunk) {
476 let idx = self.chunks.len();
477
478 let enriched = enrich_for_bm25(&chunk);
479 let tokens = tokenize(&enriched);
480 for token in &tokens {
481 let lower = token.to_lowercase();
482 let postings = self.inverted.entry(lower.clone()).or_default();
483 if postings.last().map(|(last_idx, _)| *last_idx) != Some(idx) {
484 *self.doc_freqs.entry(lower).or_insert(0) += 1;
485 }
486 postings.push((idx, 1.0));
487 }
488
489 const SNIPPET_LINES: usize = 10;
492 if chunk.content.lines().nth(SNIPPET_LINES).is_some() {
493 chunk.content = chunk
494 .content
495 .lines()
496 .take(SNIPPET_LINES)
497 .collect::<Vec<_>>()
498 .join("\n");
499 chunk.content.shrink_to_fit();
500 }
501
502 self.chunks.push(CodeChunk {
503 token_count: tokens.len(),
504 tokens: Vec::new(),
505 ..chunk
506 });
507 }
508
509 fn finalize(&mut self) {
510 self.doc_count = self.chunks.len();
511 if self.doc_count == 0 {
512 return;
513 }
514
515 let total_len: usize = self.chunks.iter().map(|c| c.token_count).sum();
516 self.avg_doc_len = total_len as f64 / self.doc_count as f64;
517 }
518
519 pub fn search(&self, query: &str, top_k: usize) -> Vec<SearchResult> {
520 let query_tokens = tokenize(query);
521 if query_tokens.is_empty() || self.doc_count == 0 {
522 return Vec::new();
523 }
524
525 let n = self.chunks.len();
528 let mut scores = vec![0.0f64; n];
529 let mut touched = Vec::with_capacity(n.min(256));
530
531 for token in &query_tokens {
532 let lower = token.to_lowercase();
533 let df = *self.doc_freqs.get(&lower).unwrap_or(&0) as f64;
534 if df == 0.0 {
535 continue;
536 }
537
538 let idf = ((self.doc_count as f64 - df + 0.5) / (df + 0.5) + 1.0).ln();
539
540 if let Some(postings) = self.inverted.get(&lower) {
541 for &(idx, weight) in postings {
542 let doc_len = self.chunks[idx].token_count as f64;
543 let norm_len = doc_len / self.avg_doc_len.max(1.0);
544 let bm25 = idf * (weight * (BM25_K1 + 1.0))
545 / (weight + BM25_K1 * (1.0 - BM25_B + BM25_B * norm_len));
546
547 if scores[idx] == 0.0 {
548 touched.push(idx);
549 }
550 scores[idx] += bm25;
551 }
552 }
553 }
554
555 for &idx in &touched {
556 scores[idx] += structural_query_boost(query, &self.chunks[idx]);
557 }
558
559 let mut results: Vec<SearchResult> = touched
560 .iter()
561 .filter(|&&idx| scores[idx] > 0.0)
562 .map(|&idx| {
563 let chunk = &self.chunks[idx];
564 let snippet = chunk.content.lines().take(5).collect::<Vec<_>>().join("\n");
565 SearchResult {
566 chunk_idx: idx,
567 score: scores[idx],
568 file_path: chunk.file_path.clone(),
569 symbol_name: chunk.symbol_name.clone(),
570 kind: chunk.kind.clone(),
571 start_line: chunk.start_line,
572 end_line: chunk.end_line,
573 snippet,
574 }
575 })
576 .collect();
577
578 results.sort_by(|a, b| {
579 b.score
580 .partial_cmp(&a.score)
581 .unwrap_or(std::cmp::Ordering::Equal)
582 .then_with(|| a.file_path.cmp(&b.file_path))
583 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
584 .then_with(|| a.start_line.cmp(&b.start_line))
585 .then_with(|| a.end_line.cmp(&b.end_line))
586 });
587 results.truncate(top_k);
588 results
589 }
590
591 pub fn save(&self, root: &Path) -> std::io::Result<SaveOutcome> {
592 if self.chunks.len() > CHUNK_COUNT_WARNING {
593 tracing::warn!(
594 "[bm25] index has {} chunks (threshold {}), consider adding extra_ignore_patterns",
595 self.chunks.len(),
596 CHUNK_COUNT_WARNING
597 );
598 }
599
600 let dir = index_dir(root);
601 std::fs::create_dir_all(&dir)?;
602
603 let target = dir.join("bm25_index.bin.zst");
606 let tmp = dir.join("bm25_index.bin.zst.tmp");
607 {
608 let file = std::fs::File::create(&tmp)?;
609 let buf_writer = std::io::BufWriter::new(file);
610 let mut encoder = zstd::Encoder::new(buf_writer, ZSTD_LEVEL)
611 .map_err(|e| std::io::Error::other(format!("zstd encoder init: {e}")))?;
612 postcard::to_io(self, &mut encoder)
613 .map_err(|e| std::io::Error::other(e.to_string()))?;
614 encoder.finish()?;
615 }
616
617 let compressed_bytes = std::fs::metadata(&tmp)?.len();
618 let max_bytes = max_bm25_cache_bytes();
619 if compressed_bytes > max_bytes {
620 let _ = std::fs::remove_file(&tmp);
621 tracing::warn!(
622 "[bm25] compressed index too large ({:.1} MB, limit {:.0} MB), refusing to persist: {}",
623 compressed_bytes as f64 / 1_048_576.0,
624 max_bytes / (1024 * 1024),
625 dir.display()
626 );
627 return Ok(SaveOutcome::SkippedTooLarge {
628 compressed_bytes,
629 limit_bytes: max_bytes,
630 });
631 }
632
633 tracing::info!(
634 "[bm25] index: {:.1} MB zstd compressed",
635 compressed_bytes as f64 / 1_048_576.0,
636 );
637
638 std::fs::rename(&tmp, &target)?;
639
640 let _ = std::fs::remove_file(dir.join("bm25_index.bin"));
641 let _ = std::fs::remove_file(dir.join("bm25_index.json"));
642
643 let _ = std::fs::write(
644 dir.join("project_root.txt"),
645 root.to_string_lossy().as_bytes(),
646 );
647
648 Ok(SaveOutcome::Persisted { compressed_bytes })
649 }
650
651 pub fn load(root: &Path) -> Option<Self> {
652 let dir = index_dir(root);
653 let max_bytes = max_bm25_cache_bytes();
654
655 let zst_path = dir.join("bm25_index.bin.zst");
656 if zst_path.exists() {
657 let meta = std::fs::metadata(&zst_path).ok()?;
658 if meta.len() > max_bytes {
659 tracing::warn!(
660 "[bm25] compressed index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
661 meta.len() as f64 / 1_073_741_824.0,
662 max_bytes / (1024 * 1024),
663 zst_path.display()
664 );
665 let quarantined = zst_path.with_extension("zst.quarantined");
666 let _ = std::fs::rename(&zst_path, &quarantined);
667 return None;
668 }
669 let compressed = std::fs::read(&zst_path).ok()?;
670 let max_decompressed = max_bytes * 20; let data = bounded_zstd_decode(&compressed, max_decompressed)?;
672 let idx: Self = postcard::from_bytes(&data).ok()?;
673 return Some(idx);
674 }
675
676 let bin_path = dir.join("bm25_index.bin");
677 if bin_path.exists() {
678 let meta = std::fs::metadata(&bin_path).ok()?;
679 if meta.len() > max_bytes {
680 tracing::warn!(
681 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
682 meta.len() as f64 / 1_073_741_824.0,
683 max_bytes / (1024 * 1024),
684 bin_path.display()
685 );
686 let quarantined = bin_path.with_extension("bin.quarantined");
687 let _ = std::fs::rename(&bin_path, &quarantined);
688 return None;
689 }
690 let data = std::fs::read(&bin_path).ok()?;
691 let idx: Self = postcard::from_bytes(&data).ok()?;
692 if let Ok(compressed) = zstd::encode_all(data.as_slice(), ZSTD_LEVEL) {
694 let zst_tmp = zst_path.with_extension("zst.tmp");
695 if std::fs::write(&zst_tmp, &compressed).is_ok()
696 && std::fs::rename(&zst_tmp, &zst_path).is_ok()
697 {
698 tracing::info!(
699 "[bm25] migrated {:.1} MB → {:.1} MB zstd",
700 data.len() as f64 / 1_048_576.0,
701 compressed.len() as f64 / 1_048_576.0
702 );
703 let _ = std::fs::remove_file(&bin_path);
704 }
705 }
706 return Some(idx);
707 }
708
709 let json_path = dir.join("bm25_index.json");
710 if json_path.exists() {
711 let meta = std::fs::metadata(&json_path).ok()?;
712 if meta.len() > max_bytes {
713 tracing::warn!(
714 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
715 meta.len() as f64 / 1_073_741_824.0,
716 max_bytes / (1024 * 1024),
717 json_path.display()
718 );
719 let quarantined = json_path.with_extension("json.quarantined");
720 let _ = std::fs::rename(&json_path, &quarantined);
721 return None;
722 }
723 let data = std::fs::read_to_string(&json_path).ok()?;
724 return serde_json::from_str(&data).ok();
725 }
726
727 None
728 }
729
730 pub fn load_or_build(root: &Path) -> Self {
731 Self::load_or_build_inner(root, false)
732 }
733
734 pub fn load_or_build_fast(root: &Path) -> Self {
737 Self::load_or_build_inner(root, true)
738 }
739
740 fn load_or_build_inner(root: &Path, fast_stale: bool) -> Self {
741 if !is_safe_bm25_root(root) {
742 return Self::default();
743 }
744 if let Some(idx) = Self::load(root) {
745 let stale = if fast_stale {
746 bm25_index_looks_stale_fast(&idx, root)
747 } else {
748 bm25_index_looks_stale(&idx, root)
749 };
750 if !stale {
751 return idx;
752 }
753 tracing::debug!(
754 "[bm25_index: stale index detected for {}; rebuilding]",
755 root.display()
756 );
757 let rebuilt = if idx.files.is_empty() {
758 Self::build_from_directory(root)
759 } else {
760 Self::rebuild_incremental(root, &idx)
761 };
762 let _ = rebuilt.save(root);
763 return rebuilt;
764 }
765
766 let built = Self::build_from_directory(root);
767 let _ = built.save(root);
768 built
769 }
770
771 pub fn index_file_path(root: &Path) -> PathBuf {
772 let dir = index_dir(root);
773 let zst = dir.join("bm25_index.bin.zst");
774 if zst.exists() {
775 return zst;
776 }
777 let bin = dir.join("bm25_index.bin");
778 if bin.exists() {
779 return bin;
780 }
781 dir.join("bm25_index.json")
782 }
783
784 pub fn ingest_content_chunks(
788 &mut self,
789 chunks: impl IntoIterator<Item = super::content_chunk::ContentChunk>,
790 ) -> usize {
791 let mut count = 0usize;
792 for cc in chunks {
793 self.add_chunk(cc.into());
794 count += 1;
795 }
796 if count > 0 {
797 self.finalize();
798 }
799 count
800 }
801
802 pub fn external_chunk_count(&self) -> usize {
804 self.chunks
805 .iter()
806 .filter(|c| c.file_path.contains("://"))
807 .count()
808 }
809
810 pub fn remove_chunks_with_prefix(&mut self, prefix: &str) -> usize {
815 let before = self.chunks.len();
816 self.chunks.retain(|c| !c.file_path.starts_with(prefix));
817 let removed = before - self.chunks.len();
818 if removed > 0 {
819 self.finalize();
820 }
821 removed
822 }
823}
824
825fn is_safe_bm25_root(root: &Path) -> bool {
826 super::graph_index::is_safe_scan_root_public(&root.to_string_lossy())
827}
828
829fn bm25_index_looks_stale(index: &BM25Index, root: &Path) -> bool {
830 bm25_index_looks_stale_inner(index, root, false)
831}
832
833pub fn bm25_index_looks_stale_fast(index: &BM25Index, root: &Path) -> bool {
836 bm25_index_looks_stale_inner(index, root, true)
837}
838
839fn bm25_index_looks_stale_inner(index: &BM25Index, root: &Path, fast: bool) -> bool {
840 if index.chunks.is_empty() {
841 return false;
842 }
843
844 if index.files.is_empty() {
845 let mut seen = std::collections::HashSet::<&str>::new();
846 for chunk in &index.chunks {
847 let rel = chunk.file_path.trim_start_matches(['/', '\\']);
848 if rel.is_empty() {
849 continue;
850 }
851 if !seen.insert(rel) {
852 continue;
853 }
854 if !root.join(rel).exists() {
855 return true;
856 }
857 }
858 return false;
859 }
860
861 if fast {
862 let sample_size = index.files.len().min(SENTINEL_SAMPLE_SIZE);
863 let step = if index.files.len() > sample_size {
864 index.files.len() / sample_size
865 } else {
866 1
867 };
868 for (i, (rel, old_state)) in index.files.iter().enumerate() {
869 if i % step != 0 {
870 continue;
871 }
872 let abs = root.join(rel);
873 if !abs.exists() {
874 return true;
875 }
876 let Some(cur) = IndexedFileState::from_path(&abs) else {
877 return true;
878 };
879 if &cur != old_state {
880 return true;
881 }
882 }
883 return false;
884 }
885
886 for (rel, old_state) in &index.files {
887 let abs = root.join(rel);
888 if !abs.exists() {
889 return true;
890 }
891 let Some(cur) = IndexedFileState::from_path(&abs) else {
892 return true;
893 };
894 if &cur != old_state {
895 return true;
896 }
897 }
898
899 for rel in list_code_files(root) {
900 if !index.files.contains_key(&rel) {
901 return true;
902 }
903 }
904
905 false
906}
907
908const SENTINEL_SAMPLE_SIZE: usize = 10;
909
910fn bounded_zstd_decode(compressed: &[u8], max_bytes: u64) -> Option<Vec<u8>> {
911 use std::io::Read;
912 let mut decoder = zstd::Decoder::new(compressed).ok()?;
913 let mut buf = Vec::new();
914 let mut chunk = vec![0u8; 65536];
915 let mut total = 0u64;
916 loop {
917 let n = decoder.read(&mut chunk).ok()?;
918 if n == 0 {
919 break;
920 }
921 total += n as u64;
922 if total > max_bytes {
923 tracing::warn!(
924 "[bm25] decompressed index exceeds limit ({:.0} MB > {:.0} MB), aborting load",
925 total as f64 / (1024.0 * 1024.0),
926 max_bytes as f64 / (1024.0 * 1024.0)
927 );
928 return None;
929 }
930 buf.extend_from_slice(&chunk[..n]);
931 }
932 Some(buf)
933}
934
935fn index_dir(root: &Path) -> PathBuf {
936 crate::core::index_namespace::vectors_dir(root)
937}
938
939fn list_code_files(root: &Path) -> Vec<String> {
940 let cfg = crate::core::config::Config::load();
941 let filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
945
946 let walker = ignore::WalkBuilder::new(root)
947 .hidden(true)
948 .git_ignore(filter.respect_gitignore)
949 .git_global(filter.respect_gitignore)
950 .git_exclude(filter.respect_gitignore)
951 .require_git(false)
952 .max_depth(Some(20))
953 .filter_entry(crate::core::walk_filter::keep_entry)
954 .build();
955
956 let mut ignore_patterns: Vec<glob::Pattern> = DEFAULT_BM25_IGNORES
957 .iter()
958 .filter_map(|p| glob::Pattern::new(p).ok())
959 .collect();
960 ignore_patterns.extend(
961 cfg.extra_ignore_patterns
962 .iter()
963 .filter_map(|p| glob::Pattern::new(p).ok()),
964 );
965
966 let mut files: Vec<String> = Vec::new();
967 let mut filtered_out = 0usize;
968 for entry in walker.flatten() {
969 let path = entry.path();
970 if !path.is_file() {
971 continue;
972 }
973 if !crate::core::ingestion::is_ingestible(path) {
974 continue;
975 }
976 let rel = path
977 .strip_prefix(root)
978 .unwrap_or(path)
979 .to_string_lossy()
980 .to_string();
981 if rel.is_empty() {
982 continue;
983 }
984 if ignore_patterns.iter().any(|p| p.matches(&rel)) {
985 continue;
986 }
987 if filter.is_excluded(&rel.replace('\\', "/")) {
991 filtered_out += 1;
992 continue;
993 }
994 if files.len() >= MAX_BM25_FILES {
995 tracing::warn!(
996 "[bm25] file cap reached ({MAX_BM25_FILES}), skipping remaining files in {}",
997 root.display()
998 );
999 break;
1000 }
1001 files.push(rel);
1002 }
1003
1004 if filtered_out > 0 {
1005 tracing::info!(
1006 "[bm25] index filter excluded {filtered_out} files ({})",
1007 filter.summary().unwrap_or_default()
1008 );
1009 }
1010
1011 files.sort();
1012 files.dedup();
1013 files
1014}
1015
1016pub fn is_code_file(path: &Path) -> bool {
1017 let ext = path
1018 .extension()
1019 .and_then(|e| e.to_str())
1020 .unwrap_or("")
1021 .to_lowercase();
1022 matches!(
1023 ext.as_str(),
1024 "rs" | "ts"
1025 | "tsx"
1026 | "js"
1027 | "jsx"
1028 | "py"
1029 | "go"
1030 | "java"
1031 | "c"
1032 | "cc"
1033 | "cpp"
1034 | "h"
1035 | "hpp"
1036 | "rb"
1037 | "cs"
1038 | "kt"
1039 | "swift"
1040 | "php"
1041 | "scala"
1042 | "sql"
1043 | "ex"
1044 | "exs"
1045 | "zig"
1046 | "lua"
1047 | "dart"
1048 | "vue"
1049 | "svelte"
1050 )
1051}