facett-search/src/lib.rs
//! **facett-search** — the ordning query-language parser + a search-bar facet.
//!
//! One grammar, used everywhere (search, the command line, saved views, boards,
//! automation conditions). This crate owns the **parser** (the valuable, pure,
//! well-tested core) and a thin egui **search bar** that parses-as-you-type and
//! shows the parsed clauses as chips.
//!
//! Grammar (a YouTrack-flavored subset; see manual ch.10):
//! ```text
//! attr:value equals
//! attr:v1,v2 OR within a field (comma)
//! -attr:value exclude
//! attr:lo..hi range
//! has:x has:-x existence
//! #shortcut a saved shorthand (#me, #overdue, #unresolved)
//! sort:field ordering (sort:field-desc for descending)
//! bare words full-text terms
//! ```
//!
//! The parser is `no-egui` logic; the facet is a thin wrapper. Destined for
//! facett (implements the `Facet` shape).
use serde::{Deserialize, Serialize};
/// A parsed query.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Query {
pub clauses: Vec<Clause>,
/// Bare full-text terms.
pub text: Vec<String>,
pub sort: Option<Sort>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Clause {
/// `attr:value` / `attr:a,b` / `-attr:value`
Eq { attr: String, values: Vec<String>, negate: bool },
/// `attr:lo..hi`
Range { attr: String, lo: String, hi: String },
/// `has:x` / `has:-x`
Has { attr: String, negate: bool },
/// `#shortcut`
Shortcut { name: String },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sort {
pub field: String,
pub desc: bool,
}
/// Parse a query string into a [`Query`]. Never fails — unrecognized tokens
/// degrade to full-text terms, so a half-typed query is still usable.
pub fn parse(input: &str) -> Query {
let mut q = Query::default();
for tok in tokenize(input) {
// #shortcut
if let Some(name) = tok.strip_prefix('#') && !name.is_empty() {
q.clauses.push(Clause::Shortcut { name: name.to_string() });
continue;
}
// -attr:value (negated)
let (negate, body) = match tok.strip_prefix('-') {
Some(rest) if rest.contains(':') => (true, rest),
_ => (false, tok.as_str()),
};
if let Some((attr, value)) = body.split_once(':') {
let attr = attr.trim().to_lowercase();
let value = value.trim();
if attr == "sort" {
let (field, desc) = match value.strip_suffix("-desc") {
Some(f) => (f.to_string(), true),
None => match value.strip_suffix(" desc") {
Some(f) => (f.trim().to_string(), true),
None => (value.to_string(), false),
},
};
q.sort = Some(Sort { field, desc });
continue;
}
if attr == "has" {
let (neg2, name) = match value.strip_prefix('-') {
Some(n) => (true, n),
None => (false, value),
};
q.clauses.push(Clause::Has { attr: name.to_string(), negate: negate ^ neg2 });
continue;
}
if let Some((lo, hi)) = value.split_once("..") {
q.clauses.push(Clause::Range {
attr,
lo: lo.trim().to_string(),
hi: hi.trim().to_string(),
});
continue;
}
let values: Vec<String> = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if values.is_empty() {
continue;
}
q.clauses.push(Clause::Eq { attr, values, negate });
continue;
}
// bare word → full-text
if !tok.is_empty() {
q.text.push(tok);
}
}
q
}
/// Split into tokens, honoring `"quoted values"` and `{brace values}` as single
/// tokens (so `status:{In Progress}` and `comment:"väntar på svar"` work).
fn tokenize(input: &str) -> Vec<String> {
let mut toks = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for ch in input.chars() {
match quote {
Some(q) => {
let closes = (q == '"' && ch == '"') || (q == '{' && ch == '}');
if closes {
quote = None;
} else {
cur.push(ch);
}
}
None => match ch {
'"' => quote = Some('"'),
'{' => quote = Some('{'),
c if c.is_whitespace() => {
if !cur.is_empty() {
toks.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
},
}
}
if !cur.is_empty() {
toks.push(cur);
}
toks
}
impl Query {
/// A short human description of the parsed query (for chips / debugging).
pub fn describe(&self) -> String {
let mut parts: Vec<String> = Vec::new();
for c in &self.clauses {
parts.push(match c {
Clause::Eq { attr, values, negate } => {
format!("{}{}: {}", if *negate { "≠" } else { "" }, attr, values.join(" OR "))
}
Clause::Range { attr, lo, hi } => format!("{attr}: {lo}..{hi}"),
Clause::Has { attr, negate } => format!("{}has:{attr}", if *negate { "no " } else { "" }),
Clause::Shortcut { name } => format!("#{name}"),
});
}
if !self.text.is_empty() {
parts.push(format!("text:\"{}\"", self.text.join(" ")));
}
if let Some(s) = &self.sort {
parts.push(format!("sort:{}{}", s.field, if s.desc { " ↓" } else { " ↑" }));
}
parts.join(" · ")
}
}
// ── The egui search-bar facet ────────────────────────────────────────────────
/// A search-bar facet: a text input that parses as you type and shows the parsed
/// clauses. `take_submit()` yields the [`Query`] when the user presses Enter.
pub struct SearchBar {
title: String,
input: String,
parsed: Query,
submitted: Option<Query>,
}
impl Default for SearchBar {
fn default() -> Self {
Self::new("Search")
}
}
impl SearchBar {
pub fn new(title: impl Into<String>) -> Self {
Self { title: title.into(), input: String::new(), parsed: Query::default(), submitted: None }
}
/// Pre-fill the bar (e.g. loading a saved view).
pub fn set_text(&mut self, text: impl Into<String>) {
self.input = text.into();
self.parsed = parse(&self.input);
}
pub fn query(&self) -> &Query {
&self.parsed
}
/// The query submitted (Enter pressed) since the last call, if any.
pub fn take_submit(&mut self) -> Option<Query> {
self.submitted.take()
}
/// Submit the current input **headlessly** — the robot counterpart of
/// pressing Enter in the bar, so `take_submit()` sees the same payload.
pub fn submit(&mut self) {
self.submitted = Some(self.parsed.clone());
}
/// The **shared demo fixture** — a bar pre-filled with a query exercising
/// every clause kind the parser branches on (a field filter, a free-text
/// term, a negation and a sort). Consumed by the robot and matrix tests
/// (L5: one fixture, not one per test file).
pub fn local() -> Self {
let mut s = Self::new("Search");
s.set_text("status:open assignee:me -tag:spam bygglov sort:due");
s
}
pub fn title(&self) -> &str {
&self.title
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"title": self.title,
"input": self.input,
"parsed": self.parsed,
"describe": self.parsed.describe(),
})
}
pub fn ui(&mut self, ui: &mut egui::Ui) {
let resp = ui.add(
egui::TextEdit::singleline(&mut self.input)
.hint_text("query — e.g. status:open assignee:me sort:due")
.desired_width(f32::INFINITY),
);
if resp.changed() {
self.parsed = parse(&self.input);
}
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
self.parsed = parse(&self.input);
self.submitted = Some(self.parsed.clone());
}
if !self.parsed.clauses.is_empty() || self.parsed.sort.is_some() || !self.parsed.text.is_empty() {
ui.label(egui::RichText::new(self.parsed.describe()).small().color(egui::Color32::GRAY));
}
}
}
// ── The host-facing `Facet` contract ───────────────────────────────────────────
impl facett_core::Facet for SearchBar {
fn title(&self) -> &str {
SearchBar::title(self)
}
fn ui(&mut self, ui: &mut egui::Ui) {
SearchBar::ui(self, ui)
}
fn state_json(&self) -> serde_json::Value {
SearchBar::state_json(self)
}
/// The **robot drive seam**: `{"set_text":"status:open"}` retypes the query
/// (re-parsing it, exactly as typing does) and `"submit"` is the Enter key.
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if v == serde_json::json!("submit") {
return self.submit();
}
if let Some(t) = v.get("set_text").and_then(|s| s.as_str()) {
self.set_text(t);
}
}
fn caps(&self) -> facett_core::FacetCaps {
facett_core::FacetCaps::NONE.copyable()
}
fn copy(&mut self) -> Option<String> {
Some(self.input.clone())
}
fn paste(&mut self, text: &str) -> bool {
self.set_text(text);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_basic_clauses() {
let q = parse("status:open assignee:me priority:high");
assert_eq!(q.clauses.len(), 3);
assert_eq!(
q.clauses[0],
Clause::Eq { attr: "status".into(), values: vec!["open".into()], negate: false }
);
}
#[test]
fn comma_is_or_and_minus_is_exclude() {
let q = parse("status:open,waiting -priority:low");
assert_eq!(q.clauses[0], Clause::Eq { attr: "status".into(), values: vec!["open".into(), "waiting".into()], negate: false });
assert_eq!(q.clauses[1], Clause::Eq { attr: "priority".into(), values: vec!["low".into()], negate: true });
}
#[test]
fn ranges_has_shortcuts_sort_text() {
let q = parse("created:2026-01-01..2026-03-31 has:attachments has:-assignee #overdue sort:due-desc login loop");
assert!(q.clauses.contains(&Clause::Range { attr: "created".into(), lo: "2026-01-01".into(), hi: "2026-03-31".into() }));
assert!(q.clauses.contains(&Clause::Has { attr: "attachments".into(), negate: false }));
assert!(q.clauses.contains(&Clause::Has { attr: "assignee".into(), negate: true }));
assert!(q.clauses.contains(&Clause::Shortcut { name: "overdue".into() }));
assert_eq!(q.sort, Some(Sort { field: "due".into(), desc: true }));
assert_eq!(q.text, vec!["login".to_string(), "loop".to_string()]);
}
#[test]
fn quotes_and_braces_group_values() {
let q = parse(r#"status:{In Progress} comment:"väntar på svar""#);
assert_eq!(q.clauses[0], Clause::Eq { attr: "status".into(), values: vec!["In Progress".into()], negate: false });
assert_eq!(q.clauses[1], Clause::Eq { attr: "comment".into(), values: vec!["väntar på svar".into()], negate: false });
}
#[test]
fn describe_is_stable() {
let q = parse("status:open,waiting sort:due");
assert!(q.describe().contains("status: open OR waiting"));
assert!(q.describe().contains("sort:due"));
}
}