git-ents.gitmain
⌘K
foforge
search.rs271 lines · 9.1 KB · rusthistorycomment on this file
1//! `GET /search`: `super::layout`'s nav search form's target -- a plain
2//! request-time substring scan over the served repository, deliberately
3//! with no index and no new state (a design decision this crate settled
4//! on rather than relitigated here): every request re-walks the `HEAD`
5//! tree and the meta-ref listings the pages that already own them use.
6//! Renders with no tab active at all (`super::Tab::None`), like
7//! [`super::account`], since it is reached from the nav search form
8//! rather than any tab.
9
10use std::sync::Arc;
11
12use axum::extract::{Query, State};
13use ents_kiln::toolchain;
14use gix::bstr::ByteSlice as _;
15use gix_object::{Find, Write};
16use maud::{Markup, html};
17use serde::Deserialize;
18
19use crate::error::Result;
20use crate::state::AppState;
21
22/// The largest number of matches shown per result group -- past it, a
23/// "more matches not shown" note replaces the rest rather than rendering
24/// an unbounded page.
25const MAX_RESULTS: usize = 100;
26
27/// The query parameters `GET /search` accepts.
28#[derive(Debug, Deserialize)]
29pub struct SearchQuery {
30 /// The search term. Empty (the default, and what a bare `GET /search`
31 /// carries) renders a "type to search" blankslate rather than a
32 /// no-matches one -- nothing was searched yet, so nothing "failed to
33 /// match" (see [`blankslate`]'s own doc).
34 #[serde(default)]
35 q: String,
36}
37
38/// `GET /search`: grouped, case-insensitive substring matches -- file
39/// paths from the `HEAD` tree (linking into `crate::pages::files`) and
40/// meta entity names (member usernames, effect names, toolchain names,
41/// linking into their own show pages) -- or a blankslate on an empty
42/// query or no matches.
43///
44/// # Errors
45///
46/// Propagates a ref-store read failure.
47pub async fn show<O>(
48 State(state): State<Arc<AppState<O>>>,
49 Query(params): Query<SearchQuery>,
50) -> Result<Markup>
51where
52 O: Find + Write + Send + 'static,
53{
54 let query = params.q.trim().to_owned();
55 let (files, files_more) = search_files(&state, &query);
56 let (members, members_more) = meta_names(&state, "refs/meta/member/", &query)?;
57 let (effects, effects_more) = meta_names(&state, "refs/meta/effects/", &query)?;
58 let (toolchains, toolchains_more) = search_toolchains(&state, &query)?;
59
60 let any_matches =
61 !files.is_empty() || !members.is_empty() || !effects.is_empty() || !toolchains.is_empty();
62
63 Ok(super::layout(
64 &super::RepoHeader::from_state(&state),
65 &super::identity_label(&state),
66 super::Tab::None,
67 "Search",
68 html! {
69 @if !any_matches {
70 (blankslate(&query))
71 } @else {
72 (result_group("files", &files, files_more, |path| format!("/files/{path}")))
73 (result_group("members", &members, members_more, |id| format!("/members/{id}")))
74 (result_group("effects", &effects, effects_more, |id| format!("/effects/{id}")))
75 (result_group(
76 "toolchains",
77 &toolchains,
78 toolchains_more,
79 |id| format!("/toolchains/{id}"),
80 ))
81 }
82 },
83 ))
84}
85
86/// Truncate `items` to [`MAX_RESULTS`], reporting whether anything was cut.
87fn cap(mut items: Vec<String>) -> (Vec<String>, bool) {
88 if items.len() > MAX_RESULTS {
89 items.truncate(MAX_RESULTS);
90 (items, true)
91 } else {
92 (items, false)
93 }
94}
95
96/// File paths under the `HEAD` tree whose path contains `query`
97/// (case-insensitive), capped via [`cap`]. Empty on an empty `query` --
98/// no walk is attempted at all, matching every other group. Best-effort:
99/// an unopenable repository or unborn `HEAD` degrade to no file matches
100/// rather than an error, exactly as `crate::pages::files`/`crate::pages::dashboard`
101/// degrade the same reads.
102fn search_files<O>(state: &AppState<O>, query: &str) -> (Vec<String>, bool) {
103 if query.is_empty() {
104 return (Vec::new(), false);
105 }
106 let Ok(repo) = gix::open(&state.path) else {
107 return (Vec::new(), false);
108 };
109 let Ok(tree) = repo.head_tree() else {
110 return (Vec::new(), false);
111 };
112 let mut paths = Vec::new();
113 collect_paths(&repo, &tree, "", &mut paths);
114 let needle = query.to_lowercase();
115 cap(paths
116 .into_iter()
117 .filter(|path| path.to_lowercase().contains(&needle))
118 .collect())
119}
120
121/// Recurse `tree`, pushing every blob's full slash-joined path (relative
122/// to the `HEAD` root) onto `out` -- the same walk
123/// `crate::pages::dashboard::collect_blobs` performs for the language
124/// breakdown, here collecting paths instead of `(name, oid)` pairs.
125/// Subtree reads that fail are skipped rather than propagated, matching
126/// that same best-effort stance.
127fn collect_paths(
128 repo: &gix::Repository,
129 tree: &gix::Tree<'_>,
130 prefix: &str,
131 out: &mut Vec<String>,
132) {
133 for entry in tree.iter() {
134 let Ok(entry) = entry else { continue };
135 let name = entry.filename().to_str_lossy();
136 let path = if prefix.is_empty() {
137 name.into_owned()
138 } else {
139 format!("{prefix}/{name}")
140 };
141 if entry.mode().is_tree() {
142 if let Ok(object) = repo.find_object(entry.oid().to_owned())
143 && let Ok(subtree) = object.try_into_tree()
144 {
145 collect_paths(repo, &subtree, &path, out);
146 }
147 } else if entry.mode().is_blob() {
148 out.push(path);
149 }
150 }
151}
152
153/// The ids of every ref directly under `prefix` (a meta-ref namespace,
154/// e.g. `refs/meta/member/`) whose id contains `query` (case-insensitive),
155/// capped via [`cap`] -- the same `state.refs.iter_prefix` listing
156/// `crate::pages::members`/`crate::pages::effects` read their own rows
157/// from, here matched against `query` instead of fully deserialized.
158///
159/// # Errors
160///
161/// Propagates a ref-store read failure.
162fn meta_names<O>(state: &AppState<O>, prefix: &str, query: &str) -> Result<(Vec<String>, bool)> {
163 if query.is_empty() {
164 return Ok((Vec::new(), false));
165 }
166 let needle = query.to_lowercase();
167 let mut names = Vec::new();
168 for entry in state.refs.iter_prefix(prefix)? {
169 let (name, _) = entry?;
170 let path = name.as_bstr().to_string();
171 if let Some(id) = path.strip_prefix(prefix)
172 && id.to_lowercase().contains(&needle)
173 {
174 names.push(id.to_owned());
175 }
176 }
177 Ok(cap(names))
178}
179
180/// Toolchain names containing `query` (case-insensitive), capped via
181/// [`cap`] -- reads through the same [`toolchain::list`]
182/// `crate::pages::toolchains::list` itself calls.
183///
184/// # Errors
185///
186/// Propagates a ref-store read failure.
187fn search_toolchains<O>(state: &AppState<O>, query: &str) -> Result<(Vec<String>, bool)> {
188 if query.is_empty() {
189 return Ok((Vec::new(), false));
190 }
191 let needle = query.to_lowercase();
192 let names = toolchain::list(state.refs.as_ref())?
193 .into_iter()
194 .filter(|name| name.to_lowercase().contains(&needle))
195 .collect();
196 Ok(cap(names))
197}
198
199/// One result group's card: `label` as its header, `rows` linked via
200/// `href_for`, and a trailing "more matches not shown" row when `rows`
201/// was capped. Renders nothing at all when `rows` is empty, so an
202/// unmatched group leaves no empty card behind.
203fn result_group(
204 label: &str,
205 rows: &[String],
206 truncated: bool,
207 href_for: impl Fn(&str) -> String,
208) -> Markup {
209 if rows.is_empty() {
210 return html! {};
211 }
212 html! {
213 div.card {
214 div.card-header { (label) }
215 ul.string-list {
216 @for row in rows {
217 li { a href=(href_for(row)) { (row) } }
218 }
219 }
220 @if truncated {
221 div.card-row.muted { "More matches not shown." }
222 }
223 }
224 }
225}
226
227/// The empty-results placeholder ([`super::blankslate`]): a "type to
228/// search" prompt before any query has been typed at all (naming the
229/// header's own "Jump to file or symbol" search input, this page's only
230/// entry point), or a "no matches" note for a non-empty query that found
231/// nothing.
232fn blankslate(query: &str) -> Markup {
233 if query.is_empty() {
234 super::blankslate(
235 "Type to search",
236 html! {
237 "Use the header's \u{201c}Jump to file or symbol\u{201d} search "
238 "to look through files and meta entities."
239 },
240 )
241 } else {
242 super::blankslate(
243 "No matches",
244 html! { "Nothing matched " code { (query) } "." },
245 )
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 #![allow(clippy::expect_used, reason = "unit test")]
252
253 use super::*;
254
255 #[test]
256 fn cap_truncates_and_reports_when_it_cut_something() {
257 let (kept, truncated) = cap((0..150).map(|n| n.to_string()).collect());
258 assert_eq!(kept.len(), MAX_RESULTS);
259 assert!(truncated);
260
261 let (kept, truncated) = cap(vec!["a".to_owned(), "b".to_owned()]);
262 assert_eq!(kept.len(), 2);
263 assert!(!truncated);
264 }
265
266 #[test]
267 fn result_group_renders_nothing_for_an_empty_group() {
268 let rendered = result_group("files", &[], false, |row| row.to_owned()).into_string();
269 assert!(rendered.is_empty());
270 }
271}