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 /// Serializes index mutation within this process. The daemon can drive two
71 /// indexers at once — the background watcher and a `strict`-freshness query
72 /// that reindexes before answering — and two concurrent incremental passes
73 /// over the same `.greplm` would race on segment writes and the manifest.
74 /// Held for the duration of [`index`](Self::index).
75 index_lock: std::sync::Mutex<()>,
76}
77
78impl Greplm {
79 /// Open (and lazily initialize) the index for `root`.
80 pub fn open(root: impl AsRef<Path>) -> Result<Greplm> {
81 let paths = Paths::new(root);
82 let config = Config::load(&paths.config_file())?;
83 Ok(Greplm {
84 backend: io_backend::select(config.backend),
85 paths,
86 config,
87 index_lock: std::sync::Mutex::new(()),
88 })
89 }
90
91 /// Find the nearest ancestor of `start` containing a `.greplm` directory,
92 /// falling back to `start` itself if none is found.
93 pub fn discover(start: impl AsRef<Path>) -> Result<Greplm> {
94 let start = start.as_ref();
95 let mut cur = Some(start);
96 while let Some(dir) = cur {
97 if dir.join(paths::DIR_NAME).is_dir() {
98 return Greplm::open(dir);
99 }
100 cur = dir.parent();
101 }
102 Greplm::open(start)
103 }
104
105 pub fn root(&self) -> &Path {
106 &self.paths.root
107 }
108
109 pub fn config(&self) -> &Config {
110 &self.config
111 }
112
113 /// Ensure the `.greplm` directory exists with a default config and gitignore.
114 pub fn ensure_initialized(&self) -> Result<()> {
115 std::fs::create_dir_all(self.paths.segments_dir())
116 .map_err(|e| Error::io(self.paths.segments_dir(), e))?;
117 let cfg = self.paths.config_file();
118 if !cfg.exists() {
119 self.config.save(&cfg)?;
120 }
121 let gi = self.paths.gitignore_file();
122 if !gi.exists() {
123 std::fs::write(&gi, "*\n").map_err(|e| Error::io(&gi, e))?;
124 }
125 Ok(())
126 }
127
128 /// Acquire the index-mutation lock. Held by [`index`](Self::index) for the
129 /// duration of a build, and exposed so the daemon can serialize a searcher
130 /// hot-swap (manifest read + publish) against indexing *and against other
131 /// swaps*. Without that, two concurrent swaps (the watcher and a
132 /// `strict`-freshness query reacting to the same edit) can store
133 /// out of order, letting an older searcher clobber a newer one and leaving
134 /// the index stale until the next file event.
135 ///
136 /// A poisoned lock just means a prior holder panicked — on-disk state is
137 /// still consistent (atomic writes), so recover the guard and proceed.
138 pub fn index_guard(&self) -> std::sync::MutexGuard<'_, ()> {
139 self.index_lock.lock().unwrap_or_else(|e| e.into_inner())
140 }
141
142 /// Build or refresh the index.
143 pub fn index(&self, force: bool) -> Result<IndexStats> {
144 self.ensure_initialized()?;
145 // Serialize concurrent in-process indexers (watcher vs. strict query).
146 let _guard = self.index_guard();
147 let indexer = Indexer::new(&self.paths, &self.config, self.backend.as_ref());
148 if force {
149 indexer.index_full()
150 } else {
151 indexer.index_incremental()
152 }
153 }
154
155 /// Ensure a usable, current-schema index exists, building it if absent,
156 /// empty, or left unreadable by an on-disk format change. Returns `true` if
157 /// a (re)build happened. This is the self-healing entry point for query
158 /// paths: a fresh checkout or a post-upgrade stale index transparently
159 /// builds instead of erroring with "run `greplm index` first".
160 ///
161 /// Cheap when a good index already exists (one manifest read). The actual
162 /// rebuild-on-corrupt logic lives in [`Indexer::index_incremental`], which
163 /// falls back to a full rebuild on an unreadable/outdated manifest.
164 pub fn ensure_indexed(&self) -> Result<bool> {
165 match self.status() {
166 // A populated, current-schema index — nothing to do.
167 Ok(s) if s.indexed => return Ok(false),
168 // Initialized but empty, or readable-but-empty manifest: build.
169 Ok(_) => {}
170 // Unreadable/outdated manifest (e.g. schema bump): index() rebuilds.
171 Err(_) => {}
172 }
173 self.index(false)?;
174 Ok(true)
175 }
176
177 /// Stat-only freshness probe: does any file on disk differ from what the
178 /// index recorded (new, modified by size/mtime, or deleted)? No content
179 /// hashing and no reads — just the same cheap pre-check the incremental
180 /// indexer uses. The daemon calls this to guarantee read-after-write
181 /// consistency: if dirty, it reindexes before answering.
182 pub fn is_dirty(&self) -> Result<bool> {
183 let cache = cache::Cache::open(&self.paths.cache_file())?;
184 let existing = match cache.load_all() {
185 Ok(existing) => existing,
186 // An undecodable cache is a lost optimization, not a probe failure:
187 // report dirty so the caller reindexes, which rebuilds the cache
188 // (see `Indexer::index_incremental`). Never surface a hard error here.
189 Err(Error::Postcard(_)) => return Ok(true),
190 Err(e) => return Err(e),
191 };
192 let walked = walk::walk(&self.paths, &self.config)?;
193
194 let mut seen = std::collections::HashSet::with_capacity(walked.entries.len());
195 for e in &walked.entries {
196 seen.insert(e.rel.clone());
197 let (_, mtime_ns, size) = cache::stat_key(&e.metadata);
198 match existing.get(&e.rel) {
199 // Already indexed; a changed stat key means it may have changed.
200 Some(rec) if rec.size == size && rec.mtime_ns == mtime_ns => {}
201 Some(_) => return Ok(true),
202 // Not in the index. It's only "dirty" if the indexer would
203 // actually index it — files it intentionally skips (binary) are
204 // never cached, so counting them would make any project with a
205 // binary file look perpetually stale.
206 None => {
207 if self.would_index(&e.path) {
208 return Ok(true);
209 }
210 }
211 }
212 }
213 // A previously indexed file that disappeared (or became un-indexable,
214 // e.g. now too large/binary and dropped from the walk) is also dirty.
215 for path in existing.keys() {
216 if !seen.contains(path) {
217 return Ok(true);
218 }
219 }
220 Ok(false)
221 }
222
223 /// Would the indexer index this not-yet-cached file, or skip it the way its
224 /// read stage does (binary content, unreadable)? Mirrors `indexer::process`
225 /// so [`is_dirty`](Self::is_dirty) doesn't flag intentionally-skipped files.
226 fn would_index(&self, path: &Path) -> bool {
227 match std::fs::read(path) {
228 Ok(data) => self.config.index_binary || memchr::memchr(0, &data).is_none(),
229 Err(_) => false,
230 }
231 }
232
233 /// Merge all segments into a single compact segment, dropping tombstoned
234 /// documents. Falls back to a full rebuild if the merge cannot proceed.
235 pub fn compact(&self) -> Result<IndexStats> {
236 self.ensure_initialized()?;
237 Indexer::new(&self.paths, &self.config, self.backend.as_ref()).compact()
238 }
239
240 /// Open a searcher over the current index.
241 pub fn searcher(&self) -> Result<Searcher> {
242 Searcher::open(&self.paths)
243 }
244
245 /// Open a searcher over the current index, sharing unchanged segments and
246 /// the warm content cache with `prev` (see [`Searcher::open_reusing`]).
247 /// The daemon uses this so a hot-swap after an incremental index re-reads
248 /// only what actually changed instead of re-parsing every segment.
249 pub fn searcher_reusing(&self, prev: &Searcher) -> Result<Searcher> {
250 Searcher::open_reusing(&self.paths, prev)
251 }
252
253 /// Content search that always returns results: it queries the index, and if
254 /// the index is missing or errors, transparently falls back to an
255 /// index-free walk+scan (grep parity). The fallback is logged at WARN.
256 pub fn search_or_grep(&self, query: &search::SearchQuery) -> Result<Vec<search::SearchHit>> {
257 match self.searcher().and_then(|s| s.search(query)) {
258 Ok(hits) => Ok(hits),
259 Err(e) => {
260 tracing::warn!("index unavailable ({e}); falling back to grep walk");
261 search::grep_walk(&self.paths, &self.config, query)
262 }
263 }
264 }
265
266 /// Report index status.
267 pub fn status(&self) -> Result<Status> {
268 let meta = if self.paths.meta_file().exists() {
269 Meta::load(&self.paths.meta_file())?
270 } else {
271 Meta::default()
272 };
273 Ok(Status {
274 root: self.paths.root.clone(),
275 indexed: !meta.segments.is_empty(),
276 segments: meta.segments.len(),
277 doc_count: meta.doc_count,
278 symbol_count: meta.symbol_count,
279 last_indexed: meta.last_indexed,
280 backend: self.backend.name().to_string(),
281 })
282 }
283
284 /// Remove the entire `.greplm` directory.
285 pub fn clean(&self) -> Result<()> {
286 if self.paths.base.is_dir() {
287 std::fs::remove_dir_all(&self.paths.base)
288 .map_err(|e| Error::io(&self.paths.base, e))?;
289 }
290 Ok(())
291 }
292
293 /// Watch the project tree and re-index incrementally on changes.
294 ///
295 /// `on_change` is called after each successful incremental update. This call
296 /// blocks until an error occurs or the watcher is dropped.
297 pub fn watch<F: FnMut(&IndexStats)>(
298 &self,
299 debounce: std::time::Duration,
300 on_change: F,
301 ) -> Result<()> {
302 watch::run(self, debounce, on_change)
303 }
304
305 /// Path to the daemon's Unix socket for this project.
306 pub fn socket_path(&self) -> PathBuf {
307 self.paths.base.join(proto::SOCKET_NAME)
308 }
309
310 /// Record a query's token savings (grep+read baseline vs. returned payload).
311 /// Best-effort; never fails a query.
312 pub fn record_savings(
313 &self,
314 kind: &str,
315 files: &std::collections::BTreeSet<String>,
316 returned_chars: u64,
317 results: u64,
318 ) {
319 savings::record(&self.paths, kind, files, returned_chars, results);
320 }
321
322 /// Aggregate the recorded token-savings log.
323 pub fn savings_report(&self) -> savings::SavingsReport {
324 savings::report(&self.paths)
325 }
326
327 pub(crate) fn paths(&self) -> &Paths {
328 &self.paths
329 }
330}