Skip to main content

greplm_core/
lib.rs

1//! greplm-core: an extreme-performance, trigram-based code indexer for LLM agents.
2//!
3//! The index lives in a `.greplm/` directory at the project root and consists of
4//! immutable, mmap-backed segments (trigram FST + roaring posting lists + doc and
5//! symbol tables). Search filters candidate documents by trigram intersection,
6//! then verifies matches with the real literal/regex matcher.
7
8mod error;
9
10pub mod cache;
11pub mod client;
12pub mod config;
13pub mod context;
14pub mod daemon;
15pub(crate) mod fsutil;
16pub mod git;
17pub mod indexer;
18pub mod io_backend;
19pub mod lang;
20pub mod meta;
21pub mod paths;
22pub mod proto;
23pub mod resolve;
24pub mod savings;
25pub mod search;
26pub mod segment;
27#[cfg(feature = "semantic")]
28pub mod semantic;
29pub mod structural;
30pub mod symbol;
31pub(crate) mod table;
32pub mod trigram;
33pub mod walk;
34pub mod watch;
35
36pub use error::{Error, Result};
37
38/// Test-only crash/fault-injection seam for the atomic-write path (see
39/// [`fsutil::faults`]). Hidden from public docs; used by greplm's own
40/// durability tests to simulate crashes mid-index/compaction.
41#[doc(hidden)]
42pub use fsutil::faults;
43
44use std::path::{Path, PathBuf};
45
46use config::Config;
47use indexer::{IndexStats, Indexer};
48use io_backend::IoBackend;
49use meta::Meta;
50use paths::Paths;
51use search::Searcher;
52
53/// Status snapshot for `greplm status`.
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct Status {
56    pub root: PathBuf,
57    pub indexed: bool,
58    pub segments: usize,
59    pub doc_count: u64,
60    pub symbol_count: u64,
61    pub last_indexed: u64,
62    pub backend: String,
63}
64
65/// A handle to a greplm project (a directory and its `.greplm` index).
66pub struct Greplm {
67    paths: Paths,
68    config: Config,
69    backend: Box<dyn IoBackend>,
70}
71
72impl Greplm {
73    /// Open (and lazily initialize) the index for `root`.
74    pub fn open(root: impl AsRef<Path>) -> Result<Greplm> {
75        let paths = Paths::new(root);
76        let config = Config::load(&paths.config_file())?;
77        Ok(Greplm {
78            paths,
79            config,
80            backend: io_backend::default_backend(),
81        })
82    }
83
84    /// Find the nearest ancestor of `start` containing a `.greplm` directory,
85    /// falling back to `start` itself if none is found.
86    pub fn discover(start: impl AsRef<Path>) -> Result<Greplm> {
87        let start = start.as_ref();
88        let mut cur = Some(start);
89        while let Some(dir) = cur {
90            if dir.join(paths::DIR_NAME).is_dir() {
91                return Greplm::open(dir);
92            }
93            cur = dir.parent();
94        }
95        Greplm::open(start)
96    }
97
98    pub fn root(&self) -> &Path {
99        &self.paths.root
100    }
101
102    pub fn config(&self) -> &Config {
103        &self.config
104    }
105
106    /// Ensure the `.greplm` directory exists with a default config and gitignore.
107    pub fn ensure_initialized(&self) -> Result<()> {
108        std::fs::create_dir_all(self.paths.segments_dir())
109            .map_err(|e| Error::io(self.paths.segments_dir(), e))?;
110        let cfg = self.paths.config_file();
111        if !cfg.exists() {
112            self.config.save(&cfg)?;
113        }
114        let gi = self.paths.gitignore_file();
115        if !gi.exists() {
116            std::fs::write(&gi, "*\n").map_err(|e| Error::io(&gi, e))?;
117        }
118        Ok(())
119    }
120
121    /// Build or refresh the index.
122    pub fn index(&self, force: bool) -> Result<IndexStats> {
123        self.ensure_initialized()?;
124        let indexer = Indexer::new(&self.paths, &self.config, self.backend.as_ref());
125        if force {
126            indexer.index_full()
127        } else {
128            indexer.index_incremental()
129        }
130    }
131
132    /// Ensure a usable, current-schema index exists, building it if absent,
133    /// empty, or left unreadable by an on-disk format change. Returns `true` if
134    /// a (re)build happened. This is the self-healing entry point for query
135    /// paths: a fresh checkout or a post-upgrade stale index transparently
136    /// builds instead of erroring with "run `greplm index` first".
137    ///
138    /// Cheap when a good index already exists (one manifest read). The actual
139    /// rebuild-on-corrupt logic lives in [`Indexer::index_incremental`], which
140    /// falls back to a full rebuild on an unreadable/outdated manifest.
141    pub fn ensure_indexed(&self) -> Result<bool> {
142        match self.status() {
143            // A populated, current-schema index — nothing to do.
144            Ok(s) if s.indexed => return Ok(false),
145            // Initialized but empty, or readable-but-empty manifest: build.
146            Ok(_) => {}
147            // Unreadable/outdated manifest (e.g. schema bump): index() rebuilds.
148            Err(_) => {}
149        }
150        self.index(false)?;
151        Ok(true)
152    }
153
154    /// Stat-only freshness probe: does any file on disk differ from what the
155    /// index recorded (new, modified by size/mtime, or deleted)? No content
156    /// hashing and no reads — just the same cheap pre-check the incremental
157    /// indexer uses. The daemon calls this to guarantee read-after-write
158    /// consistency: if dirty, it reindexes before answering.
159    pub fn is_dirty(&self) -> Result<bool> {
160        let cache = cache::Cache::open(&self.paths.cache_file())?;
161        let existing = cache.load_all()?;
162        let walked = walk::walk(&self.paths, &self.config)?;
163
164        let mut seen = std::collections::HashSet::with_capacity(walked.entries.len());
165        for e in &walked.entries {
166            seen.insert(e.rel.clone());
167            let (_, mtime_ns, size) = cache::stat_key(&e.metadata);
168            match existing.get(&e.rel) {
169                // Already indexed; a changed stat key means it may have changed.
170                Some(rec) if rec.size == size && rec.mtime_ns == mtime_ns => {}
171                Some(_) => return Ok(true),
172                // Not in the index. It's only "dirty" if the indexer would
173                // actually index it — files it intentionally skips (binary) are
174                // never cached, so counting them would make any project with a
175                // binary file look perpetually stale.
176                None => {
177                    if self.would_index(&e.path) {
178                        return Ok(true);
179                    }
180                }
181            }
182        }
183        // A previously indexed file that disappeared (or became un-indexable,
184        // e.g. now too large/binary and dropped from the walk) is also dirty.
185        for path in existing.keys() {
186            if !seen.contains(path) {
187                return Ok(true);
188            }
189        }
190        Ok(false)
191    }
192
193    /// Would the indexer index this not-yet-cached file, or skip it the way its
194    /// read stage does (binary content, unreadable)? Mirrors `indexer::process`
195    /// so [`is_dirty`](Self::is_dirty) doesn't flag intentionally-skipped files.
196    fn would_index(&self, path: &Path) -> bool {
197        match std::fs::read(path) {
198            Ok(data) => self.config.index_binary || memchr::memchr(0, &data).is_none(),
199            Err(_) => false,
200        }
201    }
202
203    /// Merge all segments into a single compact segment, dropping tombstoned
204    /// documents. Falls back to a full rebuild if the merge cannot proceed.
205    pub fn compact(&self) -> Result<IndexStats> {
206        self.ensure_initialized()?;
207        Indexer::new(&self.paths, &self.config, self.backend.as_ref()).compact()
208    }
209
210    /// Open a searcher over the current index.
211    pub fn searcher(&self) -> Result<Searcher> {
212        Searcher::open(&self.paths)
213    }
214
215    /// Open a searcher over the current index, sharing unchanged segments and
216    /// the warm content cache with `prev` (see [`Searcher::open_reusing`]).
217    /// The daemon uses this so a hot-swap after an incremental index re-reads
218    /// only what actually changed instead of re-parsing every segment.
219    pub fn searcher_reusing(&self, prev: &Searcher) -> Result<Searcher> {
220        Searcher::open_reusing(&self.paths, prev)
221    }
222
223    /// Content search that always returns results: it queries the index, and if
224    /// the index is missing or errors, transparently falls back to an
225    /// index-free walk+scan (grep parity). The fallback is logged at WARN.
226    pub fn search_or_grep(&self, query: &search::SearchQuery) -> Result<Vec<search::SearchHit>> {
227        match self.searcher().and_then(|s| s.search(query)) {
228            Ok(hits) => Ok(hits),
229            Err(e) => {
230                tracing::warn!("index unavailable ({e}); falling back to grep walk");
231                search::grep_walk(&self.paths, &self.config, query)
232            }
233        }
234    }
235
236    /// Report index status.
237    pub fn status(&self) -> Result<Status> {
238        let meta = if self.paths.meta_file().exists() {
239            Meta::load(&self.paths.meta_file())?
240        } else {
241            Meta::default()
242        };
243        Ok(Status {
244            root: self.paths.root.clone(),
245            indexed: !meta.segments.is_empty(),
246            segments: meta.segments.len(),
247            doc_count: meta.doc_count,
248            symbol_count: meta.symbol_count,
249            last_indexed: meta.last_indexed,
250            backend: self.backend.name().to_string(),
251        })
252    }
253
254    /// Remove the entire `.greplm` directory.
255    pub fn clean(&self) -> Result<()> {
256        if self.paths.base.is_dir() {
257            std::fs::remove_dir_all(&self.paths.base)
258                .map_err(|e| Error::io(&self.paths.base, e))?;
259        }
260        Ok(())
261    }
262
263    /// Watch the project tree and re-index incrementally on changes.
264    ///
265    /// `on_change` is called after each successful incremental update. This call
266    /// blocks until an error occurs or the watcher is dropped.
267    pub fn watch<F: FnMut(&IndexStats)>(
268        &self,
269        debounce: std::time::Duration,
270        on_change: F,
271    ) -> Result<()> {
272        watch::run(self, debounce, on_change)
273    }
274
275    /// Path to the daemon's Unix socket for this project.
276    pub fn socket_path(&self) -> PathBuf {
277        self.paths.base.join(proto::SOCKET_NAME)
278    }
279
280    /// Record a query's token savings (grep+read baseline vs. returned payload).
281    /// Best-effort; never fails a query.
282    pub fn record_savings(
283        &self,
284        kind: &str,
285        files: &std::collections::BTreeSet<String>,
286        returned_chars: u64,
287        results: u64,
288    ) {
289        savings::record(&self.paths, kind, files, returned_chars, results);
290    }
291
292    /// Aggregate the recorded token-savings log.
293    pub fn savings_report(&self) -> savings::SavingsReport {
294        savings::report(&self.paths)
295    }
296
297    pub(crate) fn paths(&self) -> &Paths {
298        &self.paths
299    }
300}