Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +117 −0
| @@ -549,6 +549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 549 | 549 | checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" | |
| 550 | 550 | dependencies = [ | |
| 551 | 551 | "memchr", | |
| 552 | + | "regex-automata", | |
| 552 | 553 | "serde_core", | |
| 553 | 554 | ] | |
| 554 | 555 | ||
| @@ -1578,6 +1579,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1578 | 1579 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" | |
| 1579 | 1580 | ||
| 1580 | 1581 | [[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]] | |
| 1581 | 1600 | name = "entities" | |
| 1582 | 1601 | version = "1.0.1" | |
| 1583 | 1602 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2003,6 +2022,85 @@ dependencies = [ | |||
| 2003 | 2022 | ] | |
| 2004 | 2023 | ||
| 2005 | 2024 | [[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]] | |
| 2006 | 2104 | name = "group" | |
| 2007 | 2105 | version = "0.13.0" | |
| 2008 | 2106 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2739,6 +2837,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2739 | 2837 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 2740 | 2838 | ||
| 2741 | 2839 | [[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]] | |
| 2742 | 2849 | name = "mime" | |
| 2743 | 2850 | version = "0.3.17" | |
| 2744 | 2851 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4839,6 +4946,15 @@ dependencies = [ | |||
| 4839 | 4946 | ] | |
| 4840 | 4947 | ||
| 4841 | 4948 | [[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]] | |
| 4842 | 4958 | name = "thiserror" | |
| 4843 | 4959 | version = "1.0.69" | |
| 4844 | 4960 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5462,6 +5578,7 @@ dependencies = [ | |||
| 5462 | 5578 | "config", | |
| 5463 | 5579 | "flate2", | |
| 5464 | 5580 | "git", | |
| 5581 | + | "grep", | |
| 5465 | 5582 | "highlight", | |
| 5466 | 5583 | "mail", | |
| 5467 | 5584 | "maud", | |
Cargo.toml +2 −0
| @@ -151,6 +151,8 @@ ammonia = "4" | |||
| 151 | 151 | # gzip-encoded upload-pack requests. | |
| 152 | 152 | tokio-util = { version = "0.7", features = ["io"] } | |
| 153 | 153 | flate2 = "1" | |
| 154 | + | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. | |
| 155 | + | grep = "0.3" | |
| 154 | 156 | # SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key). | |
| 155 | 157 | russh = "0.62" | |
| 156 | 158 | tracing = "0.1" | |
crates/config/src/lib.rs +1 −1
| @@ -33,7 +33,7 @@ use figment::providers::{Env, Format, Serialized, Toml}; | |||
| 33 | 33 | pub use crate::secret::{REDACTED, Secret}; | |
| 34 | 34 | pub use crate::types::{ | |
| 35 | 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 | }; | |
| 38 | 38 | ||
| 39 | 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 | 36 | pub ui: Ui, | |
| 37 | 37 | /// Git binary and pack-transport limits. | |
| 38 | 38 | pub git: Git, | |
| 39 | + | /// Search bounds. | |
| 40 | + | pub search: Search, | |
| 39 | 41 | /// Logging. | |
| 40 | 42 | pub log: Log, | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 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 | + | ||
| 43 | 67 | /// `[instance]` — identity and global policy. | |
| 44 | 68 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 45 | 69 | #[serde(deny_unknown_fields)] | |
crates/git/src/repo.rs +24 −0
| @@ -456,6 +456,30 @@ impl Repo { | |||
| 456 | 456 | Ok(result) | |
| 457 | 457 | } | |
| 458 | 458 | ||
| 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 | 483 | /// The tree at `path` within a commit, or `None` if the path is absent or is | |
| 460 | 484 | /// not a directory. An empty `path` yields the root tree. | |
| 461 | 485 | fn subtree_at<'a>( | |
crates/web/Cargo.toml +1 −0
| @@ -28,6 +28,7 @@ comrak = { workspace = true } | |||
| 28 | 28 | ammonia = { workspace = true } | |
| 29 | 29 | tokio-util = { workspace = true } | |
| 30 | 30 | flate2 = { workspace = true } | |
| 31 | + | grep = { workspace = true } | |
| 31 | 32 | base64 = { workspace = true } | |
| 32 | 33 | time = { workspace = true } | |
| 33 | 34 | serde = { workspace = true } | |
crates/web/src/lib.rs +2 −0
| @@ -18,6 +18,7 @@ mod layout; | |||
| 18 | 18 | mod markdown; | |
| 19 | 19 | mod pages; | |
| 20 | 20 | mod repo; | |
| 21 | + | mod search; | |
| 21 | 22 | #[path = "serve.rs"] | |
| 22 | 23 | mod serve_assets; | |
| 23 | 24 | mod session; | |
| @@ -111,6 +112,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 111 | 112 | "/invite/{token}", | |
| 112 | 113 | get(pages::invite_form).post(pages::invite_submit), | |
| 113 | 114 | ) | |
| 115 | + | .route("/search", get(search::global)) | |
| 114 | 116 | .route("/settings", get(pages::settings)) | |
| 115 | 117 | .route("/settings/theme", get(pages::set_theme)) | |
| 116 | 118 | .route("/healthz", get(serve_assets::healthz)) | |
crates/web/src/repo.rs +14 −6
| @@ -45,6 +45,9 @@ pub struct DiffQuery { | |||
| 45 | 45 | to: Option<u32>, | |
| 46 | 46 | /// The smart-HTTP service, for `….git/info/refs?service=`. | |
| 47 | 47 | service: Option<String>, | |
| 48 | + | /// The search query, for the in-repo `/-/search` view. | |
| 49 | + | #[serde(default)] | |
| 50 | + | q: Option<String>, | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 50 | 53 | /// The candidate README filenames, in lookup order. | |
| @@ -60,18 +63,18 @@ enum Tab { | |||
| 60 | 63 | } | |
| 61 | 64 | ||
| 62 | 65 | /// 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, | |
| 66 | 69 | /// The viewer's access level. Reserved for gating mutation UI (settings, edit) | |
| 67 | 70 | /// in later phases; every browse view here requires only `Read`. | |
| 68 | 71 | #[allow(dead_code)] | |
| 69 | - | access: auth::Access, | |
| 72 | + | pub(crate) access: auth::Access, | |
| 70 | 73 | } | |
| 71 | 74 | ||
| 72 | 75 | /// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo | |
| 73 | 76 | /// 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> | |
| 75 | 78 | where | |
| 76 | 79 | F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static, | |
| 77 | 80 | T: Send + 'static, | |
| @@ -233,7 +236,12 @@ async fn dispatch_view( | |||
| 233 | 236 | }, | |
| 234 | 237 | "branches" => branches_view(state, viewer, jar, uri, ctx).await, | |
| 235 | 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 | 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 | + | ||
| 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(¶ms.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(¶ms.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(¶ms.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. | |||
| 65 | 65 | **Rationale:** Foreground is correct for systemd and Docker, which own process | |
| 66 | 66 | lifecycle. This deviates from an earlier `server start`/`server stop` sketch. | |
| 67 | 67 | ||
| 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 | + | ||
| 68 | 87 | ## 2026-07-24 — Linear search, no index | |
| 69 | 88 | ||
| 70 | 89 | **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 | |||
| 85 | 85 | transport_timeout_secs = 3600 | |
| 86 | 86 | max_pack_size_bytes = 536870912 # reject pushes with a larger received pack | |
| 87 | 87 | ||
| 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 | + | ||
| 88 | 93 | [log] | |
| 89 | 94 | level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured) | |
| 90 | 95 | format = "pretty" # pretty | json | |