Skip to main content

greplm_core/
indexer.rs

1//! Index construction: full builds, hash-gated incremental updates, compaction.
2
3use std::collections::{HashMap, HashSet};
4
5use rayon::prelude::*;
6
7use crate::cache::{fast_hash, stat_key, Cache, FileRecord};
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::io_backend::IoBackend;
11use crate::lang::Language;
12use crate::meta::{Meta, PendingTombstones};
13use crate::paths::Paths;
14use crate::segment::{
15    merge_postings, read_bitmap, write_bitmap, write_segment_files, DocMeta, RawRef, RawSymbol,
16    Segment, SegmentWriter,
17};
18use crate::trigram;
19use crate::walk::{self, SkipReason, Skipped, WalkEntry};
20
21/// How many skipped-file paths to retain for display. Counts in
22/// `skipped_by_reason` stay exact; this only bounds the per-path sample so a
23/// repo full of binaries/large files can't balloon the stats.
24const SKIP_SAMPLE_CAP: usize = 100;
25
26/// Highest segment id with files on disk, parsed from `seg-NNNNNN.*` names.
27///
28/// Used as a fallback when the manifest is unreadable so a rebuild keeps
29/// allocating fresh ids and never overwrites a segment that the still-live
30/// (pre-swap) index depends on.
31fn max_segment_id_on_disk(paths: &Paths) -> Option<u64> {
32    let entries = std::fs::read_dir(paths.segments_dir()).ok()?;
33    entries
34        .flatten()
35        .filter_map(|e| {
36            let name = e.file_name();
37            let name = name.to_str()?;
38            let rest = name.strip_prefix("seg-")?;
39            let digits = rest.split('.').next()?;
40            digits.parse::<u64>().ok()
41        })
42        .max()
43}
44
45/// Summary statistics returned by an index operation.
46#[derive(Debug, Default, Clone)]
47pub struct IndexStats {
48    pub files_indexed: usize,
49    /// Total files that `grep` would search but greplm left out of the index
50    /// (size/empty/binary/error). Excludes gitignore/hidden pruning, which is
51    /// the configured intent rather than a surprise.
52    pub files_skipped: usize,
53    pub files_removed: usize,
54    pub symbols: usize,
55    pub segments: usize,
56    /// Exact count of skips grouped by reason.
57    pub skipped_by_reason: std::collections::BTreeMap<SkipReason, usize>,
58    /// A bounded sample of skipped paths (up to `SKIP_SAMPLE_CAP`) for display.
59    pub skipped_sample: Vec<Skipped>,
60}
61
62impl IndexStats {
63    /// Fold a set of skip records into the stats: bump per-reason counts, keep a
64    /// bounded path sample, and set `files_skipped` to the exact total.
65    fn record_skips(&mut self, skips: impl IntoIterator<Item = Skipped>) {
66        for s in skips {
67            *self.skipped_by_reason.entry(s.reason).or_default() += 1;
68            self.files_skipped += 1;
69            if self.skipped_sample.len() < SKIP_SAMPLE_CAP {
70                self.skipped_sample.push(s);
71            }
72        }
73    }
74}
75
76/// A fully processed file ready to be added to a segment.
77struct Processed {
78    rel: String,
79    inode: u64,
80    mtime_ns: i64,
81    size: u64,
82    hash: u64,
83    doc: DocMeta,
84    /// Distinct trigram keys, sorted (see [`trigram::extract_keys`]).
85    trigram_keys: Vec<u32>,
86    symbols: Vec<RawSymbol>,
87    refs: Vec<RawRef>,
88    /// Set when this file's content is byte-identical to a doc being deleted
89    /// this run (a rename/move): `(segment_id, doc_id)` of the old doc, whose
90    /// symbols/refs are copied instead of re-running tree-sitter.
91    rename_from: Option<(u64, u32)>,
92}
93
94/// A rename-source doc: where the old copy lives plus its language, so the
95/// fast path only fires when the parse result would be identical.
96struct RenameSrc {
97    segment_id: u64,
98    doc_id: u32,
99    lang: String,
100}
101
102/// Rename sources keyed by `(content hash, size)` of the deleted file.
103type RenameSources = HashMap<(u64, u64), RenameSrc>;
104
105/// Detect binary content: any NUL byte anywhere in the file.
106fn is_binary(data: &[u8]) -> bool {
107    memchr::memchr(0, data).is_some()
108}
109
110/// Count lines without over-counting a trailing newline as an extra empty line.
111fn count_lines(data: &[u8]) -> u32 {
112    if data.is_empty() {
113        return 0;
114    }
115    let nl = memchr::memchr_iter(b'\n', data).count();
116    if data.last() == Some(&b'\n') {
117        nl as u32
118    } else {
119        (nl + 1) as u32
120    }
121}
122
123/// Outcome of attempting to process a walked file.
124enum Outcome {
125    /// File was read and prepared for indexing.
126    Indexed(Box<Processed>),
127    /// File was skipped (binary or unreadable); carries the path and reason.
128    Skipped(Skipped),
129}
130
131fn process(
132    entry: &WalkEntry,
133    backend: &dyn IoBackend,
134    config: &Config,
135    renames: &RenameSources,
136) -> Outcome {
137    let skip = |reason| {
138        Outcome::Skipped(Skipped {
139            rel: entry.rel.clone(),
140            reason,
141        })
142    };
143    let data = match backend.read(&entry.path) {
144        Ok(d) => d,
145        // A read failure (permissions, vanished mid-walk) is a skip, not a hard
146        // error — record it so it's visible rather than silently dropped.
147        Err(_) => return skip(SkipReason::ReadError),
148    };
149    if !config.index_binary && is_binary(&data) {
150        return skip(SkipReason::Binary);
151    }
152    let ext = entry
153        .path
154        .extension()
155        .and_then(|e| e.to_str())
156        .unwrap_or("")
157        .to_ascii_lowercase();
158    let lang = Language::from_extension(&ext);
159    let (inode, mtime_ns, size) = stat_key(&entry.metadata);
160    let hash = fast_hash(&data);
161    let trigram_keys = trigram::extract_keys(&data);
162    // Rename fast path: identical content to a doc deleted this run, in the
163    // same language, means an identical parse — skip tree-sitter and let the
164    // caller copy the old doc's symbols/refs.
165    let rename_from = renames
166        .get(&(hash, size))
167        .filter(|src| src.lang == lang.id())
168        .map(|src| (src.segment_id, src.doc_id));
169    let (symbols, refs) = if rename_from.is_none() && lang.grammar().is_some() {
170        crate::symbol::extract_all(lang, &data)
171    } else {
172        (Vec::new(), Vec::new())
173    };
174    let doc = DocMeta {
175        path: entry.rel.clone(),
176        lang: lang.id().to_string(),
177        size,
178        hash,
179        lines: count_lines(&data),
180    };
181    Outcome::Indexed(Box::new(Processed {
182        rel: entry.rel.clone(),
183        inode,
184        mtime_ns,
185        size,
186        hash,
187        doc,
188        trigram_keys,
189        symbols,
190        refs,
191        rename_from,
192    }))
193}
194
195/// Run the read/parse stage over `candidates` in parallel, partitioning into
196/// successfully processed files and skip records (binary/unreadable).
197fn process_all(
198    candidates: &[&WalkEntry],
199    backend: &dyn IoBackend,
200    config: &Config,
201    renames: &RenameSources,
202) -> (Vec<Processed>, Vec<Skipped>) {
203    let outcomes: Vec<Outcome> = candidates
204        .par_iter()
205        .map(|e| process(e, backend, config, renames))
206        .collect();
207    let mut processed = Vec::with_capacity(outcomes.len());
208    let mut skipped = Vec::new();
209    for outcome in outcomes {
210        match outcome {
211            Outcome::Indexed(p) => processed.push(*p),
212            Outcome::Skipped(s) => skipped.push(s),
213        }
214    }
215    (processed, skipped)
216}
217
218/// Index builder bound to a project.
219pub struct Indexer<'a> {
220    pub paths: &'a Paths,
221    pub config: &'a Config,
222    pub backend: &'a dyn IoBackend,
223}
224
225impl<'a> Indexer<'a> {
226    pub fn new(paths: &'a Paths, config: &'a Config, backend: &'a dyn IoBackend) -> Self {
227        Self {
228            paths,
229            config,
230            backend,
231        }
232    }
233
234    /// Build the index from scratch, discarding any existing segments.
235    ///
236    /// The new segment is written into fresh files and the manifest is swapped
237    /// atomically; the old segments are only deleted once the new index is
238    /// durably published. So if the rebuild fails partway (e.g. the disk fills
239    /// up), the previous index stays intact and queryable instead of being
240    /// destroyed up front.
241    pub fn index_full(&self) -> Result<IndexStats> {
242        std::fs::create_dir_all(self.paths.segments_dir())
243            .map_err(|e| Error::io(self.paths.segments_dir(), e))?;
244
245        // Continue the segment-id counter from the existing manifest so the new
246        // segment never collides with the old files we're about to replace. A
247        // genuinely unreadable manifest (malformed JSON or a schema-version bump)
248        // can't be trusted for the counter, so warn and recover it by scanning
249        // the segments directory for the highest live id. A real IO error reading
250        // the manifest is propagated rather than silently producing an empty index.
251        let mut meta = match Meta::load(&self.paths.meta_file()) {
252            Ok(meta) => meta,
253            Err(e @ (Error::Corrupt(_) | Error::Json(_))) => {
254                tracing::warn!("index manifest unusable ({e}); rebuilding from scratch");
255                Meta {
256                    next_segment_id: max_segment_id_on_disk(self.paths).map_or(0, |id| id + 1),
257                    ..Meta::default()
258                }
259            }
260            Err(e) => return Err(e),
261        };
262        let old_segments = std::mem::take(&mut meta.segments);
263        // A full rebuild replaces every segment, so any unapplied tombstone
264        // journal is moot.
265        meta.pending_tombstones.clear();
266
267        let cache = Cache::open(&self.paths.cache_file())?;
268
269        let walk::WalkResult {
270            entries,
271            skipped: walk_skips,
272        } = walk::walk(self.paths, self.config)?;
273        let candidates: Vec<&WalkEntry> = entries.iter().collect();
274        let (processed, proc_skips) = process_all(
275            &candidates,
276            self.backend,
277            self.config,
278            &RenameSources::new(),
279        );
280
281        let seg_id = meta.alloc_segment();
282        let mut writer = SegmentWriter::new();
283        let mut upserts = Vec::with_capacity(processed.len());
284        // Consume `processed` by value: the symbol/ref tables are String-heavy
285        // and cloning them per file used to dominate this loop's cost.
286        for pf in processed {
287            let symbols = pf.symbols.len() as u32;
288            let doc_id = writer.add_doc(pf.doc, &pf.trigram_keys, pf.symbols, pf.refs);
289            upserts.push((
290                pf.rel,
291                FileRecord {
292                    inode: pf.inode,
293                    mtime_ns: pf.mtime_ns,
294                    size: pf.size,
295                    hash: pf.hash,
296                    segment_id: seg_id,
297                    doc_id,
298                    symbols,
299                },
300            ));
301        }
302
303        let mut stats = IndexStats {
304            files_indexed: writer.doc_count(),
305            files_removed: 0,
306            symbols: writer.symbol_count(),
307            segments: 0,
308            ..Default::default()
309        };
310        stats.record_skips(walk_skips);
311        stats.record_skips(proc_skips);
312
313        if writer.is_empty() {
314            // Nothing to index; leave an empty manifest.
315            meta.segments = Vec::new();
316        } else {
317            writer.write(self.paths, seg_id)?;
318            meta.segments = vec![seg_id];
319        }
320        stats.segments = meta.segments.len();
321
322        // Publish the new manifest first so searches always see a consistent
323        // index, then refresh the cache and reclaim the old segment files.
324        meta.doc_count = stats.files_indexed as u64;
325        meta.symbol_count = stats.symbols as u64;
326        meta.record_git_head(&self.paths.root);
327        meta.touch_now();
328        meta.save(&self.paths.meta_file())?;
329
330        cache.replace_all(&upserts)?;
331
332        // Reclaim every segment file the new manifest doesn't reference — not
333        // just the ids the old manifest listed. A schema-version bump (or any
334        // unreadable manifest) takes the rebuild-from-scratch path above with
335        // an empty `old_segments`, and trusting it would leak the entire
336        // previous index on disk.
337        drop(old_segments);
338        self.sweep_unreferenced_segments(&meta.segments);
339        Ok(stats)
340    }
341
342    /// Incrementally update the index based on filesystem changes.
343    pub fn index_incremental(&self) -> Result<IndexStats> {
344        let mut meta = match Meta::load(&self.paths.meta_file()) {
345            Ok(meta) => meta,
346            // An unreadable or outdated manifest (e.g. a greplm upgrade that
347            // bumped the on-disk schema, or a truncated/corrupt meta.json) makes
348            // the existing segments unusable. Rather than failing every command
349            // until the user manually runs `greplm index --force`, transparently
350            // rebuild from scratch — `index_full` ignores the stale manifest and
351            // only swaps in the new index once it's durably written.
352            Err(e @ (Error::Corrupt(_) | Error::Json(_))) => {
353                tracing::warn!("index manifest unusable ({e}); rebuilding from scratch");
354                return self.index_full();
355            }
356            Err(e) => return Err(e),
357        };
358        if meta.segments.is_empty() {
359            return self.index_full();
360        }
361
362        // Recovery: apply any tombstones a previous run published in the
363        // manifest but didn't get to write into the live bitmaps.
364        self.apply_pending_tombstones(&mut meta)?;
365
366        let cache = Cache::open(&self.paths.cache_file())?;
367        let existing = match cache.load_all() {
368            Ok(existing) => existing,
369            // An undecodable cache record (bit-rot, or a `FileRecord` layout
370            // change that slipped through without a `SCHEMA_VERSION` bump) is a
371            // lost optimization, not a fatal error. Mirror the manifest path:
372            // rebuild from scratch, which wipes and repopulates the cache, so a
373            // query path self-heals instead of failing until `index --force`.
374            Err(e @ Error::Postcard(_)) => {
375                tracing::warn!("cache unreadable ({e}); rebuilding from scratch");
376                drop(cache);
377                return self.index_full();
378            }
379            Err(e) => return Err(e),
380        };
381
382        // Consistency guard: under normal operation every cache record points at
383        // a segment listed in the manifest. If that invariant is broken (e.g. a
384        // compaction that published the new manifest but was interrupted before
385        // refreshing the cache), trusting the cache could tombstone the wrong
386        // segment and leave duplicate or orphaned docs. The cache is rebuildable,
387        // so degrade to a full rebuild instead.
388        let live_segs: HashSet<u64> = meta.segments.iter().copied().collect();
389        if existing
390            .values()
391            .any(|r| !live_segs.contains(&r.segment_id))
392        {
393            // Release the cache handle before `index_full` reopens the database.
394            drop(existing);
395            drop(cache);
396            return self.index_full();
397        }
398
399        // Reverse guard: a manifest segment holding live docs that no cache
400        // record references means a previous run published a delta segment but
401        // crashed before its cache update landed. Trusting the cache would
402        // re-index those files into duplicates, so degrade to a full rebuild.
403        // (A fully tombstoned segment legitimately has no cache references and
404        // is skipped by the liveness check.)
405        let referenced: HashSet<u64> = existing.values().map(|r| r.segment_id).collect();
406        for &seg_id in &meta.segments {
407            if !referenced.contains(&seg_id)
408                && read_bitmap(&self.paths.live_file(seg_id)).is_ok_and(|bm| !bm.is_empty())
409            {
410                drop(existing);
411                drop(cache);
412                return self.index_full();
413            }
414        }
415
416        let walk::WalkResult {
417            entries,
418            skipped: walk_skips,
419        } = walk::walk(self.paths, self.config)?;
420        let mut seen: HashMap<String, &WalkEntry> = HashMap::with_capacity(entries.len());
421        for e in &entries {
422            seen.insert(e.rel.clone(), e);
423        }
424
425        // Deleted files: in the cache but no longer on disk. Computed before
426        // the read/parse stage so identical-content renames can be detected
427        // there and skip re-parsing.
428        let deleted: Vec<String> = existing
429            .keys()
430            .filter(|p| !seen.contains_key(*p))
431            .cloned()
432            .collect();
433        let (renames, rename_segs) = self.rename_sources(&existing, &deleted);
434
435        // Decide which entries need (re)processing using a cheap stat pre-check.
436        let candidates: Vec<&WalkEntry> = entries
437            .iter()
438            .filter(|e| {
439                let (_, mtime_ns, size) = stat_key(&e.metadata);
440                match existing.get(&e.rel) {
441                    Some(rec) => rec.size != size || rec.mtime_ns != mtime_ns,
442                    None => true,
443                }
444            })
445            .collect();
446
447        let (processed, proc_skips) = process_all(&candidates, self.backend, self.config, &renames);
448
449        // Keep only entries whose content hash actually changed.
450        let mut changed: Vec<Processed> = Vec::new();
451        let mut touch_only: Vec<(String, FileRecord)> = Vec::new();
452        for mut pf in processed {
453            // Rename fast path: the parse stage skipped tree-sitter because
454            // this content is identical to a doc deleted this run; copy that
455            // doc's symbols/refs instead.
456            if let Some((src_seg, src_doc)) = pf.rename_from {
457                if let Some(seg) = rename_segs.get(&src_seg) {
458                    pf.symbols = seg
459                        .doc_syms(src_doc)
460                        .map(|s| RawSymbol {
461                            name: s.name,
462                            kind: s.kind,
463                            line_start: s.line_start,
464                            line_end: s.line_end,
465                            container: s.container,
466                            signature: s.signature,
467                        })
468                        .collect();
469                    pf.refs = seg
470                        .doc_refs(src_doc)
471                        .map(|r| RawRef {
472                            name: r.name,
473                            kind: r.kind,
474                            line: r.line,
475                            column: r.column,
476                        })
477                        .collect();
478                }
479            }
480            match existing.get(&pf.rel) {
481                Some(rec) if rec.hash == pf.hash => {
482                    // Content identical; just refresh the stat key.
483                    touch_only.push((
484                        pf.rel.clone(),
485                        FileRecord {
486                            inode: pf.inode,
487                            mtime_ns: pf.mtime_ns,
488                            size: pf.size,
489                            hash: pf.hash,
490                            segment_id: rec.segment_id,
491                            doc_id: rec.doc_id,
492                            symbols: rec.symbols,
493                        },
494                    ));
495                }
496                _ => changed.push(pf),
497            }
498        }
499
500        // Collect the docs superseded by changed and deleted files. These are
501        // *not* applied yet: they are published in the manifest first (see
502        // below) so adds and deletes land atomically.
503        let mut tombstones: HashMap<u64, Vec<u32>> = HashMap::new();
504        for pf in &changed {
505            if let Some(rec) = existing.get(&pf.rel) {
506                tombstones
507                    .entry(rec.segment_id)
508                    .or_default()
509                    .push(rec.doc_id);
510            }
511        }
512        for path in &deleted {
513            if let Some(rec) = existing.get(path) {
514                tombstones
515                    .entry(rec.segment_id)
516                    .or_default()
517                    .push(rec.doc_id);
518            }
519        }
520
521        // Maintain index-wide counts incrementally. Each live document maps to
522        // exactly one cache record, so the deltas below keep `doc_count` /
523        // `symbol_count` exact without re-opening and re-parsing every segment.
524        // Computed before `changed` is consumed by the segment writer.
525        let mut doc_count = meta.doc_count as i64;
526        let mut sym_count = meta.symbol_count as i64;
527        for path in &deleted {
528            if let Some(rec) = existing.get(path) {
529                doc_count -= 1;
530                sym_count -= rec.symbols as i64;
531            }
532        }
533        for pf in &changed {
534            if let Some(rec) = existing.get(&pf.rel) {
535                // Replaced an existing doc: drop the old, add the new.
536                sym_count -= rec.symbols as i64;
537            } else {
538                // Brand-new file.
539                doc_count += 1;
540            }
541            sym_count += pf.symbols.len() as i64;
542        }
543
544        let changed_count = changed.len();
545        let changed_symbols: usize = changed.iter().map(|p| p.symbols.len()).sum();
546
547        // Write changed/new files into a fresh delta segment. Only allocate a
548        // segment id when there is actually something to write, so no-op
549        // incrementals don't burn ids. `changed` is consumed by value so the
550        // String-heavy symbol/ref tables move into the writer instead of being
551        // cloned per file.
552        let mut upserts = touch_only;
553        if !changed.is_empty() {
554            let seg_id = meta.alloc_segment();
555            let mut writer = SegmentWriter::new();
556            for pf in changed {
557                let symbols = pf.symbols.len() as u32;
558                let doc_id = writer.add_doc(pf.doc, &pf.trigram_keys, pf.symbols, pf.refs);
559                upserts.push((
560                    pf.rel,
561                    FileRecord {
562                        inode: pf.inode,
563                        mtime_ns: pf.mtime_ns,
564                        size: pf.size,
565                        hash: pf.hash,
566                        segment_id: seg_id,
567                        doc_id,
568                        symbols,
569                    },
570                ));
571            }
572            writer.write(self.paths, seg_id)?;
573            meta.segments.push(seg_id);
574        }
575
576        // Atomic publish: the new delta segment *and* the doc ids it
577        // supersedes land in one manifest write. Readers subtract pending
578        // tombstones from the live sets they load, so the index flips from
579        // old state to new state at this single rename — a crash on either
580        // side never surfaces stale docs alongside their replacements.
581        meta.pending_tombstones = tombstones
582            .iter()
583            .map(|(&segment_id, doc_ids)| PendingTombstones {
584                segment_id,
585                doc_ids: doc_ids.clone(),
586            })
587            .collect();
588        meta.doc_count = doc_count.max(0) as u64;
589        meta.symbol_count = sym_count.max(0) as u64;
590        meta.record_git_head(&self.paths.root);
591        meta.touch_now();
592        meta.save(&self.paths.meta_file())?;
593
594        // Now apply the published tombstones to the live bitmaps, refresh the
595        // cache, and clear the journal. A crash anywhere in between is
596        // recovered on the next run: `apply_pending_tombstones` replays the
597        // journal (idempotently) and the consistency guards catch a cache that
598        // never learned about the new segment.
599        for (seg_id, doc_ids) in &tombstones {
600            self.tombstone(*seg_id, doc_ids)?;
601        }
602        cache.apply(&upserts, &deleted)?;
603        if !meta.pending_tombstones.is_empty() {
604            meta.pending_tombstones.clear();
605            meta.save(&self.paths.meta_file())?;
606        }
607
608        let mut stats = IndexStats {
609            files_indexed: changed_count,
610            files_removed: deleted.len(),
611            symbols: changed_symbols,
612            segments: meta.segments.len(),
613            ..Default::default()
614        };
615        stats.record_skips(walk_skips);
616        stats.record_skips(proc_skips);
617
618        // Auto-compact if we've accumulated too many segments. Release the
619        // cache handle first: redb allows only one open handle per process,
620        // and the merge opens its own.
621        if meta.segments.len() > self.config.merge_threshold {
622            drop(cache);
623            self.compact_auto()?;
624        }
625        Ok(stats)
626    }
627
628    /// Apply (and clear) any tombstones that are published in the manifest but
629    /// not yet written into the per-segment live bitmaps — the recovery half
630    /// of the atomic-delete protocol. Idempotent: removing an already-dead doc
631    /// id is a no-op, so replaying after a crash is safe. The journal is only
632    /// cleared from disk after every bitmap write succeeded.
633    fn apply_pending_tombstones(&self, meta: &mut Meta) -> Result<()> {
634        if meta.pending_tombstones.is_empty() {
635            return Ok(());
636        }
637        let pending = std::mem::take(&mut meta.pending_tombstones);
638        for pt in &pending {
639            // The segment may have been dropped by a later operation.
640            if meta.segments.contains(&pt.segment_id) {
641                self.tombstone(pt.segment_id, &pt.doc_ids)?;
642            }
643        }
644        meta.save(&self.paths.meta_file())
645    }
646
647    /// Build the rename-source table for this run: for every file that
648    /// disappeared from disk, map its `(content hash, size)` to the old doc so
649    /// a new path with byte-identical content (a rename/move) can copy the old
650    /// doc's symbols and refs instead of re-running tree-sitter. Each source
651    /// segment is opened once; an unopenable segment just disables the fast
652    /// path for its docs (the slow path re-parses).
653    fn rename_sources(
654        &self,
655        existing: &HashMap<String, FileRecord>,
656        deleted: &[String],
657    ) -> (RenameSources, HashMap<u64, Segment>) {
658        let mut wanted: HashMap<u64, Vec<&FileRecord>> = HashMap::new();
659        for path in deleted {
660            if let Some(rec) = existing.get(path) {
661                wanted.entry(rec.segment_id).or_default().push(rec);
662            }
663        }
664        let mut renames = RenameSources::new();
665        let mut segs: HashMap<u64, Segment> = HashMap::new();
666        for (seg_id, recs) in wanted {
667            let seg = match Segment::open(self.paths, seg_id) {
668                Ok(s) => s,
669                Err(e) => {
670                    tracing::debug!("rename fast-path disabled for segment {seg_id}: {e}");
671                    continue;
672                }
673            };
674            for rec in recs {
675                if let Some(doc) = seg.doc(rec.doc_id) {
676                    renames.insert(
677                        (rec.hash, rec.size),
678                        RenameSrc {
679                            segment_id: seg_id,
680                            doc_id: rec.doc_id,
681                            lang: doc.lang.clone(),
682                        },
683                    );
684                }
685            }
686            segs.insert(seg_id, seg);
687        }
688        (renames, segs)
689    }
690
691    /// Merge all live documents from every segment into a single compact
692    /// segment. This reuses the already-indexed postings/symbols (no file reads,
693    /// no re-parsing) and falls back to a full rebuild if anything goes wrong.
694    pub fn compact(&self) -> Result<IndexStats> {
695        match self.merge_segments(true) {
696            Ok(stats) => Ok(stats),
697            Err(e) => {
698                tracing::warn!("merge compaction failed ({e}); falling back to full rebuild");
699                self.index_full()
700            }
701        }
702    }
703
704    /// Auto-compaction (tiered): merge only the *smallest* segments — by live
705    /// doc count — down to half the merge threshold, leaving the large ones
706    /// untouched. Compared to rewriting the whole index on every threshold
707    /// crossing, each doc is rewritten O(log n) times over the index's life
708    /// instead of O(n / threshold).
709    fn compact_auto(&self) -> Result<IndexStats> {
710        match self.merge_segments(false) {
711            Ok(stats) => Ok(stats),
712            Err(e) => {
713                tracing::warn!("auto compaction failed ({e}); falling back to full rebuild");
714                self.index_full()
715            }
716        }
717    }
718
719    /// Core of [`compact`] / [`Self::compact_auto`]: a streaming k-way merge
720    /// over the chosen segments. Doc/symbol/ref tables are concatenated with
721    /// remapped ids; postings are merged by a union over the segments' FST
722    /// term dictionaries, so the merged index's posting lists are never all
723    /// resident at once.
724    fn merge_segments(&self, all: bool) -> Result<IndexStats> {
725        let mut meta = Meta::load(&self.paths.meta_file())?;
726        if meta.segments.is_empty() {
727            return Ok(IndexStats::default());
728        }
729        // Live bitmaps are about to be read; make sure published-but-unapplied
730        // deletes are honored first.
731        self.apply_pending_tombstones(&mut meta)?;
732
733        let cache = Cache::open(&self.paths.cache_file())?;
734        let existing = cache.load_all()?;
735
736        // Victim selection: everything for an explicit compact; otherwise the
737        // smallest segments, leaving `target` slots for the survivors plus the
738        // merged output.
739        let victim_ids: Vec<u64> = if all {
740            meta.segments.clone()
741        } else {
742            let target = (self.config.merge_threshold / 2).max(1);
743            if meta.segments.len() <= target {
744                return Ok(IndexStats::default());
745            }
746            let mut by_live: Vec<(u64, u64)> = meta
747                .segments
748                .iter()
749                .map(|&id| Ok((id, read_bitmap(&self.paths.live_file(id))?.len())))
750                .collect::<Result<_>>()?;
751            by_live.sort_by_key(|&(_, n)| n);
752            by_live.truncate(meta.segments.len() - target + 1);
753            by_live.into_iter().map(|(id, _)| id).collect()
754        };
755        let victims: HashSet<u64> = victim_ids.iter().copied().collect();
756
757        let segments: Vec<Segment> = victim_ids
758            .iter()
759            .map(|&id| Segment::open(self.paths, id))
760            .collect::<Result<_>>()?;
761
762        let mut docs: Vec<DocMeta> = Vec::new();
763        // Rows stream straight into the columnar builders; the merged tables
764        // are never materialized as entry vecs.
765        let mut syms = crate::table::SymTableBuilder::new();
766        let mut refs = crate::table::RefTableBuilder::new();
767        let mut remaps: Vec<Vec<u32>> = Vec::with_capacity(segments.len());
768        let mut upserts: Vec<(String, FileRecord)> = Vec::new();
769        let new_seg_id = meta.alloc_segment();
770
771        for seg in &segments {
772            // Old doc id -> new doc id; `u32::MAX` marks tombstoned docs.
773            let mut remap: Vec<u32> = vec![u32::MAX; seg.docs.len()];
774            for old_id in seg.all_live().iter() {
775                let doc = match seg.doc(old_id) {
776                    Some(d) => d,
777                    None => continue,
778                };
779                let new_id = docs.len() as u32;
780                remap[old_id as usize] = new_id;
781                let syms_before = syms.len();
782                for s in seg.doc_syms(old_id) {
783                    syms.push(
784                        new_id,
785                        &s.name,
786                        &s.kind,
787                        s.line_start,
788                        s.line_end,
789                        s.container.as_deref(),
790                        s.signature.as_deref(),
791                    )?;
792                }
793                for r in seg.doc_refs(old_id) {
794                    refs.push(new_id, &r.name, r.kind, r.line, r.column)?;
795                }
796                let doc_sym_count = (syms.len() - syms_before) as u32;
797                let (inode, mtime_ns) = existing
798                    .get(&doc.path)
799                    .map(|r| (r.inode, r.mtime_ns))
800                    .unwrap_or((0, 0));
801                upserts.push((
802                    doc.path.clone(),
803                    FileRecord {
804                        inode,
805                        mtime_ns,
806                        size: doc.size,
807                        hash: doc.hash,
808                        segment_id: new_seg_id,
809                        doc_id: new_id,
810                        symbols: doc_sym_count,
811                    },
812                ));
813                docs.push(doc.clone());
814            }
815            remaps.push(remap);
816        }
817
818        let symbol_count = syms.len();
819        let doc_count = docs.len();
820
821        // The streaming postings merge and the table finishes (each ending in
822        // a parallel name sort) are independent; overlap them.
823        let (postings, tables) = rayon::join(
824            || merge_postings(&segments, &remaps),
825            || rayon::join(|| syms.finish(doc_count), || refs.finish(doc_count)),
826        );
827        let (post_blob, fst_entries) = postings?;
828        let (syms_enc, refs_enc) = tables;
829        drop(segments);
830
831        meta.segments.retain(|id| !victims.contains(id));
832        if !docs.is_empty() {
833            write_segment_files(
834                self.paths,
835                new_seg_id,
836                &docs,
837                syms_enc?,
838                refs_enc?,
839                &fst_entries,
840                post_blob,
841            )?;
842            meta.segments.push(new_seg_id);
843        }
844
845        // Publish the new manifest first so searches always see a consistent
846        // index, then refresh the cache and reclaim the old segment files.
847        if all {
848            // A full merge sees every live doc, so the totals are exact.
849            meta.doc_count = doc_count as u64;
850            meta.symbol_count = symbol_count as u64;
851        }
852        meta.touch_now();
853        meta.save(&self.paths.meta_file())?;
854
855        if all {
856            cache.replace_all(&upserts)?;
857        } else {
858            // Survivor segments keep their cache records; only merged docs
859            // move.
860            cache.apply(&upserts, &[])?;
861        }
862
863        for id in victim_ids {
864            self.remove_segment_files(id);
865        }
866
867        Ok(IndexStats {
868            files_indexed: doc_count,
869            files_removed: 0,
870            symbols: symbol_count,
871            segments: meta.segments.len(),
872            ..Default::default()
873        })
874    }
875
876    /// Best-effort removal of every `seg-*` file whose id is not in `live`.
877    /// Safe against concurrent readers: they hold mmaps/open fds, so unlink
878    /// only reclaims the space once they drop the segment.
879    fn sweep_unreferenced_segments(&self, live: &[u64]) {
880        let Ok(entries) = std::fs::read_dir(self.paths.segments_dir()) else {
881            return;
882        };
883        for e in entries.flatten() {
884            let name = e.file_name();
885            let Some(id) = name
886                .to_str()
887                .and_then(|n| n.strip_prefix("seg-"))
888                .and_then(|rest| rest.split('.').next())
889                .and_then(|digits| digits.parse::<u64>().ok())
890            else {
891                continue;
892            };
893            if !live.contains(&id) {
894                let _ = std::fs::remove_file(e.path());
895            }
896        }
897    }
898
899    /// Best-effort removal of all files belonging to a segment id.
900    fn remove_segment_files(&self, seg_id: u64) {
901        for path in [
902            self.paths.fst_file(seg_id),
903            self.paths.post_file(seg_id),
904            self.paths.docs_file(seg_id),
905            self.paths.syms_file(seg_id),
906            self.paths.refs_file(seg_id),
907            self.paths.live_file(seg_id),
908        ] {
909            let _ = std::fs::remove_file(path);
910        }
911    }
912
913    /// Clear a set of doc ids from a segment's live bitmap.
914    fn tombstone(&self, seg_id: u64, doc_ids: &[u32]) -> Result<()> {
915        let live_path = self.paths.live_file(seg_id);
916        let mut live = read_bitmap(&live_path)?;
917        for id in doc_ids {
918            live.remove(*id);
919        }
920        write_bitmap(&live_path, &live)
921    }
922}