ankiconnect_rs/builders/
query.rs

1//! Builder for Anki search queries
2//!
3//! This module provides a fluent interface for building search queries for Anki.
4//! It helps to construct valid search queries with proper escaping and syntax.
5//!
6//! # Examples
7//!
8//! ```
9//! use ankiconnect_rs::builders::QueryBuilder;
10//! use ankiconnect_rs::models::Deck;
11//!
12//! // Basic query to find cards with the text "biology"
13//! let query = QueryBuilder::new().text("biology").build();
14//!
15//! // More complex query to find cards in a specific deck with certain tags
16//! let query = QueryBuilder::new()
17//!     .in_deck("Biology::Anatomy")
18//!     .and()
19//!     .has_tag("important")
20//!     .and()
21//!     .not()
22//!     .has_tag("reviewed")
23//!     .build();
24//!
25//! // Using field-specific search
26//! let query = QueryBuilder::new()
27//!     .field("Front")
28//!     .contains("mitochondria")
29//!     .build();
30//! ```
31
32use crate::models::{Field, FieldRef};
33use crate::Deck;
34use std::fmt::{self, Display, Formatter};
35
36/// Represents a complete Anki search query
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Query {
39    query_string: String,
40}
41
42impl Query {
43    /// Creates a new query from a string
44    ///
45    /// This is mostly for internal use. Prefer using `QueryBuilder` to construct queries.
46    pub fn custom(query_string: String) -> Self {
47        Self { query_string }
48    }
49
50    /// Returns the query as a string
51    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/// Predefined card states for filtering
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum CardState {
65    /// Cards that are due for review
66    Due,
67    /// New cards that haven't been studied yet
68    New,
69    /// Cards currently in the learning phase
70    Learning,
71    /// Cards in the review phase
72    Review,
73    /// Cards that have been suspended
74    Suspended,
75    /// Cards that have been buried
76    Buried,
77    /// Cards buried because a sibling was answered
78    BuriedSibling,
79    /// Cards buried manually by the user
80    BuriedManual,
81}
82
83impl CardState {
84    /// Returns the AnkiConnect query string for this state
85    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/// Predefined flag colors for filtering
100#[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/// A builder for constructing Anki search queries
112///
113/// This builder provides a fluent interface for creating properly escaped
114/// and formatted Anki search queries. It helps prevent syntax errors and
115/// ensures proper escaping of special characters.
116#[derive(Debug, Clone)]
117pub struct QueryBuilder {
118    parts: Vec<String>,
119    negated: bool,
120    current_field: Option<String>,
121}
122
123impl QueryBuilder {
124    /// Creates a new, empty query builder
125    pub fn new() -> Self {
126        Self {
127            parts: Vec::new(),
128            negated: false,
129            current_field: None,
130        }
131    }
132
133    /// Adds free text to search for across all fields
134    ///
135    /// Special characters are automatically escaped.
136    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    /// Specifies a field to search in
144    ///
145    /// This must be followed by one of the field content methods like `contains`.
146    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    /// Specifies a field to search in using a Field reference
152    ///
153    /// This must be followed by one of the field content methods like `contains`.
154    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    /// Specifies a field to search in using a FieldRef
160    ///
161    /// This ensures the field actually exists in a model.
162    /// This must be followed by one of the field content methods like `contains`.
163    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    /// Searches for cards with a specific tag
169    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    /// Searches for cards in a specific deck
175    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    /// Searches for cards in the specified deck object
186    pub fn in_deck_obj(self, deck: &Deck) -> Self {
187        self.in_deck(deck.name())
188    }
189
190    /// Searches for cards in a specific card state
191    pub fn in_state(mut self, state: CardState) -> Self {
192        self.add_part(state.as_query_str().to_string());
193        self
194    }
195
196    /// Negates the next condition
197    pub fn not(mut self) -> Self {
198        self.negated = true;
199        self
200    }
201
202    /// Combines with the next condition using AND (implicit in Anki)
203    pub fn and(self) -> Self {
204        // This is a no-op in terms of the query string,
205        // but helps make the builder more readable
206        self
207    }
208
209    /// Combines with the next condition using OR
210    pub fn or(mut self) -> Self {
211        self.add_part("or".to_string());
212        self
213    }
214
215    /// Searches for cards with a specific flag
216    pub fn has_flag(mut self, flag: Flag) -> Self {
217        self.add_part(format!("flag:{}", flag as u8));
218        self
219    }
220
221    /// Searches for cards with an interval greater than or equal to the specified days
222    pub fn interval_at_least(mut self, days: u32) -> Self {
223        self.add_part(format!("prop:ivl>={}", days));
224        self
225    }
226
227    /// Searches for cards due in the specified number of days
228    pub fn due_in(mut self, days: i32) -> Self {
229        self.add_part(format!("prop:due={}", days));
230        self
231    }
232
233    /// Searches for cards with fewer than the specified number of repetitions
234    pub fn reps_less_than(mut self, count: u32) -> Self {
235        self.add_part(format!("prop:reps<{}", count));
236        self
237    }
238
239    /// Searches for cards added within the last n days
240    pub fn added_in_last_n_days(mut self, days: u32) -> Self {
241        self.add_part(format!("added:{}", days));
242        self
243    }
244
245    /// Searches for cards rated today
246    pub fn rated_today(mut self) -> Self {
247        self.add_part("rated:1".to_string());
248        self
249    }
250
251    /// Searches for cards rated within the last n days
252    pub fn rated_in_last_n_days(mut self, days: u32) -> Self {
253        self.add_part(format!("rated:{}", days));
254        self
255    }
256
257    /// Builds the final query
258    pub fn build(self) -> Query {
259        Query::custom(self.parts.join(" "))
260    }
261
262    /// Helper method to add a part to the query
263    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    /// Helper method to escape special characters in Anki search
273    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
290/// Helper builder for field-specific queries
291///
292/// This ensures that field queries are properly structured.
293pub struct FieldQueryBuilder {
294    builder: QueryBuilder,
295}
296
297impl FieldQueryBuilder {
298    /// Specifies exact content to match in the field
299    pub fn is<S: AsRef<str>>(self, content: S) -> QueryBuilder {
300        self.with_content(content)
301    }
302
303    /// Specifies content to match in the field
304    pub fn contains<S: AsRef<str>>(self, content: S) -> QueryBuilder {
305        self.with_content(content)
306    }
307
308    /// Internal method to add field content to the query
309    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
324/// Convenience functions for common queries
325impl QueryBuilder {
326    /// Creates a query that searches for cards in the specified deck
327    pub fn deck<S: AsRef<str>>(deck: S) -> Self {
328        Self::new().in_deck(deck)
329    }
330
331    /// Creates a query that searches for cards with the specified tag
332    pub fn tag<S: AsRef<str>>(tag: S) -> Self {
333        Self::new().has_tag(tag)
334    }
335
336    /// Creates a query that searches for cards in the specified state
337    pub fn state(state: CardState) -> Self {
338        Self::new().in_state(state)
339    }
340
341    /// Creates a query that searches for cards with the specified flag
342    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}