#[cfg(test)]
mod tests;
use crate::{
config::ProfileKind,
store::{self, RequestView},
types::{RequestStatus, ReviewerId},
};
use clap::ValueEnum;
use std::{collections::BTreeSet, path::Path};
pub const DEFAULT: &str = "kind:human status:waiting_human";
const BATCH: u32 = 400;
pub const PAGE: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Key {
Repo,
Eval,
Request,
Reviewer,
Tag,
Kind,
Status,
Is,
}
impl Key {
const ALL: [Self; 8] = [
Self::Repo,
Self::Eval,
Self::Request,
Self::Reviewer,
Self::Tag,
Self::Kind,
Self::Status,
Self::Is,
];
fn name(self) -> &'static str {
match self {
Self::Repo => "repo",
Self::Eval => "eval",
Self::Request => "request",
Self::Reviewer => "reviewer",
Self::Tag => "tag",
Self::Kind => "kind",
Self::Status => "status",
Self::Is => "is",
}
}
fn parse(name: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|key| key.name().eq_ignore_ascii_case(name))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Is {
Mine,
Claimed,
Unclaimed,
}
impl Is {
const ALL: [Self; 3] = [Self::Mine, Self::Claimed, Self::Unclaimed];
fn name(self) -> &'static str {
match self {
Self::Mine => "mine",
Self::Claimed => "claimed",
Self::Unclaimed => "unclaimed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Term {
Text(String),
Field(Key, String),
Kind(ProfileKind),
Status(RequestStatus),
Is(Is),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Query {
terms: Vec<Term>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub repository: String,
pub eval: String,
pub request: String,
pub reviewer: Option<String>,
pub mine: bool,
pub kind: ProfileKind,
pub status: RequestStatus,
pub tags: Vec<String>,
}
impl Entry {
pub fn new(view: &RequestView, repo: &Path, reviewer: &ReviewerId) -> Self {
let request = &view.request;
let tags = view
.definition
.as_ref()
.and_then(|definition| definition.artifact.tags.value())
.or_else(|| {
let artifacts = request.human_definition.as_ref()?.artifacts.value()?;
artifacts.get(&request.target)?.tags.value()
})
.cloned()
.unwrap_or_default();
Self {
repository: crate::platform::path_text(repo),
eval: request.eval_id.to_string(),
request: request.id.to_string(),
reviewer: view.claim.as_ref().map(|claim| claim.reviewer.to_string()),
mine: view
.claim
.as_ref()
.is_some_and(|claim| &claim.reviewer == reviewer),
kind: request.profile.kind(),
status: request.status(),
tags,
}
}
}
fn kind_name(kind: ProfileKind) -> &'static str {
kind.name()
}
fn status_name(status: RequestStatus) -> String {
status.as_str().to_ascii_lowercase()
}
struct Word {
start: usize,
text: String,
quoted: bool,
}
fn scan(text: &str) -> (Vec<Word>, bool) {
let mut words = Vec::new();
let mut word: Option<Word> = None;
let mut quoted = false;
for (index, character) in text.char_indices() {
if character.is_whitespace() && !quoted {
words.extend(word.take());
continue;
}
let current = word.get_or_insert_with(|| Word {
start: index,
text: String::new(),
quoted: character == '"',
});
if character == '"' {
quoted = !quoted;
} else {
current.text.push(character);
}
}
let open = word.is_some();
words.extend(word);
(words, open)
}
fn words(text: &str) -> Vec<(String, bool)> {
scan(text)
.0
.into_iter()
.map(|word| (word.text, word.quoted))
.collect()
}
impl std::str::FromStr for Query {
type Err = String;
fn from_str(text: &str) -> Result<Self, String> {
let terms = words(text)
.into_iter()
.map(|(word, quoted)| {
let Some((name, value)) = word.split_once(':').filter(|_| !quoted) else {
return Ok(Term::Text(word.to_lowercase()));
};
let key = Key::parse(name).ok_or_else(|| {
format!(
"Unknown filter key `{name}:`; use {}, or quote the word to search for it.",
keys()
)
})?;
if value.is_empty() {
return Err(format!("`{}:` needs a value.", key.name()));
}
let lower = value.to_lowercase();
match key {
Key::Kind => ProfileKind::value_variants()
.iter()
.copied()
.find(|kind| kind_name(*kind) == lower)
.map(Term::Kind)
.ok_or_else(|| {
format!("Unknown kind `{value}`; use {}.", list(kind_values()))
}),
Key::Status => RequestStatus::ALL
.iter()
.copied()
.find(|status| status_name(*status) == lower)
.map(Term::Status)
.ok_or_else(|| {
format!("Unknown status `{value}`; use {}.", list(status_values()))
}),
Key::Is => Is::ALL
.into_iter()
.find(|is| is.name() == lower)
.map(Term::Is)
.ok_or_else(|| {
format!(
"Unknown qualifier `is:{value}`; use {}.",
list(Is::ALL.iter().map(|is| format!("is:{}", is.name())))
)
}),
key => Ok(Term::Field(key, lower)),
}
})
.collect::<Result<_, String>>()?;
Ok(Self { terms })
}
}
fn list(items: impl IntoIterator<Item = String>) -> String {
let items: Vec<String> = items.into_iter().collect();
match items.split_last() {
Some((last, rest)) if !rest.is_empty() => format!("{} or {last}", rest.join(", ")),
_ => items.join(""),
}
}
fn keys() -> String {
list(Key::ALL.iter().map(|key| format!("{}:", key.name())))
}
fn kind_values() -> impl Iterator<Item = String> {
ProfileKind::value_variants()
.iter()
.map(|kind| kind_name(*kind).to_owned())
}
fn status_values() -> impl Iterator<Item = String> {
RequestStatus::ALL.iter().map(|status| status_name(*status))
}
fn contains(field: &str, text: &str) -> bool {
field.to_lowercase().contains(text)
}
impl Query {
pub fn text_for(repo: Option<&Path>) -> String {
match repo {
Some(repo) => format!(
"{DEFAULT} {}",
quote("repo", &crate::platform::path_text(repo))
),
None => DEFAULT.to_owned(),
}
}
pub fn waiting_human(&self) -> bool {
self.terms.contains(&Term::Kind(ProfileKind::Human))
&& self
.terms
.contains(&Term::Status(RequestStatus::WaitingHuman))
}
pub fn matches(&self, entry: &Entry) -> bool {
self.terms.iter().all(|term| match term {
Term::Text(text) => [
Some(entry.repository.as_str()),
Some(entry.eval.as_str()),
Some(entry.request.as_str()),
entry.reviewer.as_deref(),
]
.into_iter()
.flatten()
.any(|field| contains(field, text)),
Term::Field(Key::Repo, text) => contains(&entry.repository, text),
Term::Field(Key::Eval, text) => contains(&entry.eval, text),
Term::Field(Key::Request, text) => contains(&entry.request, text),
Term::Field(Key::Reviewer, text) => entry
.reviewer
.as_deref()
.is_some_and(|reviewer| contains(reviewer, text)),
Term::Field(Key::Tag, text) => entry.tags.iter().any(|tag| contains(tag, text)),
Term::Field(_, _) => unreachable!("kind, status and is parse to their own terms"),
Term::Kind(kind) => entry.kind == *kind,
Term::Status(RequestStatus::WaitingHuman) => entry.status.is_human_wait(),
Term::Status(status) => entry.status == *status,
Term::Is(Is::Mine) => entry.mine,
Term::Is(Is::Claimed) => entry.reviewer.is_some(),
Term::Is(Is::Unclaimed) => entry.status.is_human_wait() && entry.reviewer.is_none(),
})
}
fn statuses(&self) -> Vec<RequestStatus> {
let statuses: BTreeSet<_> = self
.terms
.iter()
.filter_map(|term| match term {
Term::Status(status) => Some(*status),
Term::Is(Is::Mine | Is::Claimed | Is::Unclaimed) => {
Some(RequestStatus::WaitingHuman)
}
_ => None,
})
.collect();
match statuses.len() {
0 | 1 => statuses
.into_iter()
.flat_map(|status| {
if status == RequestStatus::WaitingHuman {
vec![status, RequestStatus::Deferred]
} else {
vec![status]
}
})
.collect(),
_ => vec![*statuses.first().expect("two statuses")],
}
}
}
fn quote(key: &str, value: &str) -> String {
if value
.chars()
.any(|character| character.is_whitespace() || character == '"')
{
format!("{key}:\"{}\"", value.replace('"', ""))
} else {
format!("{key}:{value}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Completion {
pub keep: String,
pub candidates: Vec<String>,
}
fn last_term(text: &str) -> (&str, &str) {
match scan(text) {
(words, true) => text.split_at(words.last().map_or(0, |word| word.start)),
(_, false) => (text, ""),
}
}
pub fn complete(text: &str, entries: &[Entry]) -> Completion {
let (keep, term) = last_term(text);
let candidates = match term.split_once(':').filter(|_| !term.starts_with('"')) {
None if term.starts_with('"') => Vec::new(),
None => {
let lower = term.to_lowercase();
Key::ALL
.iter()
.map(|key| format!("{}:", key.name()))
.filter(|key| key.starts_with(&lower))
.collect()
}
Some((name, partial)) => match Key::parse(name) {
None => Vec::new(),
Some(key) => {
let partial = partial.trim_matches('"').to_lowercase();
let values: BTreeSet<String> = match key {
Key::Repo => entries
.iter()
.map(|entry| {
Path::new(&entry.repository)
.file_name()
.map_or(entry.repository.clone(), |name| {
name.to_string_lossy().into_owned()
})
})
.collect(),
Key::Eval => entries.iter().map(|entry| entry.eval.clone()).collect(),
Key::Request => entries.iter().map(|entry| entry.request.clone()).collect(),
Key::Reviewer => entries
.iter()
.filter_map(|entry| entry.reviewer.clone())
.collect(),
Key::Tag => entries
.iter()
.flat_map(|entry| entry.tags.iter().cloned())
.collect(),
Key::Kind => kind_values().collect(),
Key::Status => status_values().collect(),
Key::Is => Is::ALL.iter().map(|is| is.name().to_owned()).collect(),
};
values
.into_iter()
.filter(|value| value.to_lowercase().contains(&partial))
.map(|value| quote(key.name(), &value))
.collect()
}
},
};
Completion {
keep: keep.to_owned(),
candidates,
}
}
pub async fn suggest(
state: &Path,
text: &str,
reviewer: &ReviewerId,
) -> Result<Completion, String> {
let (keep, term) = last_term(text);
let from_requests = term
.split_once(':')
.filter(|_| !term.starts_with('"'))
.and_then(|(name, _)| Key::parse(name))
.is_some_and(|key| {
matches!(
key,
Key::Repo | Key::Eval | Key::Request | Key::Reviewer | Key::Tag
)
});
let entries = match keep.parse::<Query>() {
Ok(query) if from_requests => search(state, &query, reviewer, 0)
.await?
.requests
.into_iter()
.map(|(_, entry)| entry)
.collect(),
_ => Vec::new(),
};
Ok(complete(text, &entries))
}
#[derive(Debug, Clone, Default)]
pub struct Found {
pub requests: Vec<(RequestView, Entry)>,
pub next: Option<u64>,
}
pub async fn search(
state: &Path,
query: &Query,
reviewer: &ReviewerId,
start: u64,
) -> Result<Found, String> {
let statuses = query.statuses();
let mut found = Found::default();
let mut position = start;
loop {
let batch = store::read_request_batch(state, &statuses, position, BATCH).await?;
let read = batch.len() as u64;
for (offset, (view, repo)) in batch.into_iter().enumerate() {
if Box::pin(follows_waiting(state, &view)).await {
continue;
}
let entry = Entry::new(&view, &repo, reviewer);
if query.matches(&entry) {
found.requests.push((view, entry));
if found.requests.len() == PAGE {
found.next = Some(position + offset as u64 + 1);
return Ok(found);
}
}
}
if read < u64::from(BATCH) {
return Ok(found);
}
position += read;
}
}
async fn follows_waiting(state: &Path, view: &RequestView) -> bool {
if !view.request.status().is_human_wait() {
return false;
}
let Some(source) = view
.execution
.as_ref()
.map(|execution| &execution.provenance.request_id)
.filter(|source| **source != view.request.id)
else {
return false;
};
store::read_request(state, source)
.await
.is_ok_and(|original| original.request.status().is_human_wait())
}