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
207pub(crate) fn filter(word: &str) -> Option<(&str, &str)> {
210 word.split_once(':')
211 .filter(|(_, value)| !value.starts_with(':'))
212}
213
214pub(crate) fn words(text: &str) -> Vec<(String, bool)> {
216 scan(text)
217 .0
218 .into_iter()
219 .map(|word| (word.text, word.quoted))
220 .collect()
221}
222
223impl std::str::FromStr for Query {
224 type Err = String;
225
226 fn from_str(text: &str) -> Result<Self, String> {
227 let terms = words(text)
228 .into_iter()
229 .map(|(word, quoted)| {
230 let Some((name, value)) = filter(&word).filter(|_| !quoted) else {
231 return Ok(Term::Text(word.to_lowercase()));
232 };
233 let key = Key::parse(name).ok_or_else(|| {
234 format!(
235 "Unknown filter key `{name}:`; use {}, or quote the word to search for it.",
236 keys()
237 )
238 })?;
239 if value.is_empty() {
240 return Err(format!("`{}:` needs a value.", key.name()));
241 }
242 let lower = value.to_lowercase();
243 match key {
244 Key::Kind => ProfileKind::value_variants()
245 .iter()
246 .copied()
247 .find(|kind| kind_name(*kind) == lower)
248 .map(Term::Kind)
249 .ok_or_else(|| {
250 format!("Unknown kind `{value}`; use {}.", list(kind_values()))
251 }),
252 Key::Status => RequestStatus::ALL
253 .iter()
254 .copied()
255 .find(|status| status_name(*status) == lower)
256 .map(Term::Status)
257 .ok_or_else(|| {
258 format!("Unknown status `{value}`; use {}.", list(status_values()))
259 }),
260 Key::Is => Is::ALL
261 .into_iter()
262 .find(|is| is.name() == lower)
263 .map(Term::Is)
264 .ok_or_else(|| {
265 format!(
266 "Unknown qualifier `is:{value}`; use {}.",
267 list(Is::ALL.iter().map(|is| format!("is:{}", is.name())))
268 )
269 }),
270 key => Ok(Term::Field(key, lower)),
271 }
272 })
273 .collect::<Result<_, String>>()?;
274 Ok(Self { terms })
275 }
276}
277
278fn list(items: impl IntoIterator<Item = String>) -> String {
279 let items: Vec<String> = items.into_iter().collect();
280 match items.split_last() {
281 Some((last, rest)) if !rest.is_empty() => format!("{} or {last}", rest.join(", ")),
282 _ => items.join(""),
283 }
284}
285
286fn keys() -> String {
287 list(Key::ALL.iter().map(|key| format!("{}:", key.name())))
288}
289
290fn kind_values() -> impl Iterator<Item = String> {
291 ProfileKind::value_variants()
292 .iter()
293 .map(|kind| kind_name(*kind).to_owned())
294}
295
296fn status_values() -> impl Iterator<Item = String> {
297 RequestStatus::ALL.iter().map(|status| status_name(*status))
298}
299
300fn contains(field: &str, text: &str) -> bool {
301 field.to_lowercase().contains(text)
302}
303
304impl Query {
305 pub fn text_for(repo: Option<&Path>) -> String {
307 match repo {
308 Some(repo) => format!(
309 "{DEFAULT} {}",
310 quote("repo", &crate::platform::path_text(repo))
311 ),
312 None => DEFAULT.to_owned(),
313 }
314 }
315
316 pub fn waiting_human(&self) -> bool {
318 self.terms.contains(&Term::Kind(ProfileKind::Human))
319 && self
320 .terms
321 .contains(&Term::Status(RequestStatus::WaitingHuman))
322 }
323
324 pub fn matches(&self, entry: &Entry) -> bool {
325 self.terms.iter().all(|term| match term {
326 Term::Text(text) => [
327 Some(entry.repository.as_str()),
328 Some(entry.eval.as_str()),
329 Some(entry.request.as_str()),
330 entry.reviewer.as_deref(),
331 ]
332 .into_iter()
333 .flatten()
334 .any(|field| contains(field, text)),
335 Term::Field(Key::Repo, text) => contains(&entry.repository, text),
336 Term::Field(Key::Eval, text) => contains(&entry.eval, text),
337 Term::Field(Key::Request, text) => contains(&entry.request, text),
338 Term::Field(Key::Reviewer, text) => entry
339 .reviewer
340 .as_deref()
341 .is_some_and(|reviewer| contains(reviewer, text)),
342 Term::Field(Key::Tag, text) => entry.tags.iter().any(|tag| contains(tag, text)),
343 Term::Field(_, _) => unreachable!("kind, status and is parse to their own terms"),
344 Term::Kind(kind) => entry.kind == *kind,
345 Term::Status(RequestStatus::WaitingHuman) => entry.status.is_human_wait(),
346 Term::Status(status) => entry.status == *status,
347 Term::Is(Is::Mine) => entry.mine,
348 Term::Is(Is::Claimed) => entry.reviewer.is_some(),
349 Term::Is(Is::Unclaimed) => entry.status.is_human_wait() && entry.reviewer.is_none(),
350 })
351 }
352
353 fn statuses(&self) -> Vec<RequestStatus> {
355 let statuses: BTreeSet<_> = self
356 .terms
357 .iter()
358 .filter_map(|term| match term {
359 Term::Status(status) => Some(*status),
360 Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
362 Some(RequestStatus::WaitingHuman)
363 }
364 _ => None,
365 })
366 .collect();
367 match statuses.len() {
368 0 | 1 => statuses
370 .into_iter()
371 .flat_map(|status| {
372 if status == RequestStatus::WaitingHuman {
373 vec![status, RequestStatus::Deferred]
374 } else {
375 vec![status]
376 }
377 })
378 .collect(),
379 _ => vec![*statuses.first().expect("two statuses")],
380 }
381 }
382}
383
384pub(crate) fn quote(key: &str, value: &str) -> String {
386 if value
387 .chars()
388 .any(|character| character.is_whitespace() || character == '"')
389 {
390 format!("{key}:\"{}\"", value.replace('"', ""))
391 } else {
392 format!("{key}:{value}")
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct Completion {
399 pub keep: String,
401 pub candidates: Vec<String>,
403}
404
405pub(crate) fn last_term(text: &str) -> (&str, &str) {
408 match scan(text) {
409 (words, true) => text.split_at(words.last().map_or(0, |word| word.start)),
410 (_, false) => (text, ""),
411 }
412}
413
414pub fn complete(text: &str, entries: &[Entry]) -> Completion {
417 let (keep, term) = last_term(text);
418 let candidates = match term.split_once(':').filter(|_| !term.starts_with('"')) {
420 None if term.starts_with('"') => Vec::new(),
421 None => {
422 let lower = term.to_lowercase();
423 Key::ALL
424 .iter()
425 .map(|key| format!("{}:", key.name()))
426 .filter(|key| key.starts_with(&lower))
427 .collect()
428 }
429 Some((name, partial)) => match Key::parse(name) {
430 None => Vec::new(),
431 Some(key) => {
432 let partial = partial.trim_matches('"').to_lowercase();
433 let values: BTreeSet<String> = match key {
434 Key::Repo => entries
435 .iter()
436 .map(|entry| {
437 Path::new(&entry.repository)
438 .file_name()
439 .map_or(entry.repository.clone(), |name| {
440 name.to_string_lossy().into_owned()
441 })
442 })
443 .collect(),
444 Key::Eval => entries.iter().map(|entry| entry.eval.clone()).collect(),
445 Key::Request => entries.iter().map(|entry| entry.request.clone()).collect(),
446 Key::Reviewer => entries
447 .iter()
448 .filter_map(|entry| entry.reviewer.clone())
449 .collect(),
450 Key::Tag => entries
451 .iter()
452 .flat_map(|entry| entry.tags.iter().cloned())
453 .collect(),
454 Key::Kind => kind_values().collect(),
455 Key::Status => status_values().collect(),
456 Key::Is => Is::ALL.iter().map(|is| is.name().to_owned()).collect(),
457 };
458 values
459 .into_iter()
460 .filter(|value| value.to_lowercase().contains(&partial))
461 .map(|value| quote(key.name(), &value))
462 .collect()
463 }
464 },
465 };
466 Completion {
467 keep: keep.to_owned(),
468 candidates,
469 }
470}
471
472pub async fn suggest(
475 state: &Path,
476 text: &str,
477 reviewer: &ReviewerId,
478) -> Result<Completion, String> {
479 let (keep, term) = last_term(text);
480 let from_requests = term
481 .split_once(':')
482 .filter(|_| !term.starts_with('"'))
483 .and_then(|(name, _)| Key::parse(name))
484 .is_some_and(|key| {
485 matches!(
486 key,
487 Key::Repo | Key::Eval | Key::Request | Key::Reviewer | Key::Tag
488 )
489 });
490 let entries = match keep.parse::<Query>() {
491 Ok(query) if from_requests => search(state, &query, reviewer, 0)
492 .await?
493 .requests
494 .into_iter()
495 .map(|(_, entry)| entry)
496 .collect(),
497 _ => Vec::new(),
498 };
499 Ok(complete(text, &entries))
500}
501
502#[derive(Debug, Clone, Default)]
504pub struct Found {
505 pub requests: Vec<(RequestView, Entry)>,
506 pub next: Option<u64>,
508}
509
510pub async fn search(
516 state: &Path,
517 query: &Query,
518 reviewer: &ReviewerId,
519 start: u64,
520) -> Result<Found, String> {
521 let mut found = Found::default();
522 read_matching(state, query, reviewer, start, |view, entry, position| {
523 found.requests.push((view, entry));
524 if found.requests.len() < PAGE {
525 return ControlFlow::Continue(());
526 }
527 found.next = Some(position + 1);
528 ControlFlow::Break(())
529 })
530 .await?;
531 Ok(found)
532}
533
534pub async fn count(state: &Path, query: &Query, reviewer: &ReviewerId) -> Result<u64, String> {
537 let mut count = 0;
538 read_matching(state, query, reviewer, 0, |_, _, _| {
539 count += 1;
540 ControlFlow::Continue(())
541 })
542 .await?;
543 Ok(count)
544}
545
546async fn read_matching(
549 state: &Path,
550 query: &Query,
551 reviewer: &ReviewerId,
552 start: u64,
553 mut found: impl FnMut(RequestView, Entry, u64) -> ControlFlow<()>,
554) -> Result<(), String> {
555 let statuses = query.statuses();
556 let mut position = start;
557 loop {
558 let batch = store::read_request_batch(state, &statuses, position, BATCH).await?;
559 let read = batch.len() as u64;
560 for (offset, (view, repo)) in batch.into_iter().enumerate() {
561 if Box::pin(follows_waiting(state, &view)).await {
562 continue;
563 }
564 let entry = Entry::new(&view, &repo, reviewer);
565 if query.matches(&entry) && found(view, entry, position + offset as u64).is_break() {
566 return Ok(());
567 }
568 }
569 if read < u64::from(BATCH) {
570 return Ok(());
571 }
572 position += read;
573 }
574}
575
576async fn follows_waiting(state: &Path, view: &RequestView) -> bool {
578 if !view.request.status().is_human_wait() {
579 return false;
580 }
581 let Some(source) = view
582 .execution
583 .as_ref()
584 .map(|execution| &execution.provenance.request_id)
585 .filter(|source| **source != view.request.id)
586 else {
587 return false;
588 };
589 store::read_request(state, source)
590 .await
591 .is_ok_and(|original| original.request.status().is_human_wait())
592}