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/// Words of `text` and whether each began quoted.
208pub(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    /// The query of `--repo PATH`: the default with that repository's term.
299    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    /// Whether only waiting Human reviews can match, as with the default query.
310    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    /// What the state read shows besides the requests: their Runs' definitions only for a
347    /// `tag:` term, the one term matched against them. They hold a Run's whole graph, which
348    /// for a large Run costs seconds and gigabytes to decode for each batch.
349    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    /// The statuses every match must have, for the state read to select up front.
362    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                // Claims exist only on waiting Human requests.
369                Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
370                    Some(RequestStatus::WaitingHuman)
371                }
372                _ => None,
373            })
374            .collect();
375        match statuses.len() {
376            // Two different required statuses match nothing; reading one keeps it bounded.
377            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
392/// `key:value`, quoted when the value has spaces or quotes.
393pub(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/// Completion of the query's last term.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct Completion {
407    /// The query before the completed term; a candidate replaces everything after it.
408    pub keep: String,
409    /// Whole terms, keys first (`repo:`) or `key:value` once a key is typed.
410    pub candidates: Vec<String>,
411}
412
413/// `text` split before the term still being typed, as the parser reads words: a space inside
414/// quotes (`repo:"my r`) stays in the term; after a finished word the term is empty.
415pub(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
422/// The candidates for the query's last term: keys whose names start with it, or after a key
423/// the values of that field among `entries` (fixed vocabularies for `kind:`, `status:`, `is:`).
424pub fn complete(text: &str, entries: &[Entry]) -> Completion {
425    let (keep, term) = last_term(text);
426    // A quoted word is searched for literally: nothing to complete.
427    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
480/// Complete the last term of `text` from the requests the terms before it find (their first
481/// page): what the rest of the query lists, never the half-typed term itself.
482pub 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        // Tags are completed from the definitions the matches' Runs saved.
506        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/// One page of matching requests, newest Run first, and where the next page starts.
518#[derive(Debug, Clone, Default)]
519pub struct Found {
520    pub requests: Vec<(RequestView, Entry)>,
521    /// The position of the next page in the state's request order; `None` on the last page.
522    pub next: Option<u64>,
523}
524
525/// Up to [`PAGE`] requests matching `query` from position `start` on, read in bounded batches.
526///
527/// A waiting Human request that joined another waiting request's execution (a follower) is
528/// listed once, as the original that records its definition and receives every action; a
529/// follower whose original no longer waits is listed as itself. The first page first ends the
530/// sign-offs whose repository folder is gone ([`store::retire_removed_signoffs`]): nothing can
531/// be reviewed in them any more.
532pub 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
544/// One [`search`] page, its views showing what `shown` names besides their requests.
545async 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
572/// How many requests `query` finds on all its pages together, each counted as [`search`]
573/// lists it.
574pub 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
584/// Pass each request matching `query` from position `start` on to `found` with its position,
585/// in the state's request order, until `found` breaks or the requests end. Each view shows what
586/// `shown` names besides its request.
587async 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
616/// A waiting follower whose original still waits: the original's own row lists it.
617async 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    // Only the original's status matters, never what its Run's definitions hold.
630    store::read_request_as(state, source, store::Shown::Requests)
631        .await
632        .is_ok_and(|original| original.request.status().is_human_wait())
633}