g1t/crates/contracts/src/search.rs

349 lines11,068 bytesCodeBlame
1//! The search service: one search across all of g1t, and Explore.
2//!
3//! It covers everything public, and private content the viewer is a member
4//! of: repositories (name, description, topics, README), code on default
5//! branches, issues and pull requests, people and workspaces. Who may see
6//! what is decided when the query runs, against the viewer's current
7//! memberships and each repository's current visibility, never only when
8//! something was indexed.
9//!
10//! Each `*Args` struct is the argument of the method of the same name,
11//! served at `POST /rpc/<method>`. Mirrors `packages/contracts/src/search.ts`.
12
13use serde::{Deserialize, Serialize};
14
15use crate::Viewer;
16
17/// What a search looks through: one tab of results.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum SearchType {
21 Repositories,
22 Code,
23 Issues,
24 Pulls,
25 People,
26}
27
28impl SearchType {
29 pub const ALL: [SearchType; 5] = [
30 SearchType::Repositories,
31 SearchType::Code,
32 SearchType::Issues,
33 SearchType::Pulls,
34 SearchType::People,
35 ];
36
37 pub fn as_str(self) -> &'static str {
38 match self {
39 SearchType::Repositories => "repositories",
40 SearchType::Code => "code",
41 SearchType::Issues => "issues",
42 SearchType::Pulls => "pulls",
43 SearchType::People => "people",
44 }
45 }
46
47 /// Reads a type as people write it: `repositories`, `repos`, `code`,
48 /// `issues`, `pulls`, `prs`, `people`, `users`.
49 pub fn parse(text: &str) -> Option<SearchType> {
50 match text.trim().to_lowercase().as_str() {
51 "repositories" | "repository" | "repos" | "repo" | "projects" | "project" => Some(SearchType::Repositories),
52 "code" => Some(SearchType::Code),
53 "issues" | "issue" => Some(SearchType::Issues),
54 "pulls" | "pull" | "prs" | "pr" | "pull_requests" | "pull-requests" => Some(SearchType::Pulls),
55 "people" | "users" | "user" | "workspaces" | "workspace" => Some(SearchType::People),
56 _ => None,
57 }
58 }
59}
60
61/// The most results on one page.
62pub const MAX_PER_PAGE: u32 = 50;
63/// Results on a page when none is asked for.
64pub const DEFAULT_PER_PAGE: u32 = 20;
65/// Counts stop here: past it a tab says "1,000+".
66pub const COUNT_CAP: u32 = 1000;
67/// The longest query read; the rest is ignored.
68pub const MAX_QUERY_CHARS: usize = 256;
69
70/// `search`. Returns `Outcome<SearchResults>`.
71#[derive(Debug, Default, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct SearchArgs {
74 pub viewer: Viewer,
75 /// Words, quoted phrases and qualifiers, such as
76 /// `parse_query repo:acme/web language:rust`.
77 pub query: String,
78 /// Which tab of results. Worked out from the qualifiers when absent:
79 /// `path:` means code, `is:pr` pull requests, and so on.
80 #[serde(default, rename = "type")]
81 pub kind: Option<SearchType>,
82 /// From 1.
83 #[serde(default)]
84 pub page: Option<u32>,
85 #[serde(default)]
86 pub per_page: Option<u32>,
87}
88
89/// One piece of a snippet, highlighted where it matched the query.
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Segment {
92 pub text: String,
93 #[serde(default)]
94 pub highlight: bool,
95}
96
97/// A line of code in a result, numbered from 1.
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct CodeLine {
100 pub number: u32,
101 pub parts: Vec<Segment>,
102}
103
104/// What a result is.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum HitKind {
108 Repository,
109 Code,
110 Issue,
111 Pull,
112 User,
113 Workspace,
114}
115
116/// One result. Which fields are set depends on `kind`.
117#[derive(Clone, Debug, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Hit {
120 pub kind: HitKind,
121 /// What to show as the result's name: `acme/web`, a file's path,
122 /// `Fix the login redirect`, a person's name.
123 pub title: String,
124 /// Where it is on g1t.sh, as a path: `/acme/web/blob/main/src/app.rs#L12`.
125 pub url: String,
126 /// The repository, as `owner/name`, for everything but people.
127 #[serde(default)]
128 pub repo: Option<String>,
129 /// Whether only members of its workspace can see it.
130 #[serde(default)]
131 pub private: bool,
132 /// A repository's or workspace's description, a person's bio.
133 #[serde(default)]
134 pub description: Option<String>,
135 /// The part of the text that matched, highlighted. Empty for code.
136 #[serde(default)]
137 pub snippet: Vec<Segment>,
138 /// For code: the lines that matched, with a line around each.
139 #[serde(default)]
140 pub lines: Vec<CodeLine>,
141 /// For code: the file's path in its repository.
142 #[serde(default)]
143 pub path: Option<String>,
144 /// For code: the file's language. For a repository: the language most
145 /// of its code is in.
146 #[serde(default)]
147 pub language: Option<String>,
148 /// For code: the branch it was read from.
149 #[serde(default, rename = "ref")]
150 pub git_ref: Option<String>,
151 /// For issues and pull requests.
152 #[serde(default)]
153 pub number: Option<u32>,
154 /// `open` or `closed` for an issue; `draft`, `open`, `merged` or
155 /// `closed` for a pull request.
156 #[serde(default)]
157 pub state: Option<String>,
158 /// Who opened the issue or pull request: `g1t` for one g1t made.
159 #[serde(default)]
160 pub author: Option<String>,
161 /// For an issue or pull request g1t opened: who asked for it.
162 #[serde(default)]
163 pub requested_by: Option<String>,
164 #[serde(default)]
165 pub labels: Vec<String>,
166 /// A repository's topics.
167 #[serde(default)]
168 pub topics: Vec<String>,
169 /// A user's username or a workspace's slug.
170 #[serde(default)]
171 pub slug: Option<String>,
172 /// The uploaded avatar's hash, for people and workspaces.
173 #[serde(default)]
174 pub avatar: Option<String>,
175 /// RFC 3339: when it last changed, or a repository's last push.
176 #[serde(default)]
177 pub updated_at: Option<String>,
178}
179
180impl Hit {
181 pub fn new(kind: HitKind, title: impl Into<String>, url: impl Into<String>) -> Hit {
182 Hit {
183 kind,
184 title: title.into(),
185 url: url.into(),
186 repo: None,
187 private: false,
188 description: None,
189 snippet: Vec::new(),
190 lines: Vec::new(),
191 path: None,
192 language: None,
193 git_ref: None,
194 number: None,
195 state: None,
196 author: None,
197 requested_by: None,
198 labels: Vec::new(),
199 topics: Vec::new(),
200 slug: None,
201 avatar: None,
202 updated_at: None,
203 }
204 }
205}
206
207/// How many results each tab has, up to [`COUNT_CAP`].
208#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
209pub struct SearchCounts {
210 pub repositories: u32,
211 pub code: u32,
212 pub issues: u32,
213 pub pulls: u32,
214 pub people: u32,
215}
216
217impl SearchCounts {
218 pub fn get(&self, kind: SearchType) -> u32 {
219 match kind {
220 SearchType::Repositories => self.repositories,
221 SearchType::Code => self.code,
222 SearchType::Issues => self.issues,
223 SearchType::Pulls => self.pulls,
224 SearchType::People => self.people,
225 }
226 }
227
228 pub fn set(&mut self, kind: SearchType, count: u32) {
229 let slot = match kind {
230 SearchType::Repositories => &mut self.repositories,
231 SearchType::Code => &mut self.code,
232 SearchType::Issues => &mut self.issues,
233 SearchType::Pulls => &mut self.pulls,
234 SearchType::People => &mut self.people,
235 };
236 *slot = count;
237 }
238}
239
240#[derive(Clone, Debug, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct SearchResults {
243 /// The query as it was read.
244 pub query: String,
245 /// The tab these results are for.
246 #[serde(rename = "type")]
247 pub kind: SearchType,
248 pub counts: SearchCounts,
249 pub page: u32,
250 pub per_page: u32,
251 /// Whether there is a page after this one.
252 pub more: bool,
253 pub hits: Vec<Hit>,
254 /// What the query could not do, such as a code search with no word of
255 /// three characters or more.
256 #[serde(default)]
257 pub notes: Vec<String>,
258}
259
260/// `suggest`: a few results of every kind but code, for the command
261/// palette as someone types. Returns `Vec<Hit>`.
262#[derive(Debug, Default, Serialize, Deserialize)]
263pub struct SuggestArgs {
264 pub viewer: Viewer,
265 pub query: String,
266}
267
268/// `explore`: public repositories, recently active or newly created,
269/// optionally of one language or topic. Returns `Explore`.
270#[derive(Debug, Default, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct ExploreArgs {
273 pub viewer: Viewer,
274 /// `active` (the default) or `new`.
275 #[serde(default)]
276 pub sort: Option<String>,
277 #[serde(default)]
278 pub language: Option<String>,
279 #[serde(default)]
280 pub topic: Option<String>,
281 #[serde(default)]
282 pub page: Option<u32>,
283}
284
285/// A public repository as Explore lists it.
286#[derive(Clone, Debug, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct ExploreRepo {
289 pub namespace: String,
290 pub name: String,
291 pub description: Option<String>,
292 #[serde(default)]
293 pub topics: Vec<String>,
294 pub language: Option<String>,
295 /// RFC 3339.
296 pub created_at: String,
297 /// RFC 3339: the last push to its default branch, if any.
298 pub pushed_at: Option<String>,
299 /// Whether it is archived: read-only, kept for reference.
300 #[serde(default)]
301 pub archived: bool,
302}
303
304/// A language or topic, with how many public repositories have it.
305#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
306pub struct Facet {
307 pub name: String,
308 pub count: u32,
309}
310
311#[derive(Clone, Debug, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct Explore {
314 pub repos: Vec<ExploreRepo>,
315 pub languages: Vec<Facet>,
316 pub topics: Vec<Facet>,
317 pub page: u32,
318 pub more: bool,
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn types_read_as_people_write_them() {
327 assert_eq!(SearchType::parse("prs"), Some(SearchType::Pulls));
328 assert_eq!(SearchType::parse("Repos"), Some(SearchType::Repositories));
329 assert_eq!(SearchType::parse("users"), Some(SearchType::People));
330 assert_eq!(SearchType::parse("wiki"), None);
331 }
332
333 #[test]
334 fn the_type_is_sent_as_type() {
335 let results = SearchResults {
336 query: "x".into(),
337 kind: SearchType::Code,
338 counts: SearchCounts::default(),
339 page: 1,
340 per_page: 20,
341 more: false,
342 hits: vec![],
343 notes: vec![],
344 };
345 let value = serde_json::to_value(&results).unwrap();
346 assert_eq!(value["type"], "code");
347 assert_eq!(value["perPage"], 20);
348 }
349}