Skip to main content

artifactize_core/monitor/live/
query.rs

1//! The pane's search: free text and `key:value` terms that narrow the branches and the waiting
2//! reviews alike, and the order of the branches.
3//!
4//! Terms are ANDed. A plain word matches, case-insensitively, part of a project or a branch,
5//! or of a waiting review's eval. A `key:value` term restricts the match to one field:
6//!
7//! | Key | Matches |
8//! |---|---|
9//! | `repo:` | part of the project's name |
10//! | `branch:` | part of the branch's name |
11//! | `sort:` | not a filter: `recent` (the most recent Run first, branches without Runs last) or `name` (by project, then branch) |
12//!
13//! Values with spaces are quoted, and a quoted word is always free text, as in the review
14//! search ([`crate::review::search`]), whose words this one reads.
15use super::{Branch, Place, Waiting};
16use crate::review::search::{self, Completion};
17use std::collections::BTreeSet;
18
19/// The search the pane starts with: every branch, the most recent Run first.
20pub const DEFAULT: &str = "sort:recent";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum Key {
24    Repo,
25    Branch,
26    Sort,
27}
28
29impl Key {
30    const ALL: [Self; 3] = [Self::Repo, Self::Branch, Self::Sort];
31
32    fn name(self) -> &'static str {
33        match self {
34            Self::Repo => "repo",
35            Self::Branch => "branch",
36            Self::Sort => "sort",
37        }
38    }
39
40    fn parse(name: &str) -> Option<Self> {
41        Self::ALL
42            .into_iter()
43            .find(|key| key.name().eq_ignore_ascii_case(name))
44    }
45}
46
47/// How the branches are ordered.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub enum Sort {
50    /// The branch with the most recent Run first; branches without Runs last.
51    #[default]
52    Recent,
53    /// By project, then branch.
54    Name,
55}
56
57impl Sort {
58    const ALL: [Self; 2] = [Self::Recent, Self::Name];
59
60    fn name(self) -> &'static str {
61        match self {
62            Self::Recent => "recent",
63            Self::Name => "name",
64        }
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69enum Term {
70    /// Lowercased text found in any field.
71    Text(String),
72    /// Lowercased text found in the project's name.
73    Repo(String),
74    /// Lowercased text found in the branch's name.
75    Branch(String),
76}
77
78/// A parsed search; every term must match.
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct Query {
81    terms: Vec<Term>,
82    sort: Sort,
83}
84
85impl std::str::FromStr for Query {
86    type Err = String;
87
88    fn from_str(text: &str) -> Result<Self, String> {
89        let mut query = Self::default();
90        let mut sorted = false;
91        for (word, quoted) in search::words(text) {
92            let Some((name, value)) = word.split_once(':').filter(|_| !quoted) else {
93                query.terms.push(Term::Text(word.to_lowercase()));
94                continue;
95            };
96            let key = Key::parse(name).ok_or_else(|| {
97                format!(
98                    "Unknown key `{name}:`; use repo:, branch: or sort:, or quote the word to search for it."
99                )
100            })?;
101            if value.is_empty() {
102                return Err(format!("`{}:` needs a value.", key.name()));
103            }
104            let lower = value.to_lowercase();
105            match key {
106                Key::Repo => query.terms.push(Term::Repo(lower)),
107                Key::Branch => query.terms.push(Term::Branch(lower)),
108                Key::Sort if sorted => return Err("Use one sort: term.".into()),
109                Key::Sort => {
110                    query.sort = Sort::ALL
111                        .into_iter()
112                        .find(|sort| sort.name() == lower)
113                        .ok_or_else(|| {
114                            format!("Unknown order `sort:{value}`; use sort:recent or sort:name.")
115                        })?;
116                    sorted = true;
117                }
118            }
119        }
120        Ok(query)
121    }
122}
123
124fn contains(field: &str, text: &str) -> bool {
125    field.to_lowercase().contains(text)
126}
127
128impl Query {
129    pub fn sort(&self) -> Sort {
130        self.sort
131    }
132
133    fn matches(&self, project: &str, branch: &str, eval: Option<&str>) -> bool {
134        self.terms.iter().all(|term| match term {
135            Term::Text(text) => [Some(project), Some(branch), eval]
136                .into_iter()
137                .flatten()
138                .any(|field| contains(field, text)),
139            Term::Repo(text) => contains(project, text),
140            Term::Branch(text) => contains(branch, text),
141        })
142    }
143
144    pub fn branch(&self, branch: &Branch) -> bool {
145        self.matches(&branch.project, &branch.name, None)
146    }
147
148    pub fn waiting(&self, waiting: &Waiting) -> bool {
149        self.matches(
150            &waiting.label.project,
151            &waiting.label.branch,
152            Some(&waiting.entry.eval),
153        )
154    }
155}
156
157/// How `sort` orders two branches; ties go by project, then branch.
158fn compare(sort: Sort, a: &Branch, b: &Branch) -> std::cmp::Ordering {
159    let names = (&a.project, &a.name, &a.place).cmp(&(&b.project, &b.name, &b.place));
160    match sort {
161        // Newest first: a later Run is the greater time; without Runs is the least.
162        Sort::Recent => b.latest().cmp(&a.latest()).then(names),
163        Sort::Name => names,
164    }
165}
166
167/// Order `branches` by `sort`.
168pub(super) fn order(branches: &mut [Branch], sort: Sort) {
169    branches.sort_by(|a, b| compare(sort, a, b));
170}
171
172/// The branches and waiting reviews `query` keeps: the branches `starred` first, each group in
173/// the query's order, and the waiting reviews as they came (newest first).
174pub fn show<'a>(
175    query: &Query,
176    branches: &'a [Branch],
177    waiting: &'a [Waiting],
178    starred: &BTreeSet<Place>,
179) -> (Vec<&'a Branch>, Vec<&'a Waiting>) {
180    let mut kept: Vec<&Branch> = branches
181        .iter()
182        .filter(|branch| query.branch(branch))
183        .collect();
184    let first = |branch: &Branch| !starred.contains(&branch.place);
185    kept.sort_by(|a, b| {
186        first(a)
187            .cmp(&first(b))
188            .then_with(|| compare(query.sort, a, b))
189    });
190    let waiting = waiting
191        .iter()
192        .filter(|waiting| query.waiting(waiting))
193        .collect();
194    (kept, waiting)
195}
196
197/// The candidates for the search's last term: keys whose names start with it, or after a key
198/// the projects or branches listed, or the orders.
199pub fn complete(text: &str, branches: &[Branch], waiting: &[Waiting]) -> Completion {
200    let (keep, term) = search::last_term(text);
201    let candidates = match term.split_once(':').filter(|_| !term.starts_with('"')) {
202        // A quoted word is searched for literally: nothing to complete.
203        None if term.starts_with('"') => Vec::new(),
204        None => {
205            let lower = term.to_lowercase();
206            Key::ALL
207                .iter()
208                .map(|key| format!("{}:", key.name()))
209                .filter(|key| key.starts_with(&lower))
210                .collect()
211        }
212        Some((name, partial)) => match Key::parse(name) {
213            None => Vec::new(),
214            Some(key) => {
215                let partial = partial.trim_matches('"').to_lowercase();
216                let values: BTreeSet<String> = match key {
217                    Key::Repo => branches
218                        .iter()
219                        .map(|branch| branch.project.clone())
220                        .chain(waiting.iter().map(|waiting| waiting.label.project.clone()))
221                        .collect(),
222                    Key::Branch => branches
223                        .iter()
224                        .map(|branch| branch.name.clone())
225                        .chain(waiting.iter().map(|waiting| waiting.label.branch.clone()))
226                        .collect(),
227                    Key::Sort => Sort::ALL
228                        .iter()
229                        .map(|sort| sort.name().to_owned())
230                        .collect(),
231                };
232                values
233                    .into_iter()
234                    .filter(|value| !value.is_empty() && value.to_lowercase().contains(&partial))
235                    .map(|value| search::quote(key.name(), &value))
236                    .collect()
237            }
238        },
239    };
240    Completion {
241        keep: keep.to_owned(),
242        candidates,
243    }
244}