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"
549checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"549checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
550dependencies = [550dependencies = [
551 "memchr",551 "memchr",
552 "regex-automata",
552 "serde_core",553 "serde_core",
553]554]
554555
@@ -1578,6 +1579,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1578checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"1579checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
15791580
1580[[package]]1581[[package]]
1582name = "encoding_rs"
1583version = "0.8.35"
1584source = "registry+https://github.com/rust-lang/crates.io-index"
1585checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
1586dependencies = [
1587 "cfg-if",
1588]
1589
1590[[package]]
1591name = "encoding_rs_io"
1592version = "0.1.7"
1593source = "registry+https://github.com/rust-lang/crates.io-index"
1594checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83"
1595dependencies = [
1596 "encoding_rs",
1597]
1598
1599[[package]]
1581name = "entities"1600name = "entities"
1582version = "1.0.1"1601version = "1.0.1"
1583source = "registry+https://github.com/rust-lang/crates.io-index"1602source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2003,6 +2022,85 @@ dependencies = [
2003]2022]
20042023
2005[[package]]2024[[package]]
2025name = "grep"
2026version = "0.3.2"
2027source = "registry+https://github.com/rust-lang/crates.io-index"
2028checksum = "308ae749734e28d749a86f33212c7b756748568ce332f970ac1d9cd8531f32e6"
2029dependencies = [
2030 "grep-cli",
2031 "grep-matcher",
2032 "grep-printer",
2033 "grep-regex",
2034 "grep-searcher",
2035]
2036
2037[[package]]
2038name = "grep-cli"
2039version = "0.1.12"
2040source = "registry+https://github.com/rust-lang/crates.io-index"
2041checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631"
2042dependencies = [
2043 "bstr",
2044 "globset",
2045 "libc",
2046 "log",
2047 "termcolor",
2048 "winapi-util",
2049]
2050
2051[[package]]
2052name = "grep-matcher"
2053version = "0.1.9"
2054source = "registry+https://github.com/rust-lang/crates.io-index"
2055checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847"
2056dependencies = [
2057 "memchr",
2058]
2059
2060[[package]]
2061name = "grep-printer"
2062version = "0.2.2"
2063source = "registry+https://github.com/rust-lang/crates.io-index"
2064checksum = "c112110ae4a891aa4d83ab82ecf734b307497d066f437686175e83fbd4e013fe"
2065dependencies = [
2066 "bstr",
2067 "grep-matcher",
2068 "grep-searcher",
2069 "log",
2070 "serde",
2071 "serde_json",
2072 "termcolor",
2073]
2074
2075[[package]]
2076name = "grep-regex"
2077version = "0.1.14"
2078source = "registry+https://github.com/rust-lang/crates.io-index"
2079checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef"
2080dependencies = [
2081 "bstr",
2082 "grep-matcher",
2083 "log",
2084 "regex-automata",
2085 "regex-syntax",
2086]
2087
2088[[package]]
2089name = "grep-searcher"
2090version = "0.1.17"
2091source = "registry+https://github.com/rust-lang/crates.io-index"
2092checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6"
2093dependencies = [
2094 "bstr",
2095 "encoding_rs",
2096 "encoding_rs_io",
2097 "grep-matcher",
2098 "log",
2099 "memchr",
2100 "memmap2",
2101]
2102
2103[[package]]
2006name = "group"2104name = "group"
2007version = "0.13.0"2105version = "0.13.0"
2008source = "registry+https://github.com/rust-lang/crates.io-index"2106source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2739,6 +2837,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2739checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"2837checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
27402838
2741[[package]]2839[[package]]
2840name = "memmap2"
2841version = "0.9.11"
2842source = "registry+https://github.com/rust-lang/crates.io-index"
2843checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
2844dependencies = [
2845 "libc",
2846]
2847
2848[[package]]
2742name = "mime"2849name = "mime"
2743version = "0.3.17"2850version = "0.3.17"
2744source = "registry+https://github.com/rust-lang/crates.io-index"2851source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4839,6 +4946,15 @@ dependencies = [
4839]4946]
48404947
4841[[package]]4948[[package]]
4949name = "termcolor"
4950version = "1.4.1"
4951source = "registry+https://github.com/rust-lang/crates.io-index"
4952checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
4953dependencies = [
4954 "winapi-util",
4955]
4956
4957[[package]]
4842name = "thiserror"4958name = "thiserror"
4843version = "1.0.69"4959version = "1.0.69"
4844source = "registry+https://github.com/rust-lang/crates.io-index"4960source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5462,6 +5578,7 @@ dependencies = [
5462 "config",5578 "config",
5463 "flate2",5579 "flate2",
5464 "git",5580 "git",
5581 "grep",
5465 "highlight",5582 "highlight",
5466 "mail",5583 "mail",
5467 "maud",5584 "maud",
Cargo.toml +2 −0
@@ -151,6 +151,8 @@ ammonia = "4"
151# gzip-encoded upload-pack requests.151# gzip-encoded upload-pack requests.
152tokio-util = { version = "0.7", features = ["io"] }152tokio-util = { version = "0.7", features = ["io"] }
153flate2 = "1"153flate2 = "1"
154# Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate.
155grep = "0.3"
154# SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key).156# SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key).
155russh = "0.62"157russh = "0.62"
156tracing = "0.1"158tracing = "0.1"
crates/config/src/lib.rs +1 −1
@@ -33,7 +33,7 @@ use figment::providers::{Env, Format, Serialized, Toml};
33pub use crate::secret::{REDACTED, Secret};33pub use crate::secret::{REDACTED, Secret};
34pub use crate::types::{34pub use crate::types::{
35 Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend,35 Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend,
36 MailEncryption, Server, Ssh, Storage, Ui,36 MailEncryption, Search, Server, Ssh, Storage, Ui,
37};37};
3838
39/// System-wide configuration path, the lowest-priority file source.39/// System-wide configuration path, the lowest-priority file source.
crates/config/src/types.rs +24 −0
@@ -36,10 +36,34 @@ pub struct Config {
36 pub ui: Ui,36 pub ui: Ui,
37 /// Git binary and pack-transport limits.37 /// Git binary and pack-transport limits.
38 pub git: Git,38 pub git: Git,
39 /// Search bounds.
40 pub search: Search,
39 /// Logging.41 /// Logging.
40 pub log: Log,42 pub log: Log,
41}43}
4244
45/// `[search]` — bounds on the linear (index-free) search.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub 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
57impl Default for Search {
58 fn default() -> Self {
59 Self {
60 concurrency: 4,
61 timeout_secs: 10,
62 max_results: 200,
63 }
64 }
65}
66
43/// `[instance]` — identity and global policy.67/// `[instance]` — identity and global policy.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]69#[serde(deny_unknown_fields)]
crates/git/src/repo.rs +24 −0
@@ -456,6 +456,30 @@ impl Repo {
456 Ok(result)456 Ok(result)
457 }457 }
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
459 /// The tree at `path` within a commit, or `None` if the path is absent or is483 /// The tree at `path` within a commit, or `None` if the path is absent or is
460 /// not a directory. An empty `path` yields the root tree.484 /// not a directory. An empty `path` yields the root tree.
461 fn subtree_at<'a>(485 fn subtree_at<'a>(
crates/web/Cargo.toml +1 −0
@@ -28,6 +28,7 @@ comrak = { workspace = true }
28ammonia = { workspace = true }28ammonia = { workspace = true }
29tokio-util = { workspace = true }29tokio-util = { workspace = true }
30flate2 = { workspace = true }30flate2 = { workspace = true }
31grep = { workspace = true }
31base64 = { workspace = true }32base64 = { workspace = true }
32time = { workspace = true }33time = { workspace = true }
33serde = { workspace = true }34serde = { workspace = true }
crates/web/src/lib.rs +2 −0
@@ -18,6 +18,7 @@ mod layout;
18mod markdown;18mod markdown;
19mod pages;19mod pages;
20mod repo;20mod repo;
21mod search;
21#[path = "serve.rs"]22#[path = "serve.rs"]
22mod serve_assets;23mod serve_assets;
23mod session;24mod session;
@@ -111,6 +112,7 @@ pub fn build_router(state: AppState) -> Router {
111 "/invite/{token}",112 "/invite/{token}",
112 get(pages::invite_form).post(pages::invite_submit),113 get(pages::invite_form).post(pages::invite_submit),
113 )114 )
115 .route("/search", get(search::global))
114 .route("/settings", get(pages::settings))116 .route("/settings", get(pages::settings))
115 .route("/settings/theme", get(pages::set_theme))117 .route("/settings/theme", get(pages::set_theme))
116 .route("/healthz", get(serve_assets::healthz))118 .route("/healthz", get(serve_assets::healthz))
crates/web/src/repo.rs +14 −6
@@ -45,6 +45,9 @@ pub struct DiffQuery {
45 to: Option<u32>,45 to: Option<u32>,
46 /// The smart-HTTP service, for `…​.git/info/refs?service=`.46 /// The smart-HTTP service, for `…​.git/info/refs?service=`.
47 service: Option<String>,47 service: Option<String>,
48 /// The search query, for the in-repo `/-/search` view.
49 #[serde(default)]
50 q: Option<String>,
48}51}
4952
50/// The candidate README filenames, in lookup order.53/// The candidate README filenames, in lookup order.
@@ -60,18 +63,18 @@ enum Tab {
60}63}
6164
62/// A resolved repository plus the viewer's access to it.65/// A resolved repository plus the viewer's access to it.
63struct RepoCtx {66pub(crate) struct RepoCtx {
64 owner: User,67 pub(crate) owner: User,
65 repo: Repo,68 pub(crate) repo: Repo,
66 /// The viewer's access level. Reserved for gating mutation UI (settings, edit)69 /// The viewer's access level. Reserved for gating mutation UI (settings, edit)
67 /// in later phases; every browse view here requires only `Read`.70 /// in later phases; every browse view here requires only `Read`.
68 #[allow(dead_code)]71 #[allow(dead_code)]
69 access: auth::Access,72 pub(crate) access: auth::Access,
70}73}
7174
72/// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo75/// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo
73/// per operation (§3.7 threading).76/// per operation (§3.7 threading).
74async fn git_read<T, F>(state: &AppState, repo_id: &str, f: F) -> AppResult<T>77pub(crate) async fn git_read<T, F>(state: &AppState, repo_id: &str, f: F) -> AppResult<T>
75where78where
76 F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static,79 F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static,
77 T: Send + 'static,80 T: Send + 'static,
@@ -233,7 +236,12 @@ async fn dispatch_view(
233 },236 },
234 "branches" => branches_view(state, viewer, jar, uri, ctx).await,237 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
235 "tags" => tags_view(state, viewer, jar, uri, ctx).await,238 "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.
237 _ => Err(AppError::NotFound),245 _ => Err(AppError::NotFound),
238 }246 }
239}247}
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
13use std::path::Path as FsPath;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use axum::extract::{Query, State};
18use axum::http::Uri;
19use axum::response::{IntoResponse, Response};
20use axum_extra::extract::cookie::CookieJar;
21use grep::regex::RegexMatcherBuilder;
22use grep::searcher::Searcher;
23use grep::searcher::sinks::UTF8;
24use maud::{Markup, html};
25use model::{Repo, User};
26use serde::Deserialize;
27use tokio::sync::Semaphore;
28use tokio::task::JoinSet;
29
30use crate::AppState;
31use crate::error::AppResult;
32use crate::layout::page;
33use crate::pages::build_chrome;
34use crate::session::MaybeUser;
35
36/// Max matching lines kept per file, and max files per repo, to bound one repo's
37/// contribution.
38const LINES_PER_FILE: usize = 10;
39const FILES_PER_REPO: usize = 50;
40/// Files larger than this are skipped by the content scan.
41const 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)]
45pub 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]
64pub 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".
83fn 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)]
89pub 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)]
98pub 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.
106fn 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)]
175pub struct SearchParams {
176 /// The raw query.
177 #[serde(default)]
178 q: String,
179}
180
181/// `GET /search` — global search across visible repos.
182pub 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.
217struct 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).
224async 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.
266async 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).
341pub 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.
389fn 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.
400fn 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.
419fn 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.
436fn 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}`).
446fn 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.
474async 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`.
486async 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.
509async 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)]
520mod 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.
65**Rationale:** Foreground is correct for systemd and Docker, which own process65**Rationale:** Foreground is correct for systemd and Docker, which own process
66lifecycle. This deviates from an earlier `server start`/`server stop` sketch.66lifecycle. 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)
71is implemented in `crates/web`, not a separate crate, using ripgrep's `grep`
72engine. The `path:` qualifier matches as a case-insensitive **substring**, not a
73full 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
79its 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)`
82deadline, 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`
85exclusion is deferred (binary and >1 MiB files are already skipped).
86
68## 2026-07-24 — Linear search, no index87## 2026-07-24 — Linear search, no index
6988
70**Decision:** Search (in-repo and global) is a linear scan over repo contents at89**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
85transport_timeout_secs = 360085transport_timeout_secs = 3600
86max_pack_size_bytes = 536870912 # reject pushes with a larger received pack86max_pack_size_bytes = 536870912 # reject pushes with a larger received pack
8787
88[search]
89concurrency = 4 # repos searched at once in a global search
90timeout_secs = 10 # total budget; partial results are returned past it
91max_results = 200 # cap before truncating with a notice
92
88[log]93[log]
89level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured)94level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured)
90format = "pretty" # pretty | json95format = "pretty" # pretty | json