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