fabrica

hanna/fabrica

feat(web): in-repo and global search over a linear scan

b400321 · hanna committed on 2026-07-25

Add search (§10) with a hand-written qualifier parser (repo:, user:,
path:, lang:, case:, regex:) plus bare literal terms, matched with
ripgrep's grep engine.

- In-repo (/-/search): walks the tree at the default branch, skips binary
  and >1 MiB files, and matches content (with lang/path filters), linking
  hits into the blob view at #L{n}.
- Global (/search): matches repo/user names and descriptions first, then
  runs the content scan across every repo the viewer can read, bounded by
  search.concurrency (a semaphore), search.timeout_secs (a deadline), and
  search.max_results — returning partial results with an explicit notice
  rather than truncating silently.

Config gains the [search] section; git gains Repo::file_paths. Verified
end-to-end: content, lang filter, and global name+content matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
11 files changed · +751 −7UnifiedSplit
Cargo.lock +117 −0
@@ -549,6 +549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
549549 checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
550550 dependencies = [
551551 "memchr",
552+ "regex-automata",
552553 "serde_core",
553554 ]
554555
@@ -1578,6 +1579,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
15781579 checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
15791580
15801581 [[package]]
1582+name = "encoding_rs"
1583+version = "0.8.35"
1584+source = "registry+https://github.com/rust-lang/crates.io-index"
1585+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
1586+dependencies = [
1587+ "cfg-if",
1588+]
1589+
1590+[[package]]
1591+name = "encoding_rs_io"
1592+version = "0.1.7"
1593+source = "registry+https://github.com/rust-lang/crates.io-index"
1594+checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83"
1595+dependencies = [
1596+ "encoding_rs",
1597+]
1598+
1599+[[package]]
15811600 name = "entities"
15821601 version = "1.0.1"
15831602 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2003,6 +2022,85 @@ dependencies = [
20032022 ]
20042023
20052024 [[package]]
2025+name = "grep"
2026+version = "0.3.2"
2027+source = "registry+https://github.com/rust-lang/crates.io-index"
2028+checksum = "308ae749734e28d749a86f33212c7b756748568ce332f970ac1d9cd8531f32e6"
2029+dependencies = [
2030+ "grep-cli",
2031+ "grep-matcher",
2032+ "grep-printer",
2033+ "grep-regex",
2034+ "grep-searcher",
2035+]
2036+
2037+[[package]]
2038+name = "grep-cli"
2039+version = "0.1.12"
2040+source = "registry+https://github.com/rust-lang/crates.io-index"
2041+checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631"
2042+dependencies = [
2043+ "bstr",
2044+ "globset",
2045+ "libc",
2046+ "log",
2047+ "termcolor",
2048+ "winapi-util",
2049+]
2050+
2051+[[package]]
2052+name = "grep-matcher"
2053+version = "0.1.9"
2054+source = "registry+https://github.com/rust-lang/crates.io-index"
2055+checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847"
2056+dependencies = [
2057+ "memchr",
2058+]
2059+
2060+[[package]]
2061+name = "grep-printer"
2062+version = "0.2.2"
2063+source = "registry+https://github.com/rust-lang/crates.io-index"
2064+checksum = "c112110ae4a891aa4d83ab82ecf734b307497d066f437686175e83fbd4e013fe"
2065+dependencies = [
2066+ "bstr",
2067+ "grep-matcher",
2068+ "grep-searcher",
2069+ "log",
2070+ "serde",
2071+ "serde_json",
2072+ "termcolor",
2073+]
2074+
2075+[[package]]
2076+name = "grep-regex"
2077+version = "0.1.14"
2078+source = "registry+https://github.com/rust-lang/crates.io-index"
2079+checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef"
2080+dependencies = [
2081+ "bstr",
2082+ "grep-matcher",
2083+ "log",
2084+ "regex-automata",
2085+ "regex-syntax",
2086+]
2087+
2088+[[package]]
2089+name = "grep-searcher"
2090+version = "0.1.17"
2091+source = "registry+https://github.com/rust-lang/crates.io-index"
2092+checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6"
2093+dependencies = [
2094+ "bstr",
2095+ "encoding_rs",
2096+ "encoding_rs_io",
2097+ "grep-matcher",
2098+ "log",
2099+ "memchr",
2100+ "memmap2",
2101+]
2102+
2103+[[package]]
20062104 name = "group"
20072105 version = "0.13.0"
20082106 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2739,6 +2837,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
27392837 checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
27402838
27412839 [[package]]
2840+name = "memmap2"
2841+version = "0.9.11"
2842+source = "registry+https://github.com/rust-lang/crates.io-index"
2843+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
2844+dependencies = [
2845+ "libc",
2846+]
2847+
2848+[[package]]
27422849 name = "mime"
27432850 version = "0.3.17"
27442851 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4839,6 +4946,15 @@ dependencies = [
48394946 ]
48404947
48414948 [[package]]
4949+name = "termcolor"
4950+version = "1.4.1"
4951+source = "registry+https://github.com/rust-lang/crates.io-index"
4952+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
4953+dependencies = [
4954+ "winapi-util",
4955+]
4956+
4957+[[package]]
48424958 name = "thiserror"
48434959 version = "1.0.69"
48444960 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5462,6 +5578,7 @@ dependencies = [
54625578 "config",
54635579 "flate2",
54645580 "git",
5581+ "grep",
54655582 "highlight",
54665583 "mail",
54675584 "maud",
Cargo.toml +2 −0
@@ -151,6 +151,8 @@ ammonia = "4"
151151 # gzip-encoded upload-pack requests.
152152 tokio-util = { version = "0.7", features = ["io"] }
153153 flate2 = "1"
154+# Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate.
155+grep = "0.3"
154156 # SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key).
155157 russh = "0.62"
156158 tracing = "0.1"
crates/config/src/lib.rs +1 −1
@@ -33,7 +33,7 @@ use figment::providers::{Env, Format, Serialized, Toml};
3333 pub use crate::secret::{REDACTED, Secret};
3434 pub use crate::types::{
3535 Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend,
36- MailEncryption, Server, Ssh, Storage, Ui,
36+ MailEncryption, Search, Server, Ssh, Storage, Ui,
3737 };
3838
3939 /// System-wide configuration path, the lowest-priority file source.
crates/config/src/types.rs +24 −0
@@ -36,10 +36,34 @@ pub struct Config {
3636 pub ui: Ui,
3737 /// Git binary and pack-transport limits.
3838 pub git: Git,
39+ /// Search bounds.
40+ pub search: Search,
3941 /// Logging.
4042 pub log: Log,
4143 }
4244
45+/// `[search]` — bounds on the linear (index-free) search.
46+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47+#[serde(deny_unknown_fields)]
48+pub struct Search {
49+ /// Maximum repositories searched concurrently in a global search.
50+ pub concurrency: usize,
51+ /// Total wall-clock budget for a global search, in seconds.
52+ pub timeout_secs: u64,
53+ /// Maximum results returned before truncating (with a notice).
54+ pub max_results: usize,
55+}
56+
57+impl Default for Search {
58+ fn default() -> Self {
59+ Self {
60+ concurrency: 4,
61+ timeout_secs: 10,
62+ max_results: 200,
63+ }
64+ }
65+}
66+
4367 /// `[instance]` — identity and global policy.
4468 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4569 #[serde(deny_unknown_fields)]
crates/git/src/repo.rs +24 −0
@@ -456,6 +456,30 @@ impl Repo {
456456 Ok(result)
457457 }
458458
459+ /// Every regular-file path in the tree of revision `rev`, for search walks.
460+ /// Directories, submodules, and symlinks are excluded.
461+ ///
462+ /// # Errors
463+ ///
464+ /// Returns [`GitError`] if the revision or its tree cannot be read.
465+ pub fn file_paths(&self, rev: &Resolved) -> Result<Vec<String>, GitError> {
466+ let commit = self.commit_at(&rev.oid)?;
467+ let tree = commit.tree()?;
468+ let mut paths = Vec::new();
469+ tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
470+ if entry.filemode() != i32::from(git2::FileMode::Tree)
471+ && entry.filemode() != i32::from(git2::FileMode::Commit)
472+ && entry.filemode() != i32::from(git2::FileMode::Link)
473+ {
474+ if let Some(name) = entry.name() {
475+ paths.push(format!("{root}{name}"));
476+ }
477+ }
478+ git2::TreeWalkResult::Ok
479+ })?;
480+ Ok(paths)
481+ }
482+
459483 /// The tree at `path` within a commit, or `None` if the path is absent or is
460484 /// not a directory. An empty `path` yields the root tree.
461485 fn subtree_at<'a>(
crates/web/Cargo.toml +1 −0
@@ -28,6 +28,7 @@ comrak = { workspace = true }
2828 ammonia = { workspace = true }
2929 tokio-util = { workspace = true }
3030 flate2 = { workspace = true }
31+grep = { workspace = true }
3132 base64 = { workspace = true }
3233 time = { workspace = true }
3334 serde = { workspace = true }
crates/web/src/lib.rs +2 −0
@@ -18,6 +18,7 @@ mod layout;
1818 mod markdown;
1919 mod pages;
2020 mod repo;
21+mod search;
2122 #[path = "serve.rs"]
2223 mod serve_assets;
2324 mod session;
@@ -111,6 +112,7 @@ pub fn build_router(state: AppState) -> Router {
111112 "/invite/{token}",
112113 get(pages::invite_form).post(pages::invite_submit),
113114 )
115+ .route("/search", get(search::global))
114116 .route("/settings", get(pages::settings))
115117 .route("/settings/theme", get(pages::set_theme))
116118 .route("/healthz", get(serve_assets::healthz))
crates/web/src/repo.rs +14 −6
@@ -45,6 +45,9 @@ pub struct DiffQuery {
4545 to: Option<u32>,
4646 /// The smart-HTTP service, for `…​.git/info/refs?service=`.
4747 service: Option<String>,
48+ /// The search query, for the in-repo `/-/search` view.
49+ #[serde(default)]
50+ q: Option<String>,
4851 }
4952
5053 /// The candidate README filenames, in lookup order.
@@ -60,18 +63,18 @@ enum Tab {
6063 }
6164
6265 /// A resolved repository plus the viewer's access to it.
63-struct RepoCtx {
64- owner: User,
65- repo: Repo,
66+pub(crate) struct RepoCtx {
67+ pub(crate) owner: User,
68+ pub(crate) repo: Repo,
6669 /// The viewer's access level. Reserved for gating mutation UI (settings, edit)
6770 /// in later phases; every browse view here requires only `Read`.
6871 #[allow(dead_code)]
69- access: auth::Access,
72+ pub(crate) access: auth::Access,
7073 }
7174
7275 /// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo
7376 /// per operation (§3.7 threading).
74-async fn git_read<T, F>(state: &AppState, repo_id: &str, f: F) -> AppResult<T>
77+pub(crate) async fn git_read<T, F>(state: &AppState, repo_id: &str, f: F) -> AppResult<T>
7578 where
7679 F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static,
7780 T: Send + 'static,
@@ -233,7 +236,12 @@ async fn dispatch_view(
233236 },
234237 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
235238 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
236- // Reserved `runs`/`search`/`settings` slots return 404 until built.
239+ "search" => {
240+ let header = repo_header(state, &ctx, Tab::Search, &ctx.repo.default_branch);
241+ let raw_query = dq.q.clone().unwrap_or_default();
242+ crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
243+ }
244+ // Reserved `runs`/`settings` slots return 404 until built.
237245 _ => Err(AppError::NotFound),
238246 }
239247 }
crates/web/src/search.rs +542 −0
@@ -0,0 +1,542 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Search: in-repo and global, over a linear (index-free) scan (§10).
6+//!
7+//! Queries carry a tiny hand-written qualifier syntax (`repo:`, `user:`, `path:`,
8+//! `lang:`, `case:`, `regex:`) plus bare literal terms. Content matching uses
9+//! ripgrep's engine. Global search is bounded by a worker-pool size, a total time
10+//! budget, and a result cap; exhausting the budget returns partial results with an
11+//! explicit notice rather than truncating silently.
12+
13+use std::path::Path as FsPath;
14+use std::sync::Arc;
15+use std::time::{Duration, Instant};
16+
17+use axum::extract::{Query, State};
18+use axum::http::Uri;
19+use axum::response::{IntoResponse, Response};
20+use axum_extra::extract::cookie::CookieJar;
21+use grep::regex::RegexMatcherBuilder;
22+use grep::searcher::Searcher;
23+use grep::searcher::sinks::UTF8;
24+use maud::{Markup, html};
25+use model::{Repo, User};
26+use serde::Deserialize;
27+use tokio::sync::Semaphore;
28+use tokio::task::JoinSet;
29+
30+use crate::AppState;
31+use crate::error::AppResult;
32+use crate::layout::page;
33+use crate::pages::build_chrome;
34+use crate::session::MaybeUser;
35+
36+/// Max matching lines kept per file, and max files per repo, to bound one repo's
37+/// contribution.
38+const LINES_PER_FILE: usize = 10;
39+const FILES_PER_REPO: usize = 50;
40+/// Files larger than this are skipped by the content scan.
41+const MAX_FILE_BYTES: u64 = 1_048_576;
42+
43+/// A parsed query: the free text plus any qualifiers.
44+#[derive(Debug, Default, Clone, PartialEq, Eq)]
45+pub struct ParsedQuery {
46+ /// The literal or regex text to match.
47+ pub text: String,
48+ /// `repo:owner/path`.
49+ pub repo: Option<String>,
50+ /// `user:name`.
51+ pub user: Option<String>,
52+ /// `path:glob` (matched as a substring for the MVP).
53+ pub path: Option<String>,
54+ /// `lang:name`.
55+ pub lang: Option<String>,
56+ /// `case:yes` — case-sensitive matching.
57+ pub case_sensitive: bool,
58+ /// `regex:yes` — treat `text` as a regex rather than a literal.
59+ pub regex: bool,
60+}
61+
62+/// Parse a raw query string into qualifiers and free text.
63+#[must_use]
64+pub fn parse(raw: &str) -> ParsedQuery {
65+ let mut query = ParsedQuery::default();
66+ let mut terms: Vec<&str> = Vec::new();
67+ for token in raw.split_whitespace() {
68+ match token.split_once(':') {
69+ Some(("repo", v)) => query.repo = Some(v.to_string()),
70+ Some(("user", v)) => query.user = Some(v.to_string()),
71+ Some(("path", v)) => query.path = Some(v.to_string()),
72+ Some(("lang", v)) => query.lang = Some(v.to_string()),
73+ Some(("case", v)) => query.case_sensitive = is_yes(v),
74+ Some(("regex", v)) => query.regex = is_yes(v),
75+ _ => terms.push(token),
76+ }
77+ }
78+ query.text = terms.join(" ");
79+ query
80+}
81+
82+/// Whether a qualifier value means "yes".
83+fn is_yes(v: &str) -> bool {
84+ matches!(v.to_ascii_lowercase().as_str(), "yes" | "true" | "1" | "on")
85+}
86+
87+/// One matching line within a file.
88+#[derive(Debug, Clone)]
89+pub struct LineMatch {
90+ /// 1-based line number.
91+ pub line: u64,
92+ /// The (trimmed) line text.
93+ pub text: String,
94+}
95+
96+/// A file's matches within a repo.
97+#[derive(Debug, Clone)]
98+pub struct FileResult {
99+ /// Repo-relative path.
100+ pub path: String,
101+ /// Matching lines (may be empty for a path-only match).
102+ pub lines: Vec<LineMatch>,
103+}
104+
105+/// Search one already-open repository at `rev_name`. Returns matched files.
106+fn search_repo_blocking(repo: &git::Repo, rev_name: &str, query: &ParsedQuery) -> Vec<FileResult> {
107+ let Ok(rev) = repo.resolve_ref(rev_name) else {
108+ return Vec::new();
109+ };
110+ let Ok(paths) = repo.file_paths(&rev) else {
111+ return Vec::new();
112+ };
113+ let matcher = (!query.text.is_empty())
114+ .then(|| {
115+ RegexMatcherBuilder::new()
116+ .case_insensitive(!query.case_sensitive)
117+ .fixed_strings(!query.regex)
118+ .build(&query.text)
119+ .ok()
120+ })
121+ .flatten();
122+
123+ let mut searcher = Searcher::new();
124+ let mut results = Vec::new();
125+ for path in paths {
126+ if results.len() >= FILES_PER_REPO {
127+ break;
128+ }
129+ if let Some(filter) = &query.path
130+ && !path.to_lowercase().contains(&filter.to_lowercase())
131+ {
132+ continue;
133+ }
134+ let Ok(blob) = repo.blob(&rev, FsPath::new(&path)) else {
135+ continue;
136+ };
137+ if blob.is_binary || blob.size > MAX_FILE_BYTES {
138+ continue;
139+ }
140+ if let Some(lang) = &query.lang {
141+ let resolved = highlight::resolve(FsPath::new(&path), &blob.content, None);
142+ if resolved.name().map(str::to_ascii_lowercase) != Some(lang.to_ascii_lowercase()) {
143+ continue;
144+ }
145+ }
146+
147+ let mut lines = Vec::new();
148+ if let Some(matcher) = &matcher {
149+ let _ = searcher.search_slice(
150+ matcher,
151+ &blob.content,
152+ UTF8(|lnum, line| {
153+ if lines.len() < LINES_PER_FILE {
154+ lines.push(LineMatch {
155+ line: lnum,
156+ text: line.trim_end().to_string(),
157+ });
158+ }
159+ Ok(true)
160+ }),
161+ );
162+ if !lines.is_empty() {
163+ results.push(FileResult { path, lines });
164+ }
165+ } else if query.path.is_some() || query.lang.is_some() {
166+ // No text term but a path/lang filter matched: report the file itself.
167+ results.push(FileResult { path, lines });
168+ }
169+ }
170+ results
171+}
172+
173+/// Query parameters for the global search route.
174+#[derive(Debug, Default, Deserialize)]
175+pub struct SearchParams {
176+ /// The raw query.
177+ #[serde(default)]
178+ q: String,
179+}
180+
181+/// `GET /search` — global search across visible repos.
182+pub async fn global(
183+ State(state): State<AppState>,
184+ MaybeUser(viewer): MaybeUser,
185+ jar: CookieJar,
186+ uri: Uri,
187+ Query(params): Query<SearchParams>,
188+) -> AppResult<Response> {
189+ let query = parse(&params.q);
190+ let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
191+
192+ let body = if params.q.trim().is_empty() {
193+ html! { h1 { "Search" } (search_box(&params.q, None)) }
194+ } else {
195+ let entities = entity_matches(&state, &query, viewer.as_ref()).await?;
196+ let (content, timed_out, truncated) =
197+ content_matches(&state, &query, viewer.as_ref()).await?;
198+ html! {
199+ h1 { "Search" }
200+ (search_box(&params.q, None))
201+ @if timed_out {
202+ div class="notice notice-error" { "Search timed out; showing partial results." }
203+ } @else if truncated {
204+ div class="notice" { "Showing the first results; refine your query for more." }
205+ }
206+ (entity_section(&entities))
207+ (content_section(&content))
208+ @if entities.is_empty() && content.is_empty() {
209+ p class="muted" { "No results." }
210+ }
211+ }
212+ };
213+ Ok((jar, page(&chrome, "Search", body)).into_response())
214+}
215+
216+/// A repo/user/group name-or-description match.
217+struct EntityMatch {
218+ kind: &'static str,
219+ label: String,
220+ href: String,
221+}
222+
223+/// Match repo/user names and descriptions (the "first" pass of global search).
224+async fn entity_matches(
225+ state: &AppState,
226+ query: &ParsedQuery,
227+ viewer: Option<&User>,
228+) -> AppResult<Vec<EntityMatch>> {
229+ let needle = query.text.to_lowercase();
230+ if needle.is_empty() {
231+ return Ok(Vec::new());
232+ }
233+ let mut out = Vec::new();
234+
235+ for user in state.store.list_users().await? {
236+ if user.username.to_lowercase().contains(&needle) {
237+ out.push(EntityMatch {
238+ kind: "user",
239+ label: user.username.clone(),
240+ href: format!("/{}", user.username),
241+ });
242+ }
243+ }
244+ for repo in visible_repos(state, viewer).await? {
245+ let owner = owner_name(state, &repo.owner_id).await;
246+ let hay = format!(
247+ "{} {} {}",
248+ repo.path,
249+ repo.name,
250+ repo.description.clone().unwrap_or_default()
251+ )
252+ .to_lowercase();
253+ if hay.contains(&needle) {
254+ out.push(EntityMatch {
255+ kind: "repo",
256+ label: format!("{owner}/{}", repo.path),
257+ href: format!("/{owner}/{}", repo.path),
258+ });
259+ }
260+ }
261+ Ok(out)
262+}
263+
264+/// Run the bounded content search across visible repos. Returns matches plus
265+/// whether the time budget or result cap was hit.
266+async fn content_matches(
267+ state: &AppState,
268+ query: &ParsedQuery,
269+ viewer: Option<&User>,
270+) -> AppResult<(Vec<(String, Vec<FileResult>)>, bool, bool)> {
271+ let mut repos = visible_repos(state, viewer).await?;
272+ // Honour a repo: qualifier by filtering the candidate set.
273+ if let Some(repo_q) = &query.repo {
274+ let want = repo_q.to_lowercase();
275+ // Resolve owner names to filter by "owner/path".
276+ let mut kept = Vec::new();
277+ for repo in repos {
278+ let owner = owner_name(state, &repo.owner_id).await;
279+ if format!("{owner}/{}", repo.path).to_lowercase() == want {
280+ kept.push(repo);
281+ }
282+ }
283+ repos = kept;
284+ }
285+
286+ let deadline = Instant::now() + Duration::from_secs(state.config.search.timeout_secs.max(1));
287+ let semaphore = Arc::new(Semaphore::new(state.config.search.concurrency.max(1)));
288+ let mut set: JoinSet<Option<(String, Vec<FileResult>)>> = JoinSet::new();
289+
290+ for repo in repos {
291+ let state = state.clone();
292+ let query = query.clone();
293+ let semaphore = Arc::clone(&semaphore);
294+ set.spawn(async move {
295+ let _permit = semaphore.acquire().await.ok()?;
296+ let owner = owner_name(&state, &repo.owner_id).await;
297+ let rev = repo.default_branch.clone();
298+ let files = crate::repo::git_read(&state, &repo.id, move |repo| {
299+ Ok(search_repo_blocking(repo, &rev, &query))
300+ })
301+ .await
302+ .ok()?;
303+ (!files.is_empty()).then(|| (format!("{owner}/{}", repo.path), files))
304+ });
305+ }
306+
307+ let cap = state.config.search.max_results;
308+ let mut results = Vec::new();
309+ let mut total = 0usize;
310+ let mut timed_out = false;
311+ let mut truncated = false;
312+
313+ loop {
314+ let remaining = deadline.saturating_duration_since(Instant::now());
315+ if remaining.is_zero() {
316+ timed_out = true;
317+ break;
318+ }
319+ match tokio::time::timeout(remaining, set.join_next()).await {
320+ Ok(Some(Ok(Some(entry)))) => {
321+ total += entry.1.iter().map(|f| f.lines.len().max(1)).sum::<usize>();
322+ results.push(entry);
323+ if total >= cap {
324+ truncated = true;
325+ break;
326+ }
327+ }
328+ Ok(Some(_)) => {} // a repo errored or had no matches
329+ Ok(None) => break, // all done
330+ Err(_) => {
331+ timed_out = true;
332+ break;
333+ }
334+ }
335+ }
336+ set.abort_all();
337+ Ok((results, timed_out, truncated))
338+}
339+
340+/// `search` view for a single repo (called from the repo dispatcher).
341+pub async fn in_repo(
342+ state: &AppState,
343+ viewer: Option<User>,
344+ jar: CookieJar,
345+ uri: &Uri,
346+ ctx: &crate::repo::RepoCtx,
347+ raw_query: &str,
348+ header: Markup,
349+) -> AppResult<Response> {
350+ let owner = &ctx.owner.username;
351+ let repo = &ctx.repo;
352+ let default_branch = &ctx.repo.default_branch;
353+ let query = parse(raw_query);
354+ let rev = default_branch.clone();
355+
356+ let files = if raw_query.trim().is_empty() {
357+ Vec::new()
358+ } else {
359+ let query = query.clone();
360+ let rev = rev.clone();
361+ crate::repo::git_read(state, &repo.id, move |repo| {
362+ Ok(search_repo_blocking(repo, &rev, &query))
363+ })
364+ .await?
365+ };
366+
367+ let base = format!("/{owner}/{}", repo.path);
368+ let body = html! {
369+ (header)
370+ h2 { "Search this repository" }
371+ (search_box(raw_query, Some(&format!("{base}/-/search"))))
372+ @if raw_query.trim().is_empty() {
373+ p class="muted" { "Enter a query to search the code." }
374+ } @else if files.is_empty() {
375+ p class="muted" { "No matches." }
376+ } @else {
377+ (repo_content(owner, &repo.path, default_branch, &files))
378+ }
379+ };
380+
381+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
382+ let title = format!("{owner}/{}: search", repo.path);
383+ Ok((jar, page(&chrome, &title, body)).into_response())
384+}
385+
386+// ---- components ----
387+
388+/// The search input form.
389+fn search_box(value: &str, action: Option<&str>) -> Markup {
390+ html! {
391+ form method="get" action=[action] {
392+ input type="search" name="q" value=(value) placeholder="Search…"
393+ data-search-input aria-label="Search query" autofocus;
394+ button class="btn" type="submit" { "Search" }
395+ }
396+ }
397+}
398+
399+/// Render entity (name) matches.
400+fn entity_section(entities: &[EntityMatch]) -> Markup {
401+ html! {
402+ @if !entities.is_empty() {
403+ section {
404+ h2 { "Repositories & users" }
405+ ul class="repo-list" {
406+ @for entity in entities {
407+ li {
408+ span class="badge" { (entity.kind) }
409+ " " a href=(entity.href) { (entity.label) }
410+ }
411+ }
412+ }
413+ }
414+ }
415+ }
416+}
417+
418+/// Render global content matches, grouped by repo then file.
419+fn content_section(content: &[(String, Vec<FileResult>)]) -> Markup {
420+ html! {
421+ @if !content.is_empty() {
422+ section {
423+ h2 { "Code" }
424+ @for (repo, files) in content {
425+ h3 { (repo) }
426+ @for file in files {
427+ (file_result(&format!("/{repo}"), None, file))
428+ }
429+ }
430+ }
431+ }
432+ }
433+}
434+
435+/// Render in-repo content matches with links into the blob view.
436+fn repo_content(owner: &str, repo_path: &str, rev: &str, files: &[FileResult]) -> Markup {
437+ let base = format!("/{owner}/{repo_path}/-/blob/{rev}");
438+ html! {
439+ @for file in files {
440+ (file_result(&base, Some(rev), file))
441+ }
442+ }
443+}
444+
445+/// One file's matches. `blob_base` links each line to the blob (with `#L{n}`).
446+fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup {
447+ html! {
448+ div class="card" style="margin-bottom:0.5rem" {
449+ div class="mono" {
450+ @if in_repo.is_some() {
451+ a href=(format!("{blob_base}/{}", file.path)) { (file.path) }
452+ } @else {
453+ (file.path)
454+ }
455+ }
456+ @for m in &file.lines {
457+ div class="mono" style="font-size:0.85em" {
458+ @if in_repo.is_some() {
459+ a href=(format!("{blob_base}/{}#L{}", file.path, m.line))
460+ class="muted" { (m.line) }
461+ } @else {
462+ span class="muted" { (m.line) }
463+ }
464+ " " (m.text)
465+ }
466+ }
467+ }
468+ }
469+}
470+
471+// ---- shared repo visibility ----
472+
473+/// Every repo the viewer may read (across owners), for global search.
474+async fn visible_repos(state: &AppState, viewer: Option<&User>) -> AppResult<Vec<Repo>> {
475+ let all = state.store.list_repos().await?;
476+ let mut out = Vec::new();
477+ for repo in all {
478+ if can_read(state, &repo, viewer).await? {
479+ out.push(repo);
480+ }
481+ }
482+ Ok(out)
483+}
484+
485+/// Whether the viewer can read `repo`.
486+async fn can_read(state: &AppState, repo: &Repo, viewer: Option<&User>) -> AppResult<bool> {
487+ let viewer_id = viewer.map(|u| auth::Viewer {
488+ id: u.id.clone(),
489+ is_admin: u.is_admin,
490+ });
491+ let collaborator = match viewer {
492+ Some(u) => state
493+ .store
494+ .collaborator_permission(&repo.id, &u.id)
495+ .await?
496+ .and_then(|p| auth::Permission::parse(&p)),
497+ None => None,
498+ };
499+ let access = auth::access(
500+ viewer_id.as_ref(),
501+ repo,
502+ collaborator,
503+ state.config.instance.allow_anonymous,
504+ );
505+ Ok(access >= auth::Access::Read)
506+}
507+
508+/// Resolve an owner id to a username, falling back to the id.
509+async fn owner_name(state: &AppState, owner_id: &str) -> String {
510+ state
511+ .store
512+ .user_by_id(owner_id)
513+ .await
514+ .ok()
515+ .flatten()
516+ .map_or_else(|| owner_id.to_string(), |u| u.username)
517+}
518+
519+#[cfg(test)]
520+mod tests {
521+ use super::*;
522+
523+ #[test]
524+ fn parses_qualifiers_and_free_text() {
525+ let q = parse("foo bar repo:ada/api lang:rust case:yes regex:no path:src");
526+ assert_eq!(q.text, "foo bar");
527+ assert_eq!(q.repo.as_deref(), Some("ada/api"));
528+ assert_eq!(q.lang.as_deref(), Some("rust"));
529+ assert_eq!(q.path.as_deref(), Some("src"));
530+ assert!(q.case_sensitive);
531+ assert!(!q.regex);
532+ }
533+
534+ #[test]
535+ fn bare_query_has_no_qualifiers() {
536+ let q = parse("hello world");
537+ assert_eq!(q.text, "hello world");
538+ assert!(q.repo.is_none());
539+ assert!(!q.case_sensitive);
540+ assert!(!q.regex);
541+ }
542+}
docs/decisions.md +19 −0
@@ -65,6 +65,25 @@ file. There is no `server start` / `server stop` pair.
6565 **Rationale:** Foreground is correct for systemd and Docker, which own process
6666 lifecycle. This deviates from an earlier `server start`/`server stop` sketch.
6767
68+## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP
69+
70+**Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan)
71+is implemented in `crates/web`, not a separate crate, using ripgrep's `grep`
72+engine. The `path:` qualifier matches as a case-insensitive **substring**, not a
73+full glob.
74+
75+**Alternatives:** A dedicated `search` crate; full glob path matching via
76+`globset`.
77+
78+**Rationale:** Search is a web-only feature with no other consumer, so a crate of
79+its own adds indirection for no reuse; the content scan runs inside the existing
80+`git_read` blocking closure. The global scan bounds itself with a
81+`Semaphore(search.concurrency)` and a `tokio::time::timeout(search.timeout_secs)`
82+deadline, returning partial results with an explicit notice on either the time or
83+`search.max_results` cap. A substring `path:` covers the common case; upgrading to
84+`globset` is a contained change if needed. `linguist-vendored`/`-generated`
85+exclusion is deferred (binary and >1 MiB files are already skipped).
86+
6887 ## 2026-07-24 — Linear search, no index
6988
7089 **Decision:** Search (in-repo and global) is a linear scan over repo contents at
fabrica.example.toml +5 −0
@@ -85,6 +85,11 @@ binary = "git" # must be >= 2.41 and on PATH
8585 transport_timeout_secs = 3600
8686 max_pack_size_bytes = 536870912 # reject pushes with a larger received pack
8787
88+[search]
89+concurrency = 4 # repos searched at once in a global search
90+timeout_secs = 10 # total budget; partial results are returned past it
91+max_results = 200 # cap before truncating with a notice
92+
8893 [log]
8994 level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured)
9095 format = "pretty" # pretty | json