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, ops::ControlFlow, 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(RequestStatus::WaitingHuman) => entry.status.is_human_wait(),
339 Term::Status(status) => entry.status == *status,
340 Term::Is(Is::Mine) => entry.mine,
341 Term::Is(Is::Claimed) => entry.reviewer.is_some(),
342 Term::Is(Is::Unclaimed) => entry.status.is_human_wait() && entry.reviewer.is_none(),
343 })
344 }
345
346 fn statuses(&self) -> Vec<RequestStatus> {
348 let statuses: BTreeSet<_> = self
349 .terms
350 .iter()
351 .filter_map(|term| match term {
352 Term::Status(status) => Some(*status),
353 Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
355 Some(RequestStatus::WaitingHuman)
356 }
357 _ => None,
358 })
359 .collect();
360 match statuses.len() {
361 0 | 1 => statuses
363 .into_iter()
364 .flat_map(|status| {
365 if status == RequestStatus::WaitingHuman {
366 vec![status, RequestStatus::Deferred]
367 } else {
368 vec![status]
369 }
370 })
371 .collect(),
372 _ => vec![*statuses.first().expect("two statuses")],
373 }
374 }
375}
376
377fn quote(key: &str, value: &str) -> String {
379 if value
380 .chars()
381 .any(|character| character.is_whitespace() || character == '"')
382 {
383 format!("{key}:\"{}\"", value.replace('"', ""))
384 } else {
385 format!("{key}:{value}")
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct Completion {
392 pub keep: String,
394 pub candidates: Vec<String>,
396}
397
398fn last_term(text: &str) -> (&str, &str) {
401 match scan(text) {
402 (words, true) => text.split_at(words.last().map_or(0, |word| word.start)),
403 (_, false) => (text, ""),
404 }
405}
406
407pub fn complete(text: &str, entries: &[Entry]) -> Completion {
410 let (keep, term) = last_term(text);
411 let candidates = match term.split_once(':').filter(|_| !term.starts_with('"')) {
413 None if term.starts_with('"') => Vec::new(),
414 None => {
415 let lower = term.to_lowercase();
416 Key::ALL
417 .iter()
418 .map(|key| format!("{}:", key.name()))
419 .filter(|key| key.starts_with(&lower))
420 .collect()
421 }
422 Some((name, partial)) => match Key::parse(name) {
423 None => Vec::new(),
424 Some(key) => {
425 let partial = partial.trim_matches('"').to_lowercase();
426 let values: BTreeSet<String> = match key {
427 Key::Repo => entries
428 .iter()
429 .map(|entry| {
430 Path::new(&entry.repository)
431 .file_name()
432 .map_or(entry.repository.clone(), |name| {
433 name.to_string_lossy().into_owned()
434 })
435 })
436 .collect(),
437 Key::Eval => entries.iter().map(|entry| entry.eval.clone()).collect(),
438 Key::Request => entries.iter().map(|entry| entry.request.clone()).collect(),
439 Key::Reviewer => entries
440 .iter()
441 .filter_map(|entry| entry.reviewer.clone())
442 .collect(),
443 Key::Tag => entries
444 .iter()
445 .flat_map(|entry| entry.tags.iter().cloned())
446 .collect(),
447 Key::Kind => kind_values().collect(),
448 Key::Status => status_values().collect(),
449 Key::Is => Is::ALL.iter().map(|is| is.name().to_owned()).collect(),
450 };
451 values
452 .into_iter()
453 .filter(|value| value.to_lowercase().contains(&partial))
454 .map(|value| quote(key.name(), &value))
455 .collect()
456 }
457 },
458 };
459 Completion {
460 keep: keep.to_owned(),
461 candidates,
462 }
463}
464
465pub async fn suggest(
468 state: &Path,
469 text: &str,
470 reviewer: &ReviewerId,
471) -> Result<Completion, String> {
472 let (keep, term) = last_term(text);
473 let from_requests = term
474 .split_once(':')
475 .filter(|_| !term.starts_with('"'))
476 .and_then(|(name, _)| Key::parse(name))
477 .is_some_and(|key| {
478 matches!(
479 key,
480 Key::Repo | Key::Eval | Key::Request | Key::Reviewer | Key::Tag
481 )
482 });
483 let entries = match keep.parse::<Query>() {
484 Ok(query) if from_requests => search(state, &query, reviewer, 0)
485 .await?
486 .requests
487 .into_iter()
488 .map(|(_, entry)| entry)
489 .collect(),
490 _ => Vec::new(),
491 };
492 Ok(complete(text, &entries))
493}
494
495#[derive(Debug, Clone, Default)]
497pub struct Found {
498 pub requests: Vec<(RequestView, Entry)>,
499 pub next: Option<u64>,
501}
502
503pub async fn search(
509 state: &Path,
510 query: &Query,
511 reviewer: &ReviewerId,
512 start: u64,
513) -> Result<Found, String> {
514 let mut found = Found::default();
515 read_matching(state, query, reviewer, start, |view, entry, position| {
516 found.requests.push((view, entry));
517 if found.requests.len() < PAGE {
518 return ControlFlow::Continue(());
519 }
520 found.next = Some(position + 1);
521 ControlFlow::Break(())
522 })
523 .await?;
524 Ok(found)
525}
526
527pub async fn count(state: &Path, query: &Query, reviewer: &ReviewerId) -> Result<u64, String> {
530 let mut count = 0;
531 read_matching(state, query, reviewer, 0, |_, _, _| {
532 count += 1;
533 ControlFlow::Continue(())
534 })
535 .await?;
536 Ok(count)
537}
538
539async fn read_matching(
542 state: &Path,
543 query: &Query,
544 reviewer: &ReviewerId,
545 start: u64,
546 mut found: impl FnMut(RequestView, Entry, u64) -> ControlFlow<()>,
547) -> Result<(), String> {
548 let statuses = query.statuses();
549 let mut position = start;
550 loop {
551 let batch = store::read_request_batch(state, &statuses, position, BATCH).await?;
552 let read = batch.len() as u64;
553 for (offset, (view, repo)) in batch.into_iter().enumerate() {
554 if Box::pin(follows_waiting(state, &view)).await {
555 continue;
556 }
557 let entry = Entry::new(&view, &repo, reviewer);
558 if query.matches(&entry) && found(view, entry, position + offset as u64).is_break() {
559 return Ok(());
560 }
561 }
562 if read < u64::from(BATCH) {
563 return Ok(());
564 }
565 position += read;
566 }
567}
568
569async fn follows_waiting(state: &Path, view: &RequestView) -> bool {
571 if !view.request.status().is_human_wait() {
572 return false;
573 }
574 let Some(source) = view
575 .execution
576 .as_ref()
577 .map(|execution| &execution.provenance.request_id)
578 .filter(|source| **source != view.request.id)
579 else {
580 return false;
581 };
582 store::read_request(state, source)
583 .await
584 .is_ok_and(|original| original.request.status().is_human_wait())
585}