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 | checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" | 549 | checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" |
| 550 | dependencies = [ | 550 | dependencies = [ |
| 551 | "memchr", | 551 | "memchr", |
| 552 | "regex-automata", | ||
| 552 | "serde_core", | 553 | "serde_core", |
| 553 | ] | 554 | ] |
| 554 | 555 | ||
| @@ -1578,6 +1579,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1578 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" | 1579 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" |
| 1579 | 1580 | ||
| 1580 | [[package]] | 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 | name = "entities" | 1600 | name = "entities" |
| 1582 | version = "1.0.1" | 1601 | version = "1.0.1" |
| 1583 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1602 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2003,6 +2022,85 @@ dependencies = [ | |||
| 2003 | ] | 2022 | ] |
| 2004 | 2023 | ||
| 2005 | [[package]] | 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 | name = "group" | 2104 | name = "group" |
| 2007 | version = "0.13.0" | 2105 | version = "0.13.0" |
| 2008 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | 2837 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| 2740 | 2838 | ||
| 2741 | [[package]] | 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 | name = "mime" | 2849 | name = "mime" |
| 2743 | version = "0.3.17" | 2850 | version = "0.3.17" |
| 2744 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2851 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -4839,6 +4946,15 @@ dependencies = [ | |||
| 4839 | ] | 4946 | ] |
| 4840 | 4947 | ||
| 4841 | [[package]] | 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 | name = "thiserror" | 4958 | name = "thiserror" |
| 4843 | version = "1.0.69" | 4959 | version = "1.0.69" |
| 4844 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4960 | source = "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. |
| 152 | tokio-util = { version = "0.7", features = ["io"] } | 152 | tokio-util = { version = "0.7", features = ["io"] } |
| 153 | flate2 = "1" | 153 | flate2 = "1" |
| 154 | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. | ||
| 155 | grep = "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). |
| 155 | russh = "0.62" | 157 | russh = "0.62" |
| 156 | tracing = "0.1" | 158 | tracing = "0.1" |
crates/config/src/lib.rs +1 −1
| @@ -33,7 +33,7 @@ use figment::providers::{Env, Format, Serialized, Toml}; | |||
| 33 | pub use crate::secret::{REDACTED, Secret}; | 33 | pub use crate::secret::{REDACTED, Secret}; |
| 34 | pub use crate::types::{ | 34 | pub 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 | }; |
| 38 | 38 | ||
| 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 | } |
| 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 | /// `[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 | } |
| 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 | /// The tree at `path` within a commit, or `None` if the path is absent or is | 483 | /// 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 } | |||
| 28 | ammonia = { workspace = true } | 28 | ammonia = { workspace = true } |
| 29 | tokio-util = { workspace = true } | 29 | tokio-util = { workspace = true } |
| 30 | flate2 = { workspace = true } | 30 | flate2 = { workspace = true } |
| 31 | grep = { workspace = true } | ||
| 31 | base64 = { workspace = true } | 32 | base64 = { workspace = true } |
| 32 | time = { workspace = true } | 33 | time = { workspace = true } |
| 33 | serde = { workspace = true } | 34 | serde = { workspace = true } |
crates/web/src/lib.rs +2 −0
| @@ -18,6 +18,7 @@ mod layout; | |||
| 18 | mod markdown; | 18 | mod markdown; |
| 19 | mod pages; | 19 | mod pages; |
| 20 | mod repo; | 20 | mod repo; |
| 21 | mod search; | ||
| 21 | #[path = "serve.rs"] | 22 | #[path = "serve.rs"] |
| 22 | mod serve_assets; | 23 | mod serve_assets; |
| 23 | mod session; | 24 | mod 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 | } |
| 49 | 52 | ||
| 50 | /// The candidate README filenames, in lookup order. | 53 | /// The candidate README filenames, in lookup order. |
| @@ -60,18 +63,18 @@ enum Tab { | |||
| 60 | } | 63 | } |
| 61 | 64 | ||
| 62 | /// A resolved repository plus the viewer's access to it. | 65 | /// A resolved repository plus the viewer's access to it. |
| 63 | struct RepoCtx { | 66 | pub(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 | } |
| 71 | 74 | ||
| 72 | /// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo | 75 | /// 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). |
| 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 | where | 78 | where |
| 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 | |||
| 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 | **Rationale:** Foreground is correct for systemd and Docker, which own process | 65 | **Rationale:** Foreground is correct for systemd and Docker, which own process |
| 66 | lifecycle. This deviates from an earlier `server start`/`server stop` sketch. | 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 | ## 2026-07-24 — Linear search, no index | 87 | ## 2026-07-24 — Linear search, no index |
| 69 | 88 | ||
| 70 | **Decision:** Search (in-repo and global) is a linear scan over repo contents at | 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 | transport_timeout_secs = 3600 | 85 | transport_timeout_secs = 3600 |
| 86 | max_pack_size_bytes = 536870912 # reject pushes with a larger received pack | 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 | [log] | 93 | [log] |
| 89 | level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured) | 94 | level = "info" # trace | debug | info | warn | error (RUST_LOG also honoured) |
| 90 | format = "pretty" # pretty | json | 95 | format = "pretty" # pretty | json |