1#[cfg(test)]
22mod tests;
23
24use crate::{
25 config::ProfileKind,
26 store::{self, RequestView},
27 types::{RequestStatus, ReviewerId},
28};
29use clap::ValueEnum;
30use std::{collections::BTreeSet, path::Path};
31
32pub const DEFAULT: &str = "kind:human status:waiting_human";
34
35const BATCH: u32 = 400;
37pub const PAGE: usize = 100;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Key {
42 Repo,
43 Eval,
44 Request,
45 Reviewer,
46 Tag,
47 Kind,
48 Status,
49 Is,
50}
51
52impl Key {
53 const ALL: [Self; 8] = [
54 Self::Repo,
55 Self::Eval,
56 Self::Request,
57 Self::Reviewer,
58 Self::Tag,
59 Self::Kind,
60 Self::Status,
61 Self::Is,
62 ];
63
64 fn name(self) -> &'static str {
65 match self {
66 Self::Repo => "repo",
67 Self::Eval => "eval",
68 Self::Request => "request",
69 Self::Reviewer => "reviewer",
70 Self::Tag => "tag",
71 Self::Kind => "kind",
72 Self::Status => "status",
73 Self::Is => "is",
74 }
75 }
76
77 fn parse(name: &str) -> Option<Self> {
78 Self::ALL
79 .into_iter()
80 .find(|key| key.name().eq_ignore_ascii_case(name))
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86enum Is {
87 Mine,
88 Claimed,
89 Unclaimed,
90}
91
92impl Is {
93 const ALL: [Self; 3] = [Self::Mine, Self::Claimed, Self::Unclaimed];
94
95 fn name(self) -> &'static str {
96 match self {
97 Self::Mine => "mine",
98 Self::Claimed => "claimed",
99 Self::Unclaimed => "unclaimed",
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105enum Term {
106 Text(String),
108 Field(Key, String),
110 Kind(ProfileKind),
111 Status(RequestStatus),
112 Is(Is),
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct Query {
118 terms: Vec<Term>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Entry {
124 pub repository: String,
125 pub eval: String,
126 pub request: String,
127 pub reviewer: Option<String>,
128 pub mine: bool,
129 pub kind: ProfileKind,
130 pub status: RequestStatus,
131 pub tags: Vec<String>,
132}
133
134impl Entry {
135 pub fn new(view: &RequestView, repo: &Path, reviewer: &ReviewerId) -> Self {
137 let request = &view.request;
138 let tags = view
139 .definition
140 .as_ref()
141 .and_then(|definition| definition.artifact.tags.value())
142 .or_else(|| {
143 let artifacts = request.human_definition.as_ref()?.artifacts.value()?;
144 artifacts.get(&request.target)?.tags.value()
145 })
146 .cloned()
147 .unwrap_or_default();
148 Self {
149 repository: crate::platform::path_text(repo),
150 eval: request.eval_id.to_string(),
151 request: request.id.to_string(),
152 reviewer: view.claim.as_ref().map(|claim| claim.reviewer.to_string()),
153 mine: view
154 .claim
155 .as_ref()
156 .is_some_and(|claim| &claim.reviewer == reviewer),
157 kind: request.profile.kind(),
158 status: request.status(),
159 tags,
160 }
161 }
162}
163
164fn kind_name(kind: ProfileKind) -> &'static str {
165 kind.name()
166}
167
168fn status_name(status: RequestStatus) -> String {
169 status.as_str().to_ascii_lowercase()
170}
171
172struct Word {
175 start: usize,
176 text: String,
177 quoted: bool,
178}
179
180fn scan(text: &str) -> (Vec<Word>, bool) {
183 let mut words = Vec::new();
184 let mut word: Option<Word> = None;
185 let mut quoted = false;
186 for (index, character) in text.char_indices() {
187 if character.is_whitespace() && !quoted {
188 words.extend(word.take());
189 continue;
190 }
191 let current = word.get_or_insert_with(|| Word {
192 start: index,
193 text: String::new(),
194 quoted: character == '"',
195 });
196 if character == '"' {
197 quoted = !quoted;
198 } else {
199 current.text.push(character);
200 }
201 }
202 let open = word.is_some();
203 words.extend(word);
204 (words, open)
205}
206
207fn words(text: &str) -> Vec<(String, bool)> {
209 scan(text)
210 .0
211 .into_iter()
212 .map(|word| (word.text, word.quoted))
213 .collect()
214}
215
216impl std::str::FromStr for Query {
217 type Err = String;
218
219 fn from_str(text: &str) -> Result<Self, String> {
220 let terms = words(text)
221 .into_iter()
222 .map(|(word, quoted)| {
223 let Some((name, value)) = word.split_once(':').filter(|_| !quoted) else {
224 return Ok(Term::Text(word.to_lowercase()));
225 };
226 let key = Key::parse(name).ok_or_else(|| {
227 format!(
228 "Unknown filter key `{name}:`; use {}, or quote the word to search for it.",
229 keys()
230 )
231 })?;
232 if value.is_empty() {
233 return Err(format!("`{}:` needs a value.", key.name()));
234 }
235 let lower = value.to_lowercase();
236 match key {
237 Key::Kind => ProfileKind::value_variants()
238 .iter()
239 .copied()
240 .find(|kind| kind_name(*kind) == lower)
241 .map(Term::Kind)
242 .ok_or_else(|| {
243 format!("Unknown kind `{value}`; use {}.", list(kind_values()))
244 }),
245 Key::Status => RequestStatus::ALL
246 .iter()
247 .copied()
248 .find(|status| status_name(*status) == lower)
249 .map(Term::Status)
250 .ok_or_else(|| {
251 format!("Unknown status `{value}`; use {}.", list(status_values()))
252 }),
253 Key::Is => Is::ALL
254 .into_iter()
255 .find(|is| is.name() == lower)
256 .map(Term::Is)
257 .ok_or_else(|| {
258 format!(
259 "Unknown qualifier `is:{value}`; use {}.",
260 list(Is::ALL.iter().map(|is| format!("is:{}", is.name())))
261 )
262 }),
263 key => Ok(Term::Field(key, lower)),
264 }
265 })
266 .collect::<Result<_, String>>()?;
267 Ok(Self { terms })
268 }
269}
270
271fn list(items: impl IntoIterator<Item = String>) -> String {
272 let items: Vec<String> = items.into_iter().collect();
273 match items.split_last() {
274 Some((last, rest)) if !rest.is_empty() => format!("{} or {last}", rest.join(", ")),
275 _ => items.join(""),
276 }
277}
278
279fn keys() -> String {
280 list(Key::ALL.iter().map(|key| format!("{}:", key.name())))
281}
282
283fn kind_values() -> impl Iterator<Item = String> {
284 ProfileKind::value_variants()
285 .iter()
286 .map(|kind| kind_name(*kind).to_owned())
287}
288
289fn status_values() -> impl Iterator<Item = String> {
290 RequestStatus::ALL.iter().map(|status| status_name(*status))
291}
292
293fn contains(field: &str, text: &str) -> bool {
294 field.to_lowercase().contains(text)
295}
296
297impl Query {
298 pub fn text_for(repo: Option<&Path>) -> String {
300 match repo {
301 Some(repo) => format!(
302 "{DEFAULT} {}",
303 quote("repo", &crate::platform::path_text(repo))
304 ),
305 None => DEFAULT.to_owned(),
306 }
307 }
308
309 pub fn waiting_human(&self) -> bool {
311 self.terms.contains(&Term::Kind(ProfileKind::Human))
312 && self
313 .terms
314 .contains(&Term::Status(RequestStatus::WaitingHuman))
315 }
316
317 pub fn matches(&self, entry: &Entry) -> bool {
318 self.terms.iter().all(|term| match term {
319 Term::Text(text) => [
320 Some(entry.repository.as_str()),
321 Some(entry.eval.as_str()),
322 Some(entry.request.as_str()),
323 entry.reviewer.as_deref(),
324 ]
325 .into_iter()
326 .flatten()
327 .any(|field| contains(field, text)),
328 Term::Field(Key::Repo, text) => contains(&entry.repository, text),
329 Term::Field(Key::Eval, text) => contains(&entry.eval, text),
330 Term::Field(Key::Request, text) => contains(&entry.request, text),
331 Term::Field(Key::Reviewer, text) => entry
332 .reviewer
333 .as_deref()
334 .is_some_and(|reviewer| contains(reviewer, text)),
335 Term::Field(Key::Tag, text) => entry.tags.iter().any(|tag| contains(tag, text)),
336 Term::Field(_, _) => unreachable!("kind, status and is parse to their own terms"),
337 Term::Kind(kind) => entry.kind == *kind,
338 Term::Status(status) => entry.status == *status,
339 Term::Is(Is::Mine) => entry.mine,
340 Term::Is(Is::Claimed) => entry.reviewer.is_some(),
341 Term::Is(Is::Unclaimed) => {
342 entry.status == RequestStatus::WaitingHuman && entry.reviewer.is_none()
343 }
344 })
345 }
346
347 fn statuses(&self) -> Vec<RequestStatus> {
349 let statuses: BTreeSet<_> = self
350 .terms
351 .iter()
352 .filter_map(|term| match term {
353 Term::Status(status) => Some(*status),
354 Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
356 Some(RequestStatus::WaitingHuman)
357 }
358 _ => None,
359 })
360 .collect();
361 match statuses.len() {
362 0 | 1 => statuses.into_iter().collect(),
364 _ => vec![*statuses.first().expect("two statuses")],
365 }
366 }
367}
368
369fn quote(key: &str, value: &str) -> String {
371 if value
372 .chars()
373 .any(|character| character.is_whitespace() || character == '"')
374 {
375 format!("{key}:\"{}\"", value.replace('"', ""))
376 } else {
377 format!("{key}:{value}")
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct Completion {
384 pub keep: String,
386 pub candidates: Vec<String>,
388}
389
390fn last_term(text: &str) -> (&str, &str) {
393 match scan(text) {
394 (words, true) => text.split_at(words.last().map_or(0, |word| word.start)),
395 (_, false) => (text, ""),
396 }
397}
398
399pub fn complete(text: &str, entries: &[Entry]) -> Completion {
402 let (keep, term) = last_term(text);
403 let candidates = match term.split_once(':').filter(|_| !term.starts_with('"')) {
405 None if term.starts_with('"') => Vec::new(),
406 None => {
407 let lower = term.to_lowercase();
408 Key::ALL
409 .iter()
410 .map(|key| format!("{}:", key.name()))
411 .filter(|key| key.starts_with(&lower))
412 .collect()
413 }
414 Some((name, partial)) => match Key::parse(name) {
415 None => Vec::new(),
416 Some(key) => {
417 let partial = partial.trim_matches('"').to_lowercase();
418 let values: BTreeSet<String> = match key {
419 Key::Repo => entries
420 .iter()
421 .map(|entry| {
422 Path::new(&entry.repository)
423 .file_name()
424 .map_or(entry.repository.clone(), |name| {
425 name.to_string_lossy().into_owned()
426 })
427 })
428 .collect(),
429 Key::Eval => entries.iter().map(|entry| entry.eval.clone()).collect(),
430 Key::Request => entries.iter().map(|entry| entry.request.clone()).collect(),
431 Key::Reviewer => entries
432 .iter()
433 .filter_map(|entry| entry.reviewer.clone())
434 .collect(),
435 Key::Tag => entries
436 .iter()
437 .flat_map(|entry| entry.tags.iter().cloned())
438 .collect(),
439 Key::Kind => kind_values().collect(),
440 Key::Status => status_values().collect(),
441 Key::Is => Is::ALL.iter().map(|is| is.name().to_owned()).collect(),
442 };
443 values
444 .into_iter()
445 .filter(|value| value.to_lowercase().contains(&partial))
446 .map(|value| quote(key.name(), &value))
447 .collect()
448 }
449 },
450 };
451 Completion {
452 keep: keep.to_owned(),
453 candidates,
454 }
455}
456
457pub async fn suggest(
460 state: &Path,
461 text: &str,
462 reviewer: &ReviewerId,
463) -> Result<Completion, String> {
464 let (keep, term) = last_term(text);
465 let from_requests = term
466 .split_once(':')
467 .filter(|_| !term.starts_with('"'))
468 .and_then(|(name, _)| Key::parse(name))
469 .is_some_and(|key| {
470 matches!(
471 key,
472 Key::Repo | Key::Eval | Key::Request | Key::Reviewer | Key::Tag
473 )
474 });
475 let entries = match keep.parse::<Query>() {
476 Ok(query) if from_requests => search(state, &query, reviewer, 0)
477 .await?
478 .requests
479 .into_iter()
480 .map(|(_, entry)| entry)
481 .collect(),
482 _ => Vec::new(),
483 };
484 Ok(complete(text, &entries))
485}
486
487#[derive(Debug, Clone, Default)]
489pub struct Found {
490 pub requests: Vec<(RequestView, Entry)>,
491 pub next: Option<u64>,
493}
494
495pub async fn search(
501 state: &Path,
502 query: &Query,
503 reviewer: &ReviewerId,
504 start: u64,
505) -> Result<Found, String> {
506 let statuses = query.statuses();
507 let mut found = Found::default();
508 let mut position = start;
509 loop {
510 let batch = store::read_request_batch(state, &statuses, position, BATCH).await?;
511 let read = batch.len() as u64;
512 for (offset, (view, repo)) in batch.into_iter().enumerate() {
513 if Box::pin(follows_waiting(state, &view)).await {
514 continue;
515 }
516 let entry = Entry::new(&view, &repo, reviewer);
517 if query.matches(&entry) {
518 found.requests.push((view, entry));
519 if found.requests.len() == PAGE {
520 found.next = Some(position + offset as u64 + 1);
521 return Ok(found);
522 }
523 }
524 }
525 if read < u64::from(BATCH) {
526 return Ok(found);
527 }
528 position += read;
529 }
530}
531
532async fn follows_waiting(state: &Path, view: &RequestView) -> bool {
534 if view.request.status() != RequestStatus::WaitingHuman {
535 return false;
536 }
537 let Some(source) = view
538 .execution
539 .as_ref()
540 .map(|execution| &execution.provenance.request_id)
541 .filter(|source| **source != view.request.id)
542 else {
543 return false;
544 };
545 store::read_request(state, source)
546 .await
547 .is_ok_and(|original| original.request.status() == RequestStatus::WaitingHuman)
548}