1use crate::models::{Field, FieldRef};
33use crate::Deck;
34use std::fmt::{self, Display, Formatter};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Query {
39 query_string: String,
40}
41
42impl Query {
43 pub fn custom(query_string: String) -> Self {
47 Self { query_string }
48 }
49
50 pub fn as_str(&self) -> &str {
52 &self.query_string
53 }
54}
55
56impl Display for Query {
57 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
58 write!(f, "{}", self.query_string)
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum CardState {
65 Due,
67 New,
69 Learning,
71 Review,
73 Suspended,
75 Buried,
77 BuriedSibling,
79 BuriedManual,
81}
82
83impl CardState {
84 fn as_query_str(&self) -> &'static str {
86 match self {
87 Self::Due => "is:due",
88 Self::New => "is:new",
89 Self::Learning => "is:learn",
90 Self::Review => "is:review",
91 Self::Suspended => "is:suspended",
92 Self::Buried => "is:buried",
93 Self::BuriedSibling => "is:buried-sibling",
94 Self::BuriedManual => "is:buried-manually",
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum Flag {
102 Red = 1,
103 Orange = 2,
104 Green = 3,
105 Blue = 4,
106 Pink = 5,
107 Turquoise = 6,
108 Purple = 7,
109}
110
111#[derive(Debug, Clone)]
117pub struct QueryBuilder {
118 parts: Vec<String>,
119 negated: bool,
120 current_field: Option<String>,
121}
122
123impl QueryBuilder {
124 pub fn new() -> Self {
126 Self {
127 parts: Vec::new(),
128 negated: false,
129 current_field: None,
130 }
131 }
132
133 pub fn text<S: AsRef<str>>(mut self, text: S) -> Self {
137 let text = text.as_ref();
138 let escaped = Self::escape_special_chars(text);
139 self.add_part(escaped);
140 self
141 }
142
143 pub fn field<S: AsRef<str>>(mut self, field_name: S) -> FieldQueryBuilder {
147 self.current_field = Some(field_name.as_ref().to_string());
148 FieldQueryBuilder { builder: self }
149 }
150
151 pub fn in_field(mut self, field: &Field) -> FieldQueryBuilder {
155 self.current_field = Some(field.name().to_string());
156 FieldQueryBuilder { builder: self }
157 }
158
159 pub fn in_field_ref(mut self, field_ref: FieldRef<'_>) -> FieldQueryBuilder {
164 self.current_field = Some(field_ref.name().to_string());
165 FieldQueryBuilder { builder: self }
166 }
167
168 pub fn has_tag<S: AsRef<str>>(mut self, tag: S) -> Self {
170 self.add_part(format!("tag:{}", Self::escape_special_chars(tag.as_ref())));
171 self
172 }
173
174 pub fn in_deck<S: AsRef<str>>(mut self, deck: S) -> Self {
176 let deck = deck.as_ref();
177 if deck.contains(' ') {
178 self.add_part(format!("deck:\"{}\"", Self::escape_special_chars(deck)));
179 } else {
180 self.add_part(format!("deck:{}", Self::escape_special_chars(deck)));
181 }
182 self
183 }
184
185 pub fn in_deck_obj(self, deck: &Deck) -> Self {
187 self.in_deck(deck.name())
188 }
189
190 pub fn in_state(mut self, state: CardState) -> Self {
192 self.add_part(state.as_query_str().to_string());
193 self
194 }
195
196 pub fn not(mut self) -> Self {
198 self.negated = true;
199 self
200 }
201
202 pub fn and(self) -> Self {
204 self
207 }
208
209 pub fn or(mut self) -> Self {
211 self.add_part("or".to_string());
212 self
213 }
214
215 pub fn has_flag(mut self, flag: Flag) -> Self {
217 self.add_part(format!("flag:{}", flag as u8));
218 self
219 }
220
221 pub fn interval_at_least(mut self, days: u32) -> Self {
223 self.add_part(format!("prop:ivl>={}", days));
224 self
225 }
226
227 pub fn due_in(mut self, days: i32) -> Self {
229 self.add_part(format!("prop:due={}", days));
230 self
231 }
232
233 pub fn reps_less_than(mut self, count: u32) -> Self {
235 self.add_part(format!("prop:reps<{}", count));
236 self
237 }
238
239 pub fn added_in_last_n_days(mut self, days: u32) -> Self {
241 self.add_part(format!("added:{}", days));
242 self
243 }
244
245 pub fn rated_today(mut self) -> Self {
247 self.add_part("rated:1".to_string());
248 self
249 }
250
251 pub fn rated_in_last_n_days(mut self, days: u32) -> Self {
253 self.add_part(format!("rated:{}", days));
254 self
255 }
256
257 pub fn build(self) -> Query {
259 Query::custom(self.parts.join(" "))
260 }
261
262 fn add_part(&mut self, part: String) {
264 if self.negated {
265 self.parts.push(format!("-{}", part));
266 self.negated = false;
267 } else {
268 self.parts.push(part);
269 }
270 }
271
272 fn escape_special_chars(s: &str) -> String {
274 let needs_escape = |c: char| matches!(c, '"' | '*' | '_' | '\\' | '(' | ')' | ':' | '-');
275
276 let mut result = String::with_capacity(s.len());
277 let mut chars = s.chars();
278
279 for c in chars {
280 if needs_escape(c) {
281 result.push('\\');
282 }
283 result.push(c);
284 }
285
286 result
287 }
288}
289
290pub struct FieldQueryBuilder {
294 builder: QueryBuilder,
295}
296
297impl FieldQueryBuilder {
298 pub fn is<S: AsRef<str>>(self, content: S) -> QueryBuilder {
300 self.with_content(content)
301 }
302
303 pub fn contains<S: AsRef<str>>(self, content: S) -> QueryBuilder {
305 self.with_content(content)
306 }
307
308 fn with_content<S: AsRef<str>>(mut self, content: S) -> QueryBuilder {
310 let field_name = self.builder.current_field.take().unwrap();
311 let content = content.as_ref();
312 let escaped = QueryBuilder::escape_special_chars(content);
313 self.builder.add_part(format!("{}:{}", field_name, escaped));
314 self.builder
315 }
316}
317
318impl Default for QueryBuilder {
319 fn default() -> Self {
320 Self::new()
321 }
322}
323
324impl QueryBuilder {
326 pub fn deck<S: AsRef<str>>(deck: S) -> Self {
328 Self::new().in_deck(deck)
329 }
330
331 pub fn tag<S: AsRef<str>>(tag: S) -> Self {
333 Self::new().has_tag(tag)
334 }
335
336 pub fn state(state: CardState) -> Self {
338 Self::new().in_state(state)
339 }
340
341 pub fn flag(flag: Flag) -> Self {
343 Self::new().has_flag(flag)
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn test_basic_text_search() {
353 let query = QueryBuilder::new().text("dog").build();
354 assert_eq!(query.as_str(), "dog");
355 }
356
357 #[test]
358 fn test_field_search() {
359 let query = QueryBuilder::new().field("Front").contains("dog").build();
360 assert_eq!(query.as_str(), "Front:dog");
361 }
362
363 #[test]
364 fn test_complex_search() {
365 let query = QueryBuilder::new()
366 .field("Front")
367 .contains("dog")
368 .and()
369 .not()
370 .has_tag("marked")
371 .build();
372 assert_eq!(query.as_str(), "Front:dog -tag:marked");
373 }
374
375 #[test]
376 fn test_deck_with_spaces() {
377 let query = QueryBuilder::new().in_deck("My Deck").build();
378 assert_eq!(query.as_str(), "deck:\"My Deck\"");
379 }
380
381 #[test]
382 fn test_using_deck_object() {
383 let deck = Deck::new(1234, "My Deck".to_string());
384 let query = QueryBuilder::new().in_deck_obj(&deck).build();
385 assert_eq!(query.as_str(), "deck:\"My Deck\"");
386 }
387
388 #[test]
389 fn test_card_states() {
390 let query = QueryBuilder::new()
391 .in_state(CardState::Due)
392 .and()
393 .in_state(CardState::Learning)
394 .build();
395 assert_eq!(query.as_str(), "is:due is:learn");
396 }
397
398 #[test]
399 fn test_special_char_escaping() {
400 let query = QueryBuilder::new().text("dog*cat").build();
401 assert_eq!(query.as_str(), "dog\\*cat");
402
403 let query = QueryBuilder::new().text("dog (cat)").build();
404 assert_eq!(query.as_str(), "dog \\(cat\\)");
405 }
406
407 #[test]
408 fn test_complex_query_with_or() {
409 let query = QueryBuilder::new()
410 .in_deck("Japanese")
411 .and()
412 .field("Vocabulary")
413 .contains("敷衍")
414 .or()
415 .field("Reading")
416 .contains("ふえん")
417 .and()
418 .not()
419 .in_state(CardState::Suspended)
420 .build();
421
422 assert_eq!(
423 query.as_str(),
424 "deck:Japanese Vocabulary:敷衍 or Reading:ふえん -is:suspended"
425 );
426 }
427
428 #[test]
429 fn test_convenience_constructors() {
430 let query = QueryBuilder::deck("Japanese").build();
431 assert_eq!(query.as_str(), "deck:Japanese");
432
433 let query = QueryBuilder::tag("important").build();
434 assert_eq!(query.as_str(), "tag:important");
435
436 let query = QueryBuilder::state(CardState::New).build();
437 assert_eq!(query.as_str(), "is:new");
438
439 let query = QueryBuilder::flag(Flag::Red).build();
440 assert_eq!(query.as_str(), "flag:1");
441 }
442}