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