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 = postcard::to_allocvec(self).map_err(|e| std::io::Error::other(e.to_string()))?;
566
567 let compressed = zstd::encode_all(data.as_slice(), ZSTD_LEVEL)
568 .map_err(|e| std::io::Error::other(format!("zstd compress: {e}")))?;
569 let compressed_bytes = compressed.len() as u64;
570
571 let max_bytes = max_bm25_cache_bytes();
572 if compressed_bytes > max_bytes {
573 tracing::warn!(
579 "[bm25] compressed index too large ({:.1} MB, limit {:.0} MB), refusing to persist: {}",
580 compressed_bytes as f64 / 1_048_576.0,
581 max_bytes / (1024 * 1024),
582 dir.display()
583 );
584 return Ok(SaveOutcome::SkippedTooLarge {
585 compressed_bytes,
586 limit_bytes: max_bytes,
587 });
588 }
589
590 tracing::info!(
591 "[bm25] index: {:.1} MB postcard → {:.1} MB zstd ({:.0}% saved)",
592 data.len() as f64 / 1_048_576.0,
593 compressed_bytes as f64 / 1_048_576.0,
594 (1.0 - compressed_bytes as f64 / data.len().max(1) as f64) * 100.0
595 );
596
597 let target = dir.join("bm25_index.bin.zst");
598 let tmp = dir.join("bm25_index.bin.zst.tmp");
599 std::fs::write(&tmp, &compressed)?;
600 std::fs::rename(&tmp, &target)?;
601
602 let _ = std::fs::remove_file(dir.join("bm25_index.bin"));
603 let _ = std::fs::remove_file(dir.join("bm25_index.json"));
604
605 let _ = std::fs::write(
606 dir.join("project_root.txt"),
607 root.to_string_lossy().as_bytes(),
608 );
609
610 Ok(SaveOutcome::Persisted { compressed_bytes })
611 }
612
613 pub fn load(root: &Path) -> Option<Self> {
614 let dir = index_dir(root);
615 let max_bytes = max_bm25_cache_bytes();
616
617 let zst_path = dir.join("bm25_index.bin.zst");
618 if zst_path.exists() {
619 let meta = std::fs::metadata(&zst_path).ok()?;
620 if meta.len() > max_bytes {
621 tracing::warn!(
622 "[bm25] compressed index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
623 meta.len() as f64 / 1_073_741_824.0,
624 max_bytes / (1024 * 1024),
625 zst_path.display()
626 );
627 let quarantined = zst_path.with_extension("zst.quarantined");
628 let _ = std::fs::rename(&zst_path, &quarantined);
629 return None;
630 }
631 let compressed = std::fs::read(&zst_path).ok()?;
632 let max_decompressed = max_bytes * 20; let data = bounded_zstd_decode(&compressed, max_decompressed)?;
634 let idx: Self = postcard::from_bytes(&data).ok()?;
635 return Some(idx);
636 }
637
638 let bin_path = dir.join("bm25_index.bin");
639 if bin_path.exists() {
640 let meta = std::fs::metadata(&bin_path).ok()?;
641 if meta.len() > max_bytes {
642 tracing::warn!(
643 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
644 meta.len() as f64 / 1_073_741_824.0,
645 max_bytes / (1024 * 1024),
646 bin_path.display()
647 );
648 let quarantined = bin_path.with_extension("bin.quarantined");
649 let _ = std::fs::rename(&bin_path, &quarantined);
650 return None;
651 }
652 let data = std::fs::read(&bin_path).ok()?;
653 let idx: Self = postcard::from_bytes(&data).ok()?;
654 if let Ok(compressed) = zstd::encode_all(data.as_slice(), ZSTD_LEVEL) {
656 let zst_tmp = zst_path.with_extension("zst.tmp");
657 if std::fs::write(&zst_tmp, &compressed).is_ok()
658 && std::fs::rename(&zst_tmp, &zst_path).is_ok()
659 {
660 tracing::info!(
661 "[bm25] migrated {:.1} MB → {:.1} MB zstd",
662 data.len() as f64 / 1_048_576.0,
663 compressed.len() as f64 / 1_048_576.0
664 );
665 let _ = std::fs::remove_file(&bin_path);
666 }
667 }
668 return Some(idx);
669 }
670
671 let json_path = dir.join("bm25_index.json");
672 if json_path.exists() {
673 let meta = std::fs::metadata(&json_path).ok()?;
674 if meta.len() > max_bytes {
675 tracing::warn!(
676 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
677 meta.len() as f64 / 1_073_741_824.0,
678 max_bytes / (1024 * 1024),
679 json_path.display()
680 );
681 let quarantined = json_path.with_extension("json.quarantined");
682 let _ = std::fs::rename(&json_path, &quarantined);
683 return None;
684 }
685 let data = std::fs::read_to_string(&json_path).ok()?;
686 return serde_json::from_str(&data).ok();
687 }
688
689 None
690 }
691
692 pub fn load_or_build(root: &Path) -> Self {
693 Self::load_or_build_inner(root, false)
694 }
695
696 pub fn load_or_build_fast(root: &Path) -> Self {
699 Self::load_or_build_inner(root, true)
700 }
701
702 fn load_or_build_inner(root: &Path, fast_stale: bool) -> Self {
703 if !is_safe_bm25_root(root) {
704 return Self::default();
705 }
706 if let Some(idx) = Self::load(root) {
707 let stale = if fast_stale {
708 bm25_index_looks_stale_fast(&idx, root)
709 } else {
710 bm25_index_looks_stale(&idx, root)
711 };
712 if !stale {
713 return idx;
714 }
715 tracing::debug!(
716 "[bm25_index: stale index detected for {}; rebuilding]",
717 root.display()
718 );
719 let rebuilt = if idx.files.is_empty() {
720 Self::build_from_directory(root)
721 } else {
722 Self::rebuild_incremental(root, &idx)
723 };
724 let _ = rebuilt.save(root);
725 return rebuilt;
726 }
727
728 let built = Self::build_from_directory(root);
729 let _ = built.save(root);
730 built
731 }
732
733 pub fn index_file_path(root: &Path) -> PathBuf {
734 let dir = index_dir(root);
735 let zst = dir.join("bm25_index.bin.zst");
736 if zst.exists() {
737 return zst;
738 }
739 let bin = dir.join("bm25_index.bin");
740 if bin.exists() {
741 return bin;
742 }
743 dir.join("bm25_index.json")
744 }
745
746 pub fn ingest_content_chunks(
750 &mut self,
751 chunks: impl IntoIterator<Item = super::content_chunk::ContentChunk>,
752 ) -> usize {
753 let mut count = 0usize;
754 for cc in chunks {
755 self.add_chunk(cc.into());
756 count += 1;
757 }
758 if count > 0 {
759 self.finalize();
760 }
761 count
762 }
763
764 pub fn external_chunk_count(&self) -> usize {
766 self.chunks
767 .iter()
768 .filter(|c| c.file_path.contains("://"))
769 .count()
770 }
771}
772
773fn is_safe_bm25_root(root: &Path) -> bool {
774 super::graph_index::is_safe_scan_root_public(&root.to_string_lossy())
775}
776
777fn bm25_index_looks_stale(index: &BM25Index, root: &Path) -> bool {
778 bm25_index_looks_stale_inner(index, root, false)
779}
780
781pub fn bm25_index_looks_stale_fast(index: &BM25Index, root: &Path) -> bool {
784 bm25_index_looks_stale_inner(index, root, true)
785}
786
787fn bm25_index_looks_stale_inner(index: &BM25Index, root: &Path, fast: bool) -> bool {
788 if index.chunks.is_empty() {
789 return false;
790 }
791
792 if index.files.is_empty() {
793 let mut seen = std::collections::HashSet::<&str>::new();
794 for chunk in &index.chunks {
795 let rel = chunk.file_path.trim_start_matches(['/', '\\']);
796 if rel.is_empty() {
797 continue;
798 }
799 if !seen.insert(rel) {
800 continue;
801 }
802 if !root.join(rel).exists() {
803 return true;
804 }
805 }
806 return false;
807 }
808
809 if fast {
810 let sample_size = index.files.len().min(SENTINEL_SAMPLE_SIZE);
811 let step = if index.files.len() > sample_size {
812 index.files.len() / sample_size
813 } else {
814 1
815 };
816 for (i, (rel, old_state)) in index.files.iter().enumerate() {
817 if i % step != 0 {
818 continue;
819 }
820 let abs = root.join(rel);
821 if !abs.exists() {
822 return true;
823 }
824 let Some(cur) = IndexedFileState::from_path(&abs) else {
825 return true;
826 };
827 if &cur != old_state {
828 return true;
829 }
830 }
831 return false;
832 }
833
834 for (rel, old_state) in &index.files {
835 let abs = root.join(rel);
836 if !abs.exists() {
837 return true;
838 }
839 let Some(cur) = IndexedFileState::from_path(&abs) else {
840 return true;
841 };
842 if &cur != old_state {
843 return true;
844 }
845 }
846
847 for rel in list_code_files(root) {
848 if !index.files.contains_key(&rel) {
849 return true;
850 }
851 }
852
853 false
854}
855
856const SENTINEL_SAMPLE_SIZE: usize = 10;
857
858fn bounded_zstd_decode(compressed: &[u8], max_bytes: u64) -> Option<Vec<u8>> {
859 use std::io::Read;
860 let mut decoder = zstd::Decoder::new(compressed).ok()?;
861 let mut buf = Vec::new();
862 let mut chunk = vec![0u8; 65536];
863 let mut total = 0u64;
864 loop {
865 let n = decoder.read(&mut chunk).ok()?;
866 if n == 0 {
867 break;
868 }
869 total += n as u64;
870 if total > max_bytes {
871 tracing::warn!(
872 "[bm25] decompressed index exceeds limit ({:.0} MB > {:.0} MB), aborting load",
873 total as f64 / (1024.0 * 1024.0),
874 max_bytes as f64 / (1024.0 * 1024.0)
875 );
876 return None;
877 }
878 buf.extend_from_slice(&chunk[..n]);
879 }
880 Some(buf)
881}
882
883fn index_dir(root: &Path) -> PathBuf {
884 crate::core::index_namespace::vectors_dir(root)
885}
886
887fn list_code_files(root: &Path) -> Vec<String> {
888 let walker = ignore::WalkBuilder::new(root)
889 .hidden(true)
890 .git_ignore(true)
891 .git_global(true)
892 .git_exclude(true)
893 .require_git(false)
894 .max_depth(Some(20))
895 .filter_entry(crate::core::walk_filter::keep_entry)
896 .build();
897
898 let cfg = crate::core::config::Config::load();
899 let mut ignore_patterns: Vec<glob::Pattern> = DEFAULT_BM25_IGNORES
900 .iter()
901 .filter_map(|p| glob::Pattern::new(p).ok())
902 .collect();
903 ignore_patterns.extend(
904 cfg.extra_ignore_patterns
905 .iter()
906 .filter_map(|p| glob::Pattern::new(p).ok()),
907 );
908
909 let mut files: Vec<String> = Vec::new();
910 for entry in walker.flatten() {
911 let path = entry.path();
912 if !path.is_file() {
913 continue;
914 }
915 if !crate::core::ingestion::is_ingestible(path) {
916 continue;
917 }
918 let rel = path
919 .strip_prefix(root)
920 .unwrap_or(path)
921 .to_string_lossy()
922 .to_string();
923 if rel.is_empty() {
924 continue;
925 }
926 if ignore_patterns.iter().any(|p| p.matches(&rel)) {
927 continue;
928 }
929 if files.len() >= MAX_BM25_FILES {
930 tracing::warn!(
931 "[bm25] file cap reached ({MAX_BM25_FILES}), skipping remaining files in {}",
932 root.display()
933 );
934 break;
935 }
936 files.push(rel);
937 }
938
939 files.sort();
940 files.dedup();
941 files
942}
943
944pub fn is_code_file(path: &Path) -> bool {
945 let ext = path
946 .extension()
947 .and_then(|e| e.to_str())
948 .unwrap_or("")
949 .to_lowercase();
950 matches!(
951 ext.as_str(),
952 "rs" | "ts"
953 | "tsx"
954 | "js"
955 | "jsx"
956 | "py"
957 | "go"
958 | "java"
959 | "c"
960 | "cc"
961 | "cpp"
962 | "h"
963 | "hpp"
964 | "rb"
965 | "cs"
966 | "kt"
967 | "swift"
968 | "php"
969 | "scala"
970 | "sql"
971 | "ex"
972 | "exs"
973 | "zig"
974 | "lua"
975 | "dart"
976 | "vue"
977 | "svelte"
978 )
979}