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 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 shown(&self) -> store::Shown {
350 if self
351 .terms
352 .iter()
353 .any(|term| matches!(term, Term::Field(Key::Tag, _)))
354 {
355 store::Shown::Definitions
356 } else {
357 store::Shown::Requests
358 }
359 }
360
361 fn statuses(&self) -> Vec<RequestStatus> {
363 let statuses: BTreeSet<_> = self
364 .terms
365 .iter()
366 .filter_map(|term| match term {
367 Term::Status(status) => Some(*status),
368 Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
370 Some(RequestStatus::WaitingHuman)
371 }
372 _ => None,
373 })
374 .collect();
375 match statuses.len() {
376 0 | 1 => statuses
378 .into_iter()
379 .flat_map(|status| {
380 if status == RequestStatus::WaitingHuman {
381 vec![status, RequestStatus::Deferred]
382 } else {
383 vec![status]
384 }
385 })
386 .collect(),
387 _ => vec![*statuses.first().expect("two statuses")],
388 }
389 }
390}
391
392pub(crate) fn quote(key: &str, value: &str) -> String {
394 if value
395 .chars()
396 .any(|character| character.is_whitespace() || character == '"')
397 {
398 format!("{key}:\"{}\"", value.replace('"', ""))
399 } else {
400 format!("{key}:{value}")
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct Completion {
407 pub keep: String,
409 pub candidates: Vec<String>,
411}
412
413pub(crate) fn last_term(text: &str) -> (&str, &str) {
416 match scan(text) {
417 (words, true) => text.split_at(words.last().map_or(0, |word| word.start)),
418 (_, false) => (text, ""),
419 }
420}
421
422pub fn complete(text: &str, entries: &[Entry]) -> Completion {
425 let (keep, term) = last_term(text);
426 let candidates = match term.split_once(':').filter(|_| !term.starts_with('"')) {
428 None if term.starts_with('"') => Vec::new(),
429 None => {
430 let lower = term.to_lowercase();
431 Key::ALL
432 .iter()
433 .map(|key| format!("{}:", key.name()))
434 .filter(|key| key.starts_with(&lower))
435 .collect()
436 }
437 Some((name, partial)) => match Key::parse(name) {
438 None => Vec::new(),
439 Some(key) => {
440 let partial = partial.trim_matches('"').to_lowercase();
441 let values: BTreeSet<String> = match key {
442 Key::Repo => entries
443 .iter()
444 .map(|entry| {
445 Path::new(&entry.repository)
446 .file_name()
447 .map_or(entry.repository.clone(), |name| {
448 name.to_string_lossy().into_owned()
449 })
450 })
451 .collect(),
452 Key::Eval => entries.iter().map(|entry| entry.eval.clone()).collect(),
453 Key::Request => entries.iter().map(|entry| entry.request.clone()).collect(),
454 Key::Reviewer => entries
455 .iter()
456 .filter_map(|entry| entry.reviewer.clone())
457 .collect(),
458 Key::Tag => entries
459 .iter()
460 .flat_map(|entry| entry.tags.iter().cloned())
461 .collect(),
462 Key::Kind => kind_values().collect(),
463 Key::Status => status_values().collect(),
464 Key::Is => Is::ALL.iter().map(|is| is.name().to_owned()).collect(),
465 };
466 values
467 .into_iter()
468 .filter(|value| value.to_lowercase().contains(&partial))
469 .map(|value| quote(key.name(), &value))
470 .collect()
471 }
472 },
473 };
474 Completion {
475 keep: keep.to_owned(),
476 candidates,
477 }
478}
479
480pub async fn suggest(
483 state: &Path,
484 text: &str,
485 reviewer: &ReviewerId,
486) -> Result<Completion, String> {
487 let (keep, term) = last_term(text);
488 let key = term
489 .split_once(':')
490 .filter(|_| !term.starts_with('"'))
491 .and_then(|(name, _)| Key::parse(name));
492 let from_requests = key.is_some_and(|key| {
493 matches!(
494 key,
495 Key::Repo | Key::Eval | Key::Request | Key::Reviewer | Key::Tag
496 )
497 });
498 let shown = match key {
499 Some(Key::Tag) => store::Shown::Definitions,
500 _ => keep
501 .parse::<Query>()
502 .map_or(store::Shown::Requests, |query| query.shown()),
503 };
504 let entries = match keep.parse::<Query>() {
505 Ok(query) if from_requests => page(state, &query, reviewer, 0, shown)
507 .await?
508 .requests
509 .into_iter()
510 .map(|(_, entry)| entry)
511 .collect(),
512 _ => Vec::new(),
513 };
514 Ok(complete(text, &entries))
515}
516
517#[derive(Debug, Clone, Default)]
519pub struct Found {
520 pub requests: Vec<(RequestView, Entry)>,
521 pub next: Option<u64>,
523}
524
525pub async fn search(
533 state: &Path,
534 query: &Query,
535 reviewer: &ReviewerId,
536 start: u64,
537) -> Result<Found, String> {
538 if start == 0 {
539 store::retire_removed_signoffs(state).await?;
540 }
541 page(state, query, reviewer, start, query.shown()).await
542}
543
544async fn page(
546 state: &Path,
547 query: &Query,
548 reviewer: &ReviewerId,
549 start: u64,
550 shown: store::Shown,
551) -> Result<Found, String> {
552 let mut found = Found::default();
553 read_matching(
554 state,
555 query,
556 reviewer,
557 start,
558 shown,
559 |view, entry, position| {
560 found.requests.push((view, entry));
561 if found.requests.len() < PAGE {
562 return ControlFlow::Continue(());
563 }
564 found.next = Some(position + 1);
565 ControlFlow::Break(())
566 },
567 )
568 .await?;
569 Ok(found)
570}
571
572pub async fn count(state: &Path, query: &Query, reviewer: &ReviewerId) -> Result<u64, String> {
575 let mut count = 0;
576 read_matching(state, query, reviewer, 0, query.shown(), |_, _, _| {
577 count += 1;
578 ControlFlow::Continue(())
579 })
580 .await?;
581 Ok(count)
582}
583
584async fn read_matching(
588 state: &Path,
589 query: &Query,
590 reviewer: &ReviewerId,
591 start: u64,
592 shown: store::Shown,
593 mut found: impl FnMut(RequestView, Entry, u64) -> ControlFlow<()>,
594) -> Result<(), String> {
595 let statuses = query.statuses();
596 let mut position = start;
597 loop {
598 let batch = store::read_request_batch(state, &statuses, position, BATCH, shown).await?;
599 let read = batch.len() as u64;
600 for (offset, (view, repo)) in batch.into_iter().enumerate() {
601 if Box::pin(follows_waiting(state, &view)).await {
602 continue;
603 }
604 let entry = Entry::new(&view, &repo, reviewer);
605 if query.matches(&entry) && found(view, entry, position + offset as u64).is_break() {
606 return Ok(());
607 }
608 }
609 if read < u64::from(BATCH) {
610 return Ok(());
611 }
612 position += read;
613 }
614}
615
616async fn follows_waiting(state: &Path, view: &RequestView) -> bool {
618 if !view.request.status().is_human_wait() {
619 return false;
620 }
621 let Some(source) = view
622 .execution
623 .as_ref()
624 .map(|execution| &execution.provenance.request_id)
625 .filter(|source| **source != view.request.id)
626 else {
627 return false;
628 };
629 store::read_request_as(state, source, store::Shown::Requests)
631 .await
632 .is_ok_and(|original| original.request.status().is_human_wait())
633}