Skip to main content

hemoglobin_search/
lib.rs

1#[warn(clippy::pedantic)]
2#[warn(clippy::nursery)]
3pub mod fuzzy;
4pub mod query_parser;
5use query_parser::parse_query;
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::HashMap,
9    fmt::{Display, Write},
10};
11
12#[cfg(target_arch = "wasm32")]
13use wasm_bindgen::prelude::*;
14
15use fuzzy::weighted_compare;
16use hemoglobin::{
17    cards::{
18        Card, CardId, Keyword, KeywordData,
19        kins::KinComparison,
20        properties::{Array, Number, Read, Text},
21    },
22    clean_ascii,
23    numbers::{Comparison, ImpreciseOrd, compare::Ternary},
24};
25use regex::Regex;
26
27/// Errors that might happen during searching
28#[derive(Debug)]
29pub enum Errors {
30    NonRegexable(String),
31    InvalidOr,
32    InvalidComparisonString,
33    UnknownSubQueryParam(String),
34    UnknownStringParam(String),
35    InvalidOrdering(String),
36    InvalidPolarity,
37    NotSortable,
38    UnclosedSubquery,
39    UnclosedString,
40    UnclosedRegex,
41    RegexErr(regex::Error),
42    AttemptedEmptyParamName,
43}
44
45/// Represents a search query
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Query {
48    pub name: String,
49    pub restrictions: Vec<QueryRestriction>,
50    pub sort: Sort,
51}
52
53impl Query {
54    fn from_restrictions(restrictions: Vec<QueryRestriction>) -> Self {
55        let mut name = String::new();
56
57        for restriction in &restrictions {
58            if let QueryRestriction::Fuzzy(a) = restriction {
59                name += a;
60                name += " ";
61            }
62        }
63
64        let sort = if name.is_empty() {
65            Sort::Alphabet(Text::Name, Ordering::Ascending)
66        } else {
67            Sort::Fuzzy
68        };
69
70        Self {
71            name: name.trim().to_string(),
72            restrictions,
73            sort,
74        }
75    }
76    fn triviality(&self) -> Triviality {
77        if !self.name.is_empty() {
78            return Triviality::NonTrivial;
79        }
80
81        if self.restrictions.is_empty() {
82            return Triviality::Tautology;
83        }
84
85        let mut triviality = Triviality::Tautology;
86
87        for restriction in &self.restrictions {
88            match restriction.triviality() {
89                Triviality::NonTrivial => return Triviality::NonTrivial,
90                Triviality::Contradiction => triviality = Triviality::Contradiction,
91                Triviality::Tautology => (),
92            }
93        }
94
95        triviality
96    }
97    fn keyword_is_match<'card, 'cardvec, 'query, T, I>(
98        &'query self,
99        keyword: &'card Keyword,
100        cards: &'cardvec I,
101        cache: &'query mut Cache<&'card T>,
102    ) -> bool
103    where
104        T: Read + 'card + Clone,
105        &'card T: Read,
106        I: Iterator<Item = &'card T> + Clone,
107    {
108        if keyword.name == "devours" {
109            if let Some(KeywordData::CardId(ref devoured_id)) = keyword.data {
110                return matches_query(devoured_id.as_ref(), self, cards, cache).is_true();
111            }
112        }
113
114        false
115    }
116}
117
118impl Display for Query {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        let mut text_properties = vec![];
121        for restriction in &self.restrictions {
122            text_properties.push(format!("{restriction}"));
123        }
124        match &self.sort {
125            Sort::None => (),
126            Sort::Fuzzy if !self.name.is_empty() => {
127                text_properties.push("sorted by fuzzy match".to_string());
128            }
129            Sort::Fuzzy => text_properties.push("sorted by Name in Ascending order".to_owned()),
130            Sort::Alphabet(property, order) => {
131                text_properties.push(format!("sorted by {property} in {order} order"));
132            }
133            Sort::Numeric(property, order) => {
134                text_properties.push(format!("sorted by {property} in {order} order"));
135            }
136        }
137
138        let text_properties = text_properties.into_iter().reduce(|mut acc, el| {
139            write!(&mut acc, ", {el}").unwrap();
140            acc
141        });
142        match text_properties {
143            Some(text_properties) => write!(f, "Cards {text_properties}"),
144            None => write!(f, "Cards"),
145        }
146    }
147}
148
149#[allow(clippy::match_wildcard_for_single_variants)]
150impl Display for QueryRestriction {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            Self::DevouredBy(devourer) => {
154                write!(f, "which are devoured by [{devourer}]")
155            }
156            Self::Fuzzy(text) => write!(f, "with \"{text}\" written on them"),
157            Self::Devours(devourees, _) => write!(f, "that devour [{devourees}]"),
158            Self::NumberComparison(property, comparison) => {
159                write!(f, "with {property} {comparison}")
160            }
161            Self::TextComparison(property, text) => match text {
162                TextComparison::Contains(text) => write!(f, "whose {property} contains \"{text}\""),
163                TextComparison::HasMatch(regex) => write!(f, "whose {property} matches /{regex}/"),
164                TextComparison::EqualTo(text) => {
165                    write!(f, "whose {property} is equal to \"{text}\"")
166                }
167            },
168            Self::Has(property, text) => match property {
169                Array::Functions => match text {
170                    TextComparison::Contains(text) => {
171                        write!(f, "which can be used to \"{text}\"")
172                    }
173                    TextComparison::EqualTo(text) => write!(f, "which can be used to \"{text}\""),
174                    TextComparison::HasMatch(regex) => write!(f, "which can be used to /{regex}/"),
175                },
176            },
177            Self::HasKw(keyword) => match keyword {
178                TextComparison::Contains(text) => {
179                    write!(f, "with a \"{text}\" keyword")
180                }
181                TextComparison::EqualTo(text) => {
182                    write!(f, "with exactly a \"{text}\" keyword")
183                }
184                TextComparison::HasMatch(regex) => {
185                    write!(f, "with a /{regex}/ keyword")
186                }
187            },
188            Self::Not(query) => write!(f, "that aren't [{query}]"),
189            Self::LenientNot(query) => write!(
190                f,
191                "that aren't [{query}], counting lacks of a property as a non-match"
192            ),
193            Self::Group(query) => write!(f, "which are [{query}]"),
194            Self::Or(a, b) => write!(f, "which are [{a}] or [{b}]"),
195            Self::Xor(a, b) => write!(f, "which are [{a}] xor [{b}] but not both"),
196            Self::KinComparison(comparison) => match comparison {
197                KinComparison::Equal(kin) => write!(f, "whose kin is exactly {kin}"),
198                KinComparison::Similar(kin) => write!(f, "whose kin is {kin}"),
199                KinComparison::TextContains(kin) => write!(f, "whose kin is \"{kin}\""),
200                KinComparison::TextEqual(kin) => write!(f, "whose kin is exactly \"{kin}\""),
201                KinComparison::RegexMatch(regex) => write!(f, "whose kin matches /{regex}/"),
202            },
203        }
204    }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub enum TextComparison {
209    Contains(String),
210    EqualTo(String),
211    #[serde(with = "serde_regex")]
212    HasMatch(Regex),
213}
214
215impl TextComparison {
216    pub fn is_match(&self, other: &'_ str) -> bool {
217        let clean_other = clean_ascii(other);
218        match self {
219            TextComparison::Contains(self_text) => clean_other.contains(&clean_ascii(self_text)),
220            TextComparison::EqualTo(self_text) => clean_ascii(self_text) == clean_other,
221            TextComparison::HasMatch(regex) => regex.is_match(&clean_other),
222        }
223    }
224}
225
226/// Represents a specific restriction that a `Query` will apply to cards.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub enum QueryRestriction {
229    Fuzzy(String),
230    Devours(Query, ComparisonKind),
231    DevouredBy(Query),
232    NumberComparison(Number, Comparison),
233    TextComparison(Text, TextComparison),
234    Has(Array, TextComparison),
235    KinComparison(KinComparison),
236    HasKw(TextComparison),
237    Not(Box<QueryRestriction>),
238    LenientNot(Box<QueryRestriction>),
239    Group(Query),
240    Or(Box<QueryRestriction>, Box<QueryRestriction>),
241    Xor(Box<QueryRestriction>, Box<QueryRestriction>),
242}
243
244impl QueryRestriction {
245    fn not(self) -> Self {
246        Self::Not(Box::new(self))
247    }
248    fn lnot(self) -> Self {
249        Self::LenientNot(Box::new(self))
250    }
251    fn or(self, b: Self) -> Self {
252        Self::Or(Box::new(self), Box::new(b))
253    }
254    fn xor(self, b: Self) -> Self {
255        Self::Xor(Box::new(self), Box::new(b))
256    }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260enum Triviality {
261    NonTrivial,
262    Contradiction,
263    Tautology,
264}
265
266impl QueryRestriction {
267    fn triviality(&self) -> Triviality {
268        match self {
269            QueryRestriction::NumberComparison(Number::Cost, Comparison::LessThan(0)) => {
270                Triviality::Contradiction
271            }
272            QueryRestriction::NumberComparison(Number::Cost, Comparison::GreaterThanOrEqual(0)) => {
273                Triviality::Tautology
274            }
275            QueryRestriction::Not(query) => match query.triviality() {
276                Triviality::Tautology => Triviality::Contradiction,
277                Triviality::Contradiction => Triviality::Tautology,
278                Triviality::NonTrivial => Triviality::NonTrivial,
279            },
280            QueryRestriction::Group(query) => query.triviality(),
281            QueryRestriction::Or(query, query1) => {
282                match (query.triviality(), query1.triviality()) {
283                    (Triviality::NonTrivial, _) => Triviality::NonTrivial,
284                    (_, Triviality::NonTrivial) => Triviality::NonTrivial,
285                    (Triviality::Contradiction, Triviality::Contradiction) => {
286                        Triviality::Contradiction
287                    }
288                    (Triviality::Contradiction, Triviality::Tautology) => Triviality::Tautology,
289                    (Triviality::Tautology, Triviality::Contradiction) => Triviality::Tautology,
290                    (Triviality::Tautology, Triviality::Tautology) => Triviality::Tautology,
291                }
292            }
293            QueryRestriction::Xor(query, query1) => {
294                match (query.triviality(), query1.triviality()) {
295                    (Triviality::NonTrivial, _) => Triviality::NonTrivial,
296                    (_, Triviality::NonTrivial) => Triviality::NonTrivial,
297                    (Triviality::Contradiction, Triviality::Contradiction) => {
298                        Triviality::Contradiction
299                    }
300                    (Triviality::Contradiction, Triviality::Tautology) => Triviality::Tautology,
301                    (Triviality::Tautology, Triviality::Contradiction) => Triviality::Tautology,
302                    (Triviality::Tautology, Triviality::Tautology) => Triviality::Contradiction,
303                }
304            }
305            _ => Triviality::NonTrivial,
306        }
307    }
308    fn is_match<'card, 'cardvec, 'query, C, T, I>(
309        &'query self,
310        card: &'card C,
311        query: &'query Query,
312        cards: &'cardvec I,
313        cache: &'query mut Cache<&'card T>,
314    ) -> Ternary
315    where
316        C: Read,
317        T: Read + 'card + Clone,
318        &'card T: Read,
319        I: Iterator<Item = &'card T> + Clone,
320    {
321        match self {
322            QueryRestriction::TextComparison(property, comparison) => card
323                .get_text_property(property)
324                .map_or(Ternary::Void, |property| {
325                    comparison.is_match(&property).into()
326                }),
327            QueryRestriction::Xor(group1, group2) => {
328                let res1 = group1.is_match(card, query, cards, cache);
329                let res2 = group2.is_match(card, query, cards, cache);
330                res1.xor(res2)
331            }
332            QueryRestriction::Or(group1, group2) => {
333                let res1 = group1.is_match(card, query, cards, cache);
334                let res2 = group2.is_match(card, query, cards, cache);
335                res1.or(res2)
336            }
337            QueryRestriction::Group(group) => matches_query(card, group, cards, cache),
338            QueryRestriction::Fuzzy(x) => fuzzy(card, x).into(),
339
340            QueryRestriction::NumberComparison(field, comparison) => {
341                comparison.compare(&card.get_num_property(field))
342            }
343            QueryRestriction::Has(property, comparison) => card
344                .get_vec_property(property)
345                .map_or(Ternary::Void, |field| {
346                    field.iter().any(|x| comparison.is_match(x)).into()
347                }),
348            QueryRestriction::HasKw(comparison) => {
349                card.get_keywords().map_or(Ternary::Void, |keyword| {
350                    keyword.iter().any(|x| comparison.is_match(&x.name)).into()
351                })
352            }
353            QueryRestriction::Not(res) => !res.is_match(card, query, cards, cache),
354            QueryRestriction::LenientNot(res) => match res.is_match(card, query, cards, cache) {
355                Ternary::Void => Ternary::True,
356                Ternary::False => Ternary::True,
357                Ternary::True => Ternary::False,
358            },
359            QueryRestriction::Devours(devours, comparison) => match comparison {
360                ComparisonKind::Contains => devours_match(card, cards, devours, cache),
361                ComparisonKind::Equals => card.get_keywords().map_or(Ternary::Void, |keyword| {
362                    keyword
363                        .iter()
364                        .any(|keyword| devours.keyword_is_match(keyword, cards, cache))
365                        .into()
366                }),
367            },
368            QueryRestriction::DevouredBy(devoured_by) => {
369                devouredby_match(card, cards, query, devoured_by, cache)
370            }
371            QueryRestriction::KinComparison(comparison) => card
372                .get_kin()
373                .map_or(Ternary::Void, |kin| comparison.is_match(*kin).into()),
374        }
375    }
376}
377
378fn devouredby_match<'card, 'cardvec, 'query, T, I, C>(
379    card: &'card C,
380    cards: &'cardvec I,
381    query: &'query Query,
382    devoured_by: &'query Query,
383    cache: &'query mut Cache<&'card T>,
384) -> Ternary
385where
386    C: Read,
387    T: Read + 'card + Clone,
388    &'card T: Read,
389    I: Iterator<Item = &'card T> + Clone,
390{
391    let key = format!("{devoured_by}");
392    let devoured_cards;
393    let maybe_devourees = cache.devouredby.get(&key).cloned();
394    if let Some(value) = maybe_devourees {
395        devoured_cards = value;
396    } else {
397        let devourers = cards
398            .clone()
399            .filter(|card| matches_query(*card, devoured_by, cards, cache).is_true());
400
401        let mut no_devourers = true;
402        let mut queries = vec![];
403
404        for devourer in devourers {
405            if let Some(Keyword {
406                name: _,
407                data: Some(KeywordData::CardId(card_id)),
408            }) = devourer
409                .get_keywords()
410                .and_then(|x| x.iter().find(|x| x.name == "devours"))
411            {
412                no_devourers = false;
413                queries.push(QueryRestriction::Group(Query {
414                    name: String::new(),
415                    restrictions: card_id.get_as_query(),
416                    sort: Sort::None,
417                }));
418            }
419        }
420
421        if no_devourers {
422            devoured_cards = vec![];
423            cache.devouredby.insert(key, vec![]);
424        } else {
425            let devourees_restriction = queries
426                .into_iter()
427                .reduce(|first, second| first.or(second))
428                .map(|x| vec![x])
429                .unwrap_or_default();
430
431            let devourees_query = Query {
432                name: query.name.clone(),
433                restrictions: devourees_restriction,
434                sort: query.sort,
435            };
436
437            devoured_cards = search(&devourees_query, cards.clone());
438            cache.devouredby.insert(key, devoured_cards.clone());
439        }
440    }
441    devoured_cards
442        .iter()
443        .any(|x| x.get_name() == card.get_name())
444        .into()
445}
446
447fn devours_match<'card, 'cardvec, 'query, T, I, C>(
448    card: &'card C,
449    cards: &'cardvec I,
450    devourees: &'query Query,
451    cache: &'query mut Cache<&'card T>,
452) -> Ternary
453where
454    C: Read,
455    T: Read + 'card + Clone,
456    &'card T: Read,
457    I: Iterator<Item = &'card T> + Clone,
458{
459    let devourees = cache
460        .devourers
461        .entry(devourees.to_string())
462        .or_insert(search(devourees, cards.clone()));
463
464    if let Some(Keyword {
465        name: _,
466        data: Some(KeywordData::CardId(card_id)),
467    }) = card
468        .get_keywords()
469        .and_then(|x| x.iter().find(|x| x.name == "devours"))
470    {
471        let query = Query::from_restrictions(card_id.get_as_query());
472        let specific_devourees = search(&query, cards.clone());
473        if specific_devourees
474            .into_iter()
475            .any(|x| devourees.iter().any(|y| x.get_name() == y.get_name()))
476        {
477            return Ternary::True;
478        }
479    }
480
481    Ternary::False
482}
483
484/// Represents a specific ordering for sorting.
485#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
486pub enum Ordering {
487    Ascending,
488    Descending,
489}
490
491impl Display for Ordering {
492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493        match self {
494            Self::Ascending => write!(f, "Ascending"),
495            Self::Descending => write!(f, "Descending"),
496        }
497    }
498}
499
500/// Specific ways to sort cards.
501#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
502pub enum Sort {
503    /// Do not sort
504    None,
505    /// Sort by how much they match a string
506    Fuzzy,
507    Alphabet(Text, Ordering),
508    Numeric(Number, Ordering),
509}
510
511/// Restriction that matches only if a card contains some text
512#[must_use]
513pub fn fuzzy(card: &impl Read, query: &str) -> bool {
514    card.get_description()
515        .is_some_and(|x| clean_ascii(x).contains(&clean_ascii(query)))
516        || card
517            .get_name()
518            .is_some_and(|x| clean_ascii(x).contains(&clean_ascii(query)))
519        || card
520            .get_type()
521            .is_some_and(|x| clean_ascii(x).contains(&clean_ascii(query)))
522        || card
523            .get_kin()
524            .is_some_and(|x| clean_ascii(x.get_name()).contains(&clean_ascii(query)))
525        || card.get_keywords().is_some_and(|x| {
526            x.iter()
527                .any(|x| clean_ascii(&x.name).contains(&clean_ascii(query)))
528        })
529}
530
531/// The Cache for `devouredby` queries.
532pub type CachePart<T> = HashMap<String, Vec<T>>;
533
534#[derive(Default)]
535struct Cache<T> {
536    devouredby: CachePart<T>,
537    devourers: CachePart<T>,
538}
539
540#[derive(Deserialize)]
541enum SearchError {
542    ParseError,
543    FromJsError,
544}
545
546#[cfg(target_arch = "wasm32")]
547#[wasm_bindgen]
548pub fn search_wasm(query: &str, cards: JsValue) -> Result<Vec<JsValue>, String> {
549    console_error_panic_hook::set_once();
550    let cards: Vec<Card> =
551        serde_wasm_bindgen::from_value(cards).map_err(|_| "FromJsError".to_string())?;
552    let query: Query = parse_query(query).map_err(|_| "ParseError".to_string())?;
553
554    let matches = search(&query, cards.iter());
555    let mut out = vec![];
556
557    for card in matches {
558        let card = serde_wasm_bindgen::to_value(&card).map_err(|_| "ToJsError".to_string())?;
559
560        out.push(card);
561    }
562
563    Ok(out)
564}
565
566/// Function that takes `cards` and outputs a vector pointing to all the cards that matched the `query`.
567#[must_use]
568pub fn search<'card, 'query, C, I>(query: &'query Query, cards: I) -> Vec<&'card C>
569where
570    C: Read + Clone + 'card,
571    I: IntoIterator<Item = &'card C> + Clone + 'query,
572    I::IntoIter: Clone,
573    &'card C: Read,
574{
575    let mut results: Vec<_> = match query.triviality() {
576        Triviality::NonTrivial => {
577            let iter = cards.into_iter();
578            let iter_clone = iter.clone();
579            let mut cache = Cache {
580                devouredby: HashMap::new(),
581                devourers: HashMap::new(),
582            };
583            iter.filter(|card| {
584                matches_query(*card, query, &iter_clone, &mut cache) == Ternary::True
585            })
586            .collect()
587        }
588        Triviality::Contradiction => return vec![],
589        Triviality::Tautology => cards.into_iter().collect(),
590    };
591
592    match &query.sort {
593        Sort::None => (),
594        Sort::Fuzzy if !query.name.is_empty() => results.sort_by(|a, b| {
595            weighted_compare(b, &query.name)
596                .partial_cmp(&weighted_compare(a, &query.name))
597                .unwrap_or(std::cmp::Ordering::Equal)
598        }),
599        Sort::Fuzzy => results.sort_by(|a, b| Ord::cmp(&a.get_name(), &b.get_name())),
600        Sort::Alphabet(property, Ordering::Ascending) => results.sort_by(|a, b| {
601            Ord::cmp(
602                &a.get_text_property(property),
603                &b.get_text_property(property),
604            )
605        }),
606        Sort::Numeric(property, Ordering::Ascending) => results.sort_by(|a, b| {
607            ImpreciseOrd::imprecise_cmp(
608                &a.get_num_property(property),
609                &b.get_num_property(property),
610            )
611        }),
612        Sort::Alphabet(property, Ordering::Descending) => {
613            results.sort_by(|a, b| {
614                Ord::cmp(
615                    &a.get_text_property(property),
616                    &b.get_text_property(property),
617                )
618                .reverse()
619            });
620        }
621        Sort::Numeric(property, Ordering::Descending) => results.sort_by(|a, b| {
622            ImpreciseOrd::imprecise_cmp(
623                &a.get_num_property(property),
624                &b.get_num_property(property),
625            )
626            .reverse()
627        }),
628    }
629
630    results
631}
632
633/// Checks whether a `card` matches a specific `query`'s restrictions.
634///
635/// Since `devouredby` queries always require two searches, the results of the first search are stored in a `cache` that is internally mutable. This cache is only ever mutated the first time each devouredby query is executed.
636/// The sum total of available `cards` is passed in order to perform searches. This function clones these cards, so this value should be an Iterator.
637#[allow(clippy::too_many_lines)]
638fn matches_query<'card, 'cardvec, 'query, C, T, I>(
639    card: &'card C,
640    query: &'query Query,
641    cards: &'cardvec I,
642    cache: &'query mut Cache<&'card T>,
643) -> Ternary
644where
645    C: Read,
646    T: Read + 'card + Clone,
647    &'card T: Read,
648    I: Iterator<Item = &'card T> + Clone,
649{
650    let mut filtered = Ternary::True;
651    for res in &query.restrictions {
652        match res.triviality() {
653            Triviality::NonTrivial => {
654                filtered = filtered.and(res.is_match(card, query, cards, cache))
655            }
656            Triviality::Contradiction => filtered = filtered.and(Ternary::False),
657            Triviality::Tautology => filtered = filtered.and(Ternary::True),
658        }
659    }
660    filtered
661}
662
663pub trait QueryFrom {
664    // #[must_use]
665    // Creates a vector of `QueryRestriction`s.
666    fn get_as_query(&self) -> Vec<QueryRestriction>;
667}
668
669impl QueryFrom for CardId {
670    fn get_as_query(&self) -> Vec<QueryRestriction> {
671        let mut restrictions = vec![];
672
673        if let Some(name) = &self.name {
674            restrictions.push(QueryRestriction::TextComparison(
675                Text::Name,
676                TextComparison::EqualTo(name.clone()),
677            ));
678        }
679
680        if let Some(kin) = &self.kin {
681            restrictions.push(QueryRestriction::KinComparison(KinComparison::Equal(*kin)));
682        }
683
684        if let Some(keywords) = &self.keywords {
685            for keyword in keywords {
686                let keyword = TextComparison::EqualTo(keyword.name.clone());
687                restrictions.push(QueryRestriction::HasKw(keyword));
688            }
689        }
690
691        if let Some(cost) = &self.cost {
692            restrictions.push(QueryRestriction::NumberComparison(
693                Number::Cost,
694                cost.as_comparison(),
695            ));
696        }
697
698        if let Some(health) = &self.health {
699            restrictions.push(QueryRestriction::NumberComparison(
700                Number::Health,
701                health.as_comparison(),
702            ));
703        }
704
705        if let Some(power) = &self.power {
706            restrictions.push(QueryRestriction::NumberComparison(
707                Number::Power,
708                power.as_comparison(),
709            ));
710        }
711
712        if let Some(defense) = &self.defense {
713            restrictions.push(QueryRestriction::NumberComparison(
714                Number::Defense,
715                defense.as_comparison(),
716            ));
717        }
718
719        restrictions
720    }
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub enum ComparisonKind {
725    Contains,
726    Equals,
727}
728
729#[cfg(test)]
730mod test {
731    use crate::*;
732    use hemoglobin::cards::Card;
733    use query_parser::parse_query;
734
735    #[test]
736    fn test_equals_search() {
737        let cards: Vec<Card> = serde_json::from_str(
738            &std::fs::read_to_string("tests/search.json").expect("Couldn't load search.json"),
739        )
740        .expect("Couldn't convert search.json to a vec of cards");
741        for val in 4..=6 {
742            let result = search(
743                &parse_query(&format!("c={val}")).expect("couldn't parse query"),
744                cards.iter(),
745            );
746
747            let fail = match val {
748                4 => result
749                    .iter()
750                    .any(|x| x.name == "eq 5" || x.name == "gteq 5" || x.name == "gt 5"),
751                5 => result
752                    .iter()
753                    .any(|x| x.name == "lt 5" || x.name == "gt 5" || x.name == "neq 5"),
754                6 => result
755                    .iter()
756                    .any(|x| x.name == "lt 5" || x.name == "lteq 5" || x.name == "eq 5"),
757                _ => unreachable!(),
758            };
759
760            assert!(!fail);
761        }
762    }
763
764    #[test]
765    fn test_gt_search() {
766        let cards: Vec<Card> = serde_json::from_str(
767            &std::fs::read_to_string("tests/search.json").expect("Couldn't load search.json"),
768        )
769        .expect("Couldn't convert search.json to a vec of cards");
770        for val in 4..=6 {
771            let result = search(
772                &parse_query(&format!("c>{val}")).expect("couldn't parse query"),
773                cards.iter(),
774            );
775
776            let fail = match val {
777                4 => result.iter().any(|x| x.name == "lt 5"),
778                5 => result
779                    .iter()
780                    .any(|x| x.name == "lt 5" || x.name == "lteq 5" || x.name == "eq 5"),
781                6 => result
782                    .iter()
783                    .any(|x| x.name == "lt 5" || x.name == "lteq 5" || x.name == "eq 5"),
784                _ => unreachable!(),
785            };
786
787            assert!(!fail);
788        }
789    }
790
791    #[test]
792    fn test_gteq_search() {
793        let cards: Vec<Card> = serde_json::from_str(
794            &std::fs::read_to_string("tests/search.json").expect("Couldn't load search.json"),
795        )
796        .expect("Couldn't convert search.json to a vec of cards");
797
798        for val in 4..=6 {
799            let result = search(
800                &parse_query(&format!("c>={val}")).expect("couldn't parse query"),
801                cards.iter(),
802            );
803
804            let fail = match val {
805                4 => false,
806                5 => result.iter().any(|x| x.name == "lt 5"),
807                6 => result
808                    .iter()
809                    .any(|x| x.name == "lt 5" || x.name == "lteq 5" || x.name == "eq 5"),
810                _ => unreachable!(),
811            };
812
813            assert!(!fail);
814        }
815    }
816
817    #[test]
818    fn search_all() {
819        let cards: Vec<Card> = serde_json::from_str(
820            &std::fs::read_to_string("tests/search.json").expect("Couldn't load search.json"),
821        )
822        .expect("Couldn't convert search.json to a vec of cards");
823
824        let query = parse_query("()").expect("Could not parse query");
825        let results = search(&query, &cards);
826
827        assert_eq!(cards.len(), results.len())
828    }
829
830    #[test]
831    fn no_fuzzy_confusion_avoiding_correct_parse() {
832        let query = parse_query("c=0").expect("Could not parse query");
833        assert!(query.name.is_empty());
834        let query = parse_query("()").expect("Could not parse query");
835        assert!(query.name.is_empty());
836    }
837
838    #[test]
839    fn no_fuzzy_confusion_avoiding_failure() {
840        let _ = parse_query("c=").expect_err("Query should not be parseable");
841        let _ = parse_query("power<").expect_err("Query should not be parseable");
842        let _ = parse_query(r#"d="awawa"#).expect("Query should be parseable");
843    }
844}