Skip to main content

lean_ctx/core/bm25_index/
mod.rs

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    // Single source of truth: `Config::bm25_max_cache_mb_effective` (env override
36    // › explicit config › disk-budget › generous default). Decoupled from the RAM
37    // profile so large repos persist instead of rebuilding forever (issue #249).
38    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
45/// Effective on-disk ceiling (bytes) for the persisted BM25 index. Single source
46/// of truth shared with `doctor` so its "oversized index" warning matches what
47/// `save`/`load` actually enforce.
48pub fn persist_ceiling_bytes() -> u64 {
49    max_bm25_cache_bytes()
50}
51
52/// Outcome of persisting a BM25 index to disk. Distinguishes a real write from a
53/// size-capped refusal so callers never mistake "refused to persist" for
54/// success (the bug behind the perpetual "index warming" report, issue #249).
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SaveOutcome {
57    /// Written to disk. Carries the compressed (zstd) size in bytes.
58    Persisted { compressed_bytes: u64 },
59    /// Built fine but NOT written — the compressed size exceeds the disk
60    /// ceiling. The in-memory index is still usable for this process; callers
61    /// should surface the remedy (raise the cap / add ignore patterns) instead
62    /// of silently rebuilding on every call.
63    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    // -- External source kinds (Context Engine) --
92    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    /// True once `shrink_resident_content_to_snippet` has trimmed each chunk's
133    /// `content` down to the snippet lines. Resident-only RAM-saving state: never
134    /// persisted (`skip`) so the on-disk index keeps full content, and a reload
135    /// always starts as `false`. Guards the embedding pass against re-embedding
136    /// truncated bodies (see `ensure_embeddings`).
137    #[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    /// Approximate heap memory used by this index in bytes.
176    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    /// Drops all in-memory data, effectively freeing heap. Index can be re-loaded from disk.
199    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    /// Shrinks each resident chunk's `content` to its first `keep_lines` lines,
214    /// reclaiming the RAM held by the full source bodies once the embedding pass
215    /// has already consumed them. The search path only ever reads
216    /// `content.lines().take(5)` for snippets, so the trimmed copy is functionally
217    /// complete for BM25/dense/hybrid result rendering.
218    ///
219    /// RESIDENT-ONLY: this mutates the in-memory copy. The persisted `.bin.zst`
220    /// keeps full content (truncation never runs before `save`), so a reload
221    /// restores complete bodies. Sets `content_truncated` so a later
222    /// `ensure_embeddings` against this same resident index skips re-embedding
223    /// (which would otherwise feed truncated bodies to the embedder).
224    ///
225    /// Idempotent and only ever shrinks: chunks shorter than `keep_lines` are
226    /// left untouched.
227    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            // Cheap line-count gate: only allocate a new string when the body is
231            // actually longer than the snippet window.
232            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                // Reclaim the spare capacity left by the larger original body.
241                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    /// Builds an index from explicit chunks (unit tests; avoids filesystem walking).
253    #[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    /// Like `build_from_directory` but reuses file content from a prior scan
271    /// (e.g. the graph index walk) to avoid redundant disk reads.
272    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            // Content sources, cheapest first: an explicit per-build hint, then
309            // the shared resident content cache (populated by the search-index
310            // build / ctx_search, issue #148) validated by `(mtime, size)`, then
311            // a one-time disk read that also publishes into the shared cache.
312            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                // Binary document (PDF, …): extract clean text from raw bytes.
318                // Skipped if extraction yields nothing (e.g. scanned/image-only).
319                // Never populates the shared UTF-8 content cache (not text).
320                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        // Pre-allocated score array: O(1) per-access vs HashMap overhead.
493        // Kolmogorov-optimal: minimal allocation for the scoring operation.
494        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            // Do NOT pretend success: a silent `Ok(())` here made `load` return
574            // `None` forever and the index rebuild on every call (issue #249).
575            // Report the refusal so the orchestrator can record an actionable
576            // note and the agent-facing tools can stop claiming the index will
577            // be "ready next call".
578            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; // allow 20x expansion ratio
633            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            // Auto-migrate: compress legacy .bin to .bin.zst
655            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    /// Like `load_or_build` but uses a fast sentinel-sampling staleness check
697    /// that skips the expensive full directory walk for new-file detection.
698    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    /// Ingest external `ContentChunk`s into the BM25 index.
747    /// Converts each chunk to a `CodeChunk` (backward-compatible) and
748    /// rebuilds the inverted index. Returns the number of chunks ingested.
749    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    /// Number of chunks originating from external providers.
765    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
781/// Fast staleness check: samples a subset of tracked files and skips the
782/// expensive `list_code_files()` walk for new-file detection.
783pub 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}