Skip to main content

artifactize_core/review/
search.rs

1//! The review list's request search: a query of free text and `key:value` terms, matched
2//! against saved requests, completed from the requests shown, and read page by page from the
3//! state. Every review frontend (the terminal `review` and the desktop app) uses this one rule.
4//!
5//! Terms are ANDed. A plain word matches, case-insensitively, a substring of the repository,
6//! eval, request ID or claimant. A `key:value` term restricts the match to one field:
7//!
8//! | Key | Matches |
9//! |---|---|
10//! | `repo:` | a substring of the repository path |
11//! | `eval:` | a substring of the eval ID |
12//! | `request:` | a substring of the request ID |
13//! | `reviewer:` | a substring of the claimant; unclaimed requests never match |
14//! | `tag:` | a substring of a tag of the eval's Artifact |
15//! | `kind:` | the eval kind exactly: `runtime`, `agent` or `human` |
16//! | `status:` | the request status; `waiting_human` includes `deferred` sign-offs |
17//! | `is:` | `mine`, `claimed` or `unclaimed` (claims exist only on waiting Human requests) |
18//!
19//! Values with spaces are quoted: `repo:"/work/my repo"`. A quoted word is always free text, so
20//! `"type:style"` searches for that text rather than naming a key.
21#[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
32/// The query the review list starts with: the waiting Human sign-offs.
33pub const DEFAULT: &str = "kind:human status:waiting_human";
34
35/// Requests read from the state per batch while a page is filled.
36const BATCH: u32 = 400;
37/// Matching requests per page, as the monitor pages its Runs.
38pub 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/// A claim fact of a request.
85#[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    /// Lowercased text found in any listed column.
107    Text(String),
108    /// Lowercased text found in one field.
109    Field(Key, String),
110    Kind(ProfileKind),
111    Status(RequestStatus),
112    Is(Is),
113}
114
115/// A parsed query; every term must match.
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct Query {
118    terms: Vec<Term>,
119}
120
121/// The fields of one request that a query matches.
122#[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    /// The searchable facts of `view` from repository `repo`, for the current `reviewer`.
136    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
172/// One word of a query: where it starts in the text, its text without quotes, and whether it
173/// began quoted.
174struct Word {
175    start: usize,
176    text: String,
177    quoted: bool,
178}
179
180/// Words of `text`, a double-quoted run counting as one word, and whether the text ends inside
181/// a word (no whitespace after it outside quotes): the term still being typed.
182fn 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
207/// An unquoted word's `key:value` split; a word whose first colon begins a `::`, such as the
208/// Artifact name `combat::Code`, is text to search for.
209pub(crate) fn filter(word: &str) -> Option<(&str, &str)> {
210    word.split_once(':')
211        .filter(|(_, value)| !value.starts_with(':'))
212}
213
214/// Words of `text` and whether each began quoted.
215pub(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    /// The query of `--repo PATH`: the default with that repository's term.
306    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    /// Whether only waiting Human reviews can match, as with the default query.
317    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    /// The statuses every match must have, for the state read to select up front.
354    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                // Claims exist only on waiting Human requests.
361                Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
362                    Some(RequestStatus::WaitingHuman)
363                }
364                _ => None,
365            })
366            .collect();
367        match statuses.len() {
368            // Two different required statuses match nothing; reading one keeps it bounded.
369            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
384/// `key:value`, quoted when the value has spaces or quotes.
385pub(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/// Completion of the query's last term.
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct Completion {
399    /// The query before the completed term; a candidate replaces everything after it.
400    pub keep: String,
401    /// Whole terms, keys first (`repo:`) or `key:value` once a key is typed.
402    pub candidates: Vec<String>,
403}
404
405/// `text` split before the term still being typed, as the parser reads words: a space inside
406/// quotes (`repo:"my r`) stays in the term; after a finished word the term is empty.
407pub(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
414/// The candidates for the query's last term: keys whose names start with it, or after a key
415/// the values of that field among `entries` (fixed vocabularies for `kind:`, `status:`, `is:`).
416pub fn complete(text: &str, entries: &[Entry]) -> Completion {
417    let (keep, term) = last_term(text);
418    // A quoted word is searched for literally: nothing to complete.
419    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
472/// Complete the last term of `text` from the requests the terms before it find (their first
473/// page): what the rest of the query lists, never the half-typed term itself.
474pub 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/// One page of matching requests, newest Run first, and where the next page starts.
503#[derive(Debug, Clone, Default)]
504pub struct Found {
505    pub requests: Vec<(RequestView, Entry)>,
506    /// The position of the next page in the state's request order; `None` on the last page.
507    pub next: Option<u64>,
508}
509
510/// Up to [`PAGE`] requests matching `query` from position `start` on, read in bounded batches.
511///
512/// A waiting Human request that joined another waiting request's execution (a follower) is
513/// listed once, as the original that records its definition and receives every action; a
514/// follower whose original no longer waits is listed as itself.
515pub 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
534/// How many requests `query` finds on all its pages together, each counted as [`search`]
535/// lists it.
536pub 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
546/// Pass each request matching `query` from position `start` on to `found` with its position,
547/// in the state's request order, until `found` breaks or the requests end.
548async 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
576/// A waiting follower whose original still waits: the original's own row lists it.
577async 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}