#[derive(Debug, Clone, Default)]
#[must_use = "QueryBuilder does nothing until .build() is called"]
pub struct QueryBuilder {
parts: Vec<String>,
}
impl QueryBuilder {
pub fn new() -> Self {
Self { parts: Vec::new() }
}
pub fn deck(mut self, name: &str) -> Self {
self.parts.push(format!("deck:{}", quote_if_needed(name)));
self
}
pub fn note_type(mut self, model: &str) -> Self {
self.parts.push(format!("note:{}", quote_if_needed(model)));
self
}
pub fn card_template(mut self, ordinal: i32) -> Self {
self.parts.push(format!("card:{}", ordinal));
self
}
pub fn is_due(mut self) -> Self {
self.parts.push("is:due".to_string());
self
}
pub fn is_new(mut self) -> Self {
self.parts.push("is:new".to_string());
self
}
pub fn is_review(mut self) -> Self {
self.parts.push("is:review".to_string());
self
}
pub fn is_learn(mut self) -> Self {
self.parts.push("is:learn".to_string());
self
}
pub fn is_suspended(mut self) -> Self {
self.parts.push("is:suspended".to_string());
self
}
pub fn is_buried(mut self) -> Self {
self.parts.push("is:buried".to_string());
self
}
pub fn not_suspended(mut self) -> Self {
self.parts.push("-is:suspended".to_string());
self
}
pub fn not_buried(mut self) -> Self {
self.parts.push("-is:buried".to_string());
self
}
pub fn tag(mut self, tag: &str) -> Self {
self.parts.push(format!("tag:{}", quote_if_needed(tag)));
self
}
pub fn without_tag(mut self, tag: &str) -> Self {
self.parts.push(format!("-tag:{}", quote_if_needed(tag)));
self
}
pub fn untagged(mut self) -> Self {
self.parts.push("tag:none".to_string());
self
}
pub fn interval_gt(mut self, days: i32) -> Self {
self.parts.push(format!("prop:ivl>{}", days));
self
}
pub fn interval_lt(mut self, days: i32) -> Self {
self.parts.push(format!("prop:ivl<{}", days));
self
}
pub fn interval_eq(mut self, days: i32) -> Self {
self.parts.push(format!("prop:ivl={}", days));
self
}
pub fn ease_gt(mut self, ease: f32) -> Self {
self.parts.push(format!("prop:ease>{:.2}", ease));
self
}
pub fn ease_lt(mut self, ease: f32) -> Self {
self.parts.push(format!("prop:ease<{:.2}", ease));
self
}
pub fn lapses_gte(mut self, n: i32) -> Self {
self.parts.push(format!("prop:lapses>={}", n));
self
}
pub fn lapses_eq(mut self, n: i32) -> Self {
self.parts.push(format!("prop:lapses={}", n));
self
}
pub fn reps_gte(mut self, n: i32) -> Self {
self.parts.push(format!("prop:reps>={}", n));
self
}
pub fn due_in_days(mut self, days: i32) -> Self {
self.parts.push(format!("prop:due={}", days));
self
}
pub fn due_before_days(mut self, days: i32) -> Self {
self.parts.push(format!("prop:due<{}", days));
self
}
pub fn added_within_days(mut self, days: i32) -> Self {
self.parts.push(format!("added:{}", days));
self
}
pub fn rated_within_days(mut self, days: i32) -> Self {
self.parts.push(format!("rated:{}", days));
self
}
pub fn edited_within_days(mut self, days: i32) -> Self {
self.parts.push(format!("edited:{}", days));
self
}
pub fn introduced_within_days(mut self, days: i32) -> Self {
self.parts.push(format!("introduced:{}", days));
self
}
pub fn contains(mut self, text: &str) -> Self {
self.parts.push(format!("\"{}\"", escape_quotes(text)));
self
}
pub fn word(mut self, word: &str) -> Self {
self.parts.push(quote_if_needed(word));
self
}
pub fn field(mut self, field_name: &str, text: &str) -> Self {
self.parts
.push(format!("{}:{}", field_name, quote_if_needed(text)));
self
}
pub fn field_regex(mut self, field_name: &str, pattern: &str) -> Self {
self.parts.push(format!("{}:re:{}", field_name, pattern));
self
}
pub fn field_wildcard(mut self, field_name: &str, pattern: &str) -> Self {
self.parts.push(format!("{}:{}", field_name, pattern));
self
}
pub fn field_empty(mut self, field_name: &str) -> Self {
self.parts.push(format!("{}:", field_name));
self
}
pub fn flag(mut self, flag: i32) -> Self {
self.parts.push(format!("flag:{}", flag));
self
}
pub fn has_flag(mut self) -> Self {
self.parts.push("-flag:0".to_string());
self
}
pub fn no_flag(mut self) -> Self {
self.parts.push("flag:0".to_string());
self
}
pub fn or<F>(mut self, f: F) -> Self
where
F: FnOnce(OrBuilder) -> OrBuilder,
{
let or_builder = f(OrBuilder::new());
let or_query = or_builder.build();
if !or_query.is_empty() {
self.parts.push(format!("({})", or_query));
}
self
}
pub fn not<F>(mut self, f: F) -> Self
where
F: FnOnce(QueryBuilder) -> QueryBuilder,
{
let inner = f(QueryBuilder::new());
for part in inner.parts {
if let Some(stripped) = part.strip_prefix('-') {
self.parts.push(stripped.to_string());
} else {
self.parts.push(format!("-{}", part));
}
}
self
}
pub fn raw(mut self, query: &str) -> Self {
self.parts.push(query.to_string());
self
}
pub fn build(self) -> String {
self.parts.join(" ")
}
}
#[derive(Debug, Clone, Default)]
pub struct OrBuilder {
parts: Vec<String>,
}
impl OrBuilder {
fn new() -> Self {
Self { parts: Vec::new() }
}
pub fn tag(mut self, tag: &str) -> Self {
self.parts.push(format!("tag:{}", quote_if_needed(tag)));
self
}
pub fn deck(mut self, name: &str) -> Self {
self.parts.push(format!("deck:{}", quote_if_needed(name)));
self
}
pub fn note_type(mut self, model: &str) -> Self {
self.parts.push(format!("note:{}", quote_if_needed(model)));
self
}
pub fn field(mut self, field_name: &str, text: &str) -> Self {
self.parts
.push(format!("{}:{}", field_name, quote_if_needed(text)));
self
}
pub fn raw(mut self, query: &str) -> Self {
self.parts.push(query.to_string());
self
}
pub fn is_new(mut self) -> Self {
self.parts.push("is:new".to_string());
self
}
pub fn is_due(mut self) -> Self {
self.parts.push("is:due".to_string());
self
}
pub fn is_review(mut self) -> Self {
self.parts.push("is:review".to_string());
self
}
pub fn is_learn(mut self) -> Self {
self.parts.push("is:learn".to_string());
self
}
fn build(self) -> String {
self.parts.join(" OR ")
}
}
fn quote_if_needed(s: &str) -> String {
if s.contains(' ') || s.contains('"') || s.contains('(') || s.contains(')') {
format!("\"{}\"", escape_quotes(s))
} else {
s.to_string()
}
}
fn escape_quotes(s: &str) -> String {
s.replace('"', "\\\"")
}
impl std::fmt::Display for QueryBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.parts.join(" "))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_query() {
let q = QueryBuilder::new().build();
assert_eq!(q, "");
}
#[test]
fn test_deck() {
let q = QueryBuilder::new().deck("Japanese").build();
assert_eq!(q, "deck:Japanese");
}
#[test]
fn test_deck_with_spaces() {
let q = QueryBuilder::new().deck("My Deck").build();
assert_eq!(q, "deck:\"My Deck\"");
}
#[test]
fn test_hierarchical_deck() {
let q = QueryBuilder::new()
.deck("Languages::Italian::Verbs")
.build();
assert_eq!(q, "deck:Languages::Italian::Verbs");
}
#[test]
fn test_card_states() {
let q = QueryBuilder::new().is_due().is_new().build();
assert_eq!(q, "is:due is:new");
let q = QueryBuilder::new().not_suspended().not_buried().build();
assert_eq!(q, "-is:suspended -is:buried");
}
#[test]
fn test_tags() {
let q = QueryBuilder::new().tag("vocab").without_tag("hard").build();
assert_eq!(q, "tag:vocab -tag:hard");
}
#[test]
fn test_properties() {
let q = QueryBuilder::new()
.lapses_gte(5)
.ease_lt(2.1)
.interval_gt(30)
.build();
assert_eq!(q, "prop:lapses>=5 prop:ease<2.10 prop:ivl>30");
}
#[test]
fn test_time_filters() {
let q = QueryBuilder::new()
.added_within_days(7)
.rated_within_days(1)
.build();
assert_eq!(q, "added:7 rated:1");
}
#[test]
fn test_content_search() {
let q = QueryBuilder::new().contains("to eat").build();
assert_eq!(q, "\"to eat\"");
let q = QueryBuilder::new().field("Front", "hello").build();
assert_eq!(q, "Front:hello");
let q = QueryBuilder::new().field("Front", "hello world").build();
assert_eq!(q, "Front:\"hello world\"");
}
#[test]
fn test_field_regex() {
let q = QueryBuilder::new().field_regex("Front", r"^to\s+").build();
assert_eq!(q, r"Front:re:^to\s+");
}
#[test]
fn test_field_empty() {
let q = QueryBuilder::new().field_empty("Example").build();
assert_eq!(q, "Example:");
}
#[test]
fn test_or_combinator() {
let q = QueryBuilder::new()
.deck("Italian")
.or(|q| q.tag("verb").tag("noun"))
.build();
assert_eq!(q, "deck:Italian (tag:verb OR tag:noun)");
}
#[test]
fn test_not_combinator() {
let q = QueryBuilder::new()
.deck("Test")
.not(|q| q.tag("exclude"))
.build();
assert_eq!(q, "deck:Test -tag:exclude");
}
#[test]
fn test_complex_query() {
let q = QueryBuilder::new()
.deck("Japanese")
.is_due()
.not_suspended()
.lapses_gte(3)
.or(|q| q.tag("verb").tag("noun").tag("adjective"))
.build();
assert_eq!(
q,
"deck:Japanese is:due -is:suspended prop:lapses>=3 (tag:verb OR tag:noun OR tag:adjective)"
);
}
#[test]
fn test_raw_escape_hatch() {
let q = QueryBuilder::new().deck("Test").raw("prop:pos>5").build();
assert_eq!(q, "deck:Test prop:pos>5");
}
#[test]
fn test_display() {
let q = QueryBuilder::new().deck("Test").is_due();
assert_eq!(format!("{}", q), "deck:Test is:due");
}
#[test]
fn test_flags() {
let q = QueryBuilder::new().flag(1).build();
assert_eq!(q, "flag:1");
let q = QueryBuilder::new().has_flag().build();
assert_eq!(q, "-flag:0");
let q = QueryBuilder::new().no_flag().build();
assert_eq!(q, "flag:0");
}
}