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`, `human` or `dependency` |
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, 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.
208fn 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    /// The statuses every match must have, for the state read to select up front.
347    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                // Claims exist only on waiting Human requests.
354                Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
355                    Some(RequestStatus::WaitingHuman)
356                }
357                _ => None,
358            })
359            .collect();
360        match statuses.len() {
361            // Two different required statuses match nothing; reading one keeps it bounded.
362            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
377/// `key:value`, quoted when the value has spaces or quotes.
378fn 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/// Completion of the query's last term.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct Completion {
392    /// The query before the completed term; a candidate replaces everything after it.
393    pub keep: String,
394    /// Whole terms, keys first (`repo:`) or `key:value` once a key is typed.
395    pub candidates: Vec<String>,
396}
397
398/// `text` split before the term still being typed, as the parser reads words: a space inside
399/// quotes (`repo:"my r`) stays in the term; after a finished word the term is empty.
400fn 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
407/// The candidates for the query's last term: keys whose names start with it, or after a key
408/// the values of that field among `entries` (fixed vocabularies for `kind:`, `status:`, `is:`).
409pub fn complete(text: &str, entries: &[Entry]) -> Completion {
410    let (keep, term) = last_term(text);
411    // A quoted word is searched for literally: nothing to complete.
412    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
465/// Complete the last term of `text` from the requests the terms before it find (their first
466/// page): what the rest of the query lists, never the half-typed term itself.
467pub 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/// One page of matching requests, newest Run first, and where the next page starts.
496#[derive(Debug, Clone, Default)]
497pub struct Found {
498    pub requests: Vec<(RequestView, Entry)>,
499    /// The position of the next page in the state's request order; `None` on the last page.
500    pub next: Option<u64>,
501}
502
503/// Up to [`PAGE`] requests matching `query` from position `start` on, read in bounded batches.
504///
505/// A waiting Human request that joined another waiting request's execution (a follower) is
506/// listed once, as the original that records its definition and receives every action; a
507/// follower whose original no longer waits is listed as itself.
508pub async fn search(
509    state: &Path,
510    query: &Query,
511    reviewer: &ReviewerId,
512    start: u64,
513) -> Result<Found, String> {
514    let statuses = query.statuses();
515    let mut found = Found::default();
516    let mut position = start;
517    loop {
518        let batch = store::read_request_batch(state, &statuses, position, BATCH).await?;
519        let read = batch.len() as u64;
520        for (offset, (view, repo)) in batch.into_iter().enumerate() {
521            if Box::pin(follows_waiting(state, &view)).await {
522                continue;
523            }
524            let entry = Entry::new(&view, &repo, reviewer);
525            if query.matches(&entry) {
526                found.requests.push((view, entry));
527                if found.requests.len() == PAGE {
528                    found.next = Some(position + offset as u64 + 1);
529                    return Ok(found);
530                }
531            }
532        }
533        if read < u64::from(BATCH) {
534            return Ok(found);
535        }
536        position += read;
537    }
538}
539
540/// A waiting follower whose original still waits: the original's own row lists it.
541async fn follows_waiting(state: &Path, view: &RequestView) -> bool {
542    if !view.request.status().is_human_wait() {
543        return false;
544    }
545    let Some(source) = view
546        .execution
547        .as_ref()
548        .map(|execution| &execution.provenance.request_id)
549        .filter(|source| **source != view.request.id)
550    else {
551        return false;
552    };
553    store::read_request(state, source)
554        .await
555        .is_ok_and(|original| original.request.status().is_human_wait())
556}