Skip to main content

gunnar_server/
search.rs

1//! **`gunnar search` — a literal string, across one revision's files.**
2//!
3//! There was no code search anywhere in gunnar before this. This is the
4//! smallest honest first version: resolve a ref to its tree, walk the tree, and
5//! scan each file's lines for a **literal** substring, returning `path:line`
6//! matches.
7//!
8//! # What it is bounded by, and why the bound is the point
9//!
10//! A search is a control-plane read, and unlike a clone it has no transfer
11//! admission in front of it — so an unbounded walk of the znippy blob store is a
12//! way to make one cheap request read every byte the repository holds. It is
13//! therefore capped by construction:
14//!
15//! * **A file-size ceiling** ([`Limits::max_file_bytes`]). A blob larger than it
16//!   is skipped *without being read* — the size comes from
17//!   [`GitServe::header`], which a backend answers without inflating the object,
18//!   so a 2 GiB blob costs a header lookup, not 2 GiB of reads.
19//! * **A match ceiling** ([`Limits::max_matches`]). The walk stops as soon as it
20//!   is reached; a pattern that is in every file does not stream the whole tree
21//!   back.
22//!
23//! Binary files are skipped by the same NUL probe a diff uses
24//! ([`crate::diff::is_binary`]), so "search" means "search text".
25//!
26//! # The two follow-ups this deliberately is not
27//!
28//! 1. **A persistent index.** This walks the tree live on every query. A search
29//!    index — the trigram/postings store that answers a query in milliseconds
30//!    and survives a restart — is a separate, much larger lane: it has to be
31//!    built, kept in step with every push, and stored. This does none of that,
32//!    and says so.
33//! 2. **Regex, and history.** The pattern here is a literal, and the scope is
34//!    the *latest* revision of one ref. Regular-expression matching (which would
35//!    add the `regex` crate and its tail to an appliance that today links
36//!    neither) and searching across history are the next steps, not this one.
37
38use anyhow::{Context, Result};
39use gunnar_store::git_storage_trait::{GitServe, ObjType};
40use gunnar_store::object::{self, EntryKind, TreeIter};
41
42use crate::stores::ServerStores;
43
44/// A file that is scanned no matter its size would be a way to make one request
45/// read the largest blob in the repository; a match ceiling that is missing
46/// would let a common word stream the whole tree back. Both are here.
47#[derive(Debug, Clone, Copy)]
48pub struct Limits {
49    /// Files larger than this are skipped without being read.
50    pub max_file_bytes: u64,
51    /// The walk stops once this many matches have been found.
52    pub max_matches: usize,
53}
54
55/// 1 MiB per file, 1000 matches — a source-tree search, not a data-lake scan.
56pub const DEFAULT_MAX_FILE_BYTES: u64 = 1 << 20;
57/// The default match ceiling.
58pub const DEFAULT_MAX_MATCHES: usize = 1000;
59
60impl Default for Limits {
61    fn default() -> Self {
62        Limits {
63            max_file_bytes: DEFAULT_MAX_FILE_BYTES,
64            max_matches: DEFAULT_MAX_MATCHES,
65        }
66    }
67}
68
69/// One matching line.
70#[derive(Debug, Clone)]
71pub struct Match {
72    /// The file's path within the tree, `/`-joined.
73    pub path: String,
74    /// The one-based line number, the way an editor and `grep` count.
75    pub line_number: usize,
76    /// The line's bytes, without its trailing newline.
77    pub line: Vec<u8>,
78}
79
80/// **The tip's root tree, or `None` if the walk should find nothing.**
81fn root_tree(
82    git: &dyn GitServe,
83    hash: gix_hash::Kind,
84    commit: gix_hash::ObjectId,
85) -> Result<gix_hash::ObjectId> {
86    let (kind, bytes) = git
87        .read(commit.as_bytes())?
88        .with_context(|| format!("commit {commit} is not in this repository"))?;
89    anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
90    Ok(object::commit(&bytes, hash)
91        .with_context(|| format!("decoding commit {commit}"))?
92        .tree)
93}
94
95/// **Search the tree at `commit` for the literal `needle`.**
96///
97/// The testable core: it takes only a [`GitServe`] and a commit, so a test can
98/// plant a known string at a known path and line and assert exactly that — and
99/// assert a string that is *not* there returns nothing rather than an error or a
100/// false hit.
101pub fn search_tree(
102    git: &dyn GitServe,
103    hash: gix_hash::Kind,
104    commit: gix_hash::ObjectId,
105    needle: &[u8],
106    limits: Limits,
107) -> Result<Vec<Match>> {
108    // An empty pattern matches every position of every line; that is not a
109    // search, it is a dump, and it is refused rather than answered.
110    anyhow::ensure!(!needle.is_empty(), "the search pattern is empty");
111
112    let mut out = Vec::new();
113    let root = root_tree(git, hash, commit)?;
114    walk(git, hash, root, "", needle, &limits, &mut out)?;
115    Ok(out)
116}
117
118/// Depth-first over one tree, appending matches until the ceiling is reached.
119fn walk(
120    git: &dyn GitServe,
121    hash: gix_hash::Kind,
122    tree: gix_hash::ObjectId,
123    prefix: &str,
124    needle: &[u8],
125    limits: &Limits,
126    out: &mut Vec<Match>,
127) -> Result<()> {
128    if out.len() >= limits.max_matches {
129        return Ok(());
130    }
131    let (kind, bytes) = match git.read(tree.as_bytes())? {
132        Some(pair) => pair,
133        None => return Ok(()),
134    };
135    if kind != ObjType::Tree {
136        return Ok(());
137    }
138    for entry in TreeIter::from_bytes(&bytes, hash).named() {
139        let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
140        let name = String::from_utf8_lossy(entry.name);
141        let path = if prefix.is_empty() {
142            name.into_owned()
143        } else {
144            format!("{prefix}/{name}")
145        };
146        match entry.kind {
147            EntryKind::Tree => walk(git, hash, entry.oid, &path, needle, limits, out)?,
148            EntryKind::Blob | EntryKind::BlobExecutable => {
149                scan_blob(git, entry.oid, &path, needle, limits, out)?
150            }
151            // A symlink's blob is a path, not searchable source; a gitlink names
152            // a commit in another repository and has no blob here. Neither is a
153            // file this search is about.
154            EntryKind::Link | EntryKind::Commit => {}
155        }
156        if out.len() >= limits.max_matches {
157            break;
158        }
159    }
160    Ok(())
161}
162
163/// Scan one blob's lines, honouring the size ceiling before any bytes are read.
164fn scan_blob(
165    git: &dyn GitServe,
166    oid: gix_hash::ObjectId,
167    path: &str,
168    needle: &[u8],
169    limits: &Limits,
170    out: &mut Vec<Match>,
171) -> Result<()> {
172    // The size FIRST, from the header, so an over-ceiling blob is never
173    // inflated. `None` means the store cannot answer without reading; then the
174    // read below still enforces the ceiling.
175    if let Some((_, size)) = git.header(oid.as_bytes())? {
176        if size > limits.max_file_bytes {
177            return Ok(());
178        }
179    }
180    let Some((ObjType::Blob, bytes)) = git.read(oid.as_bytes())? else {
181        return Ok(());
182    };
183    // A store that could not size the blob from its header still must not scan a
184    // file over the ceiling.
185    if bytes.len() as u64 > limits.max_file_bytes {
186        return Ok(());
187    }
188    if crate::diff::is_binary(&bytes) {
189        return Ok(());
190    }
191    for (i, line) in crate::diff::split_lines(&bytes).into_iter().enumerate() {
192        if contains(line, needle) {
193            out.push(Match {
194                path: path.to_owned(),
195                line_number: i + 1,
196                line: line.to_vec(),
197            });
198            if out.len() >= limits.max_matches {
199                break;
200            }
201        }
202    }
203    Ok(())
204}
205
206/// Does `haystack` contain `needle` as a contiguous byte run?
207fn contains(haystack: &[u8], needle: &[u8]) -> bool {
208    if needle.is_empty() || needle.len() > haystack.len() {
209        return false;
210    }
211    haystack
212        .windows(needle.len())
213        .any(|window| window == needle)
214}
215
216/// **Search `rev` in `repo`** for the literal `pattern`, resolving the revision
217/// the way `verify` and `Refs.Log` do.
218pub fn search(
219    stores: &ServerStores,
220    plane: &crate::control_plane::CoreControlPlane,
221    repo: &gunnar_proto::RepoPath,
222    rev: &str,
223    pattern: &str,
224    limits: Limits,
225) -> Result<Vec<Match>> {
226    use gunnar_grpc::ControlPlane as _;
227
228    let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
229        .context("the repository path is not a legal one")?;
230    let entry = stores
231        .catalog()
232        .get(&repo_ref)?
233        .with_context(|| format!("no such repository: {repo_ref}"))?;
234    let store = stores.repo_store(&entry)?;
235    let git = store.serve();
236    let hash = store.hash_kind();
237
238    let commits = plane
239        .log(repo, rev, 1, "")
240        .map_err(|err| anyhow::anyhow!("{err}"))?;
241    let head = commits
242        .first()
243        .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
244    let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
245        .map_err(gix_error::Error::from)
246        .with_context(|| format!("{} is not an object id", head.oid))?;
247
248    search_tree(git, hash, tip, pattern.as_bytes(), limits)
249}
250
251/// [`search`] over a data root, opening the stores itself — the CLI verb's entry.
252pub fn search_at(
253    data_root: &std::path::Path,
254    repo: &gunnar_proto::RepoPath,
255    rev: &str,
256    pattern: &str,
257    limits: Limits,
258) -> Result<Vec<Match>> {
259    let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
260    let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
261    search(&stores, &plane, repo, rev, pattern, limits)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::testgit::{Repo, TREE_MODE_BLOB, TREE_MODE_TREE};
268
269    /// **A present string is found at its exact file and line; an absent one
270    /// returns nothing.**
271    ///
272    /// Both halves are the guard. The red the "present" half is written against
273    /// is a search that returns everything (or the wrong line); the red the
274    /// "absent" half is written against is a search that errors, or matches a
275    /// substring that is not really there. Proven red on 2026-08-31 by making
276    /// `contains` always return `true`: the absent-string assertion then found
277    /// hits and failed.
278    #[test]
279    fn a_present_string_is_found_at_its_line_and_an_absent_one_is_not() {
280        let repo = Repo::new();
281        let readme = repo.blob(b"hello world\nthe needle is here\ngoodbye\n");
282        let other = repo.blob(b"nothing to see\nmove along\n");
283        let src = repo.tree(&[(TREE_MODE_BLOB, "main.rs", other)]);
284        let root = repo.tree(&[
285            (TREE_MODE_BLOB, "README.md", readme),
286            (TREE_MODE_TREE, "src", src),
287        ]);
288        let commit = repo.commit(root, &[], "Ada", "seed");
289
290        let hits = search_tree(
291            repo.serve(),
292            repo.hash_kind(),
293            commit,
294            b"needle",
295            Limits::default(),
296        )
297        .expect("search");
298        assert_eq!(
299            hits.len(),
300            1,
301            "the needle is on exactly one line of one file"
302        );
303        assert_eq!(hits[0].path, "README.md");
304        assert_eq!(hits[0].line_number, 2);
305        assert_eq!(hits[0].line, b"the needle is here");
306
307        // A string that is nowhere returns an EMPTY result — not an error, and
308        // not a false match on a near-miss.
309        let none = search_tree(
310            repo.serve(),
311            repo.hash_kind(),
312            commit,
313            b"haystackk",
314            Limits::default(),
315        )
316        .expect("search for an absent string is not an error");
317        assert!(
318            none.is_empty(),
319            "an absent string must yield zero hits, got {none:?}"
320        );
321    }
322
323    /// The size ceiling skips a file without letting it match, and the match
324    /// ceiling stops the walk — both are asserted so neither can silently do
325    /// nothing.
326    #[test]
327    fn the_ceilings_bind() {
328        let repo = Repo::new();
329        // A file over the size ceiling holds the needle and must be skipped.
330        let big = {
331            let mut body = vec![b'x'; 64];
332            body.extend_from_slice(b"\nneedle\n");
333            repo.blob(&body)
334        };
335        let small = repo.blob(b"needle\nneedle\nneedle\n");
336        let root = repo.tree(&[
337            (TREE_MODE_BLOB, "big.txt", big),
338            (TREE_MODE_BLOB, "small.txt", small),
339        ]);
340        let commit = repo.commit(root, &[], "Ada", "seed");
341
342        let tight = Limits {
343            max_file_bytes: 8,
344            max_matches: DEFAULT_MAX_MATCHES,
345        };
346        let hits =
347            search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", tight).expect("search");
348        assert!(
349            hits.iter().all(|m| m.path == "small.txt"),
350            "the over-ceiling big.txt must be skipped, got {hits:?}"
351        );
352
353        // The match ceiling stops the walk at two, though three lines match.
354        let capped = Limits {
355            max_file_bytes: DEFAULT_MAX_FILE_BYTES,
356            max_matches: 2,
357        };
358        let two =
359            search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", capped).expect("search");
360        assert_eq!(two.len(), 2, "the match ceiling caps the result at two");
361    }
362}