use std::ops::RangeInclusive;
use crate::models::PokemonEntry;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Query {
pub text: String,
pub text_dex: Option<u32>,
pub types: Vec<String>,
pub generations: Vec<u8>,
pub dex: Vec<RangeInclusive<u32>>,
}
impl Query {
pub fn parse(raw: &str) -> Self {
let mut query = Query::default();
let mut words: Vec<&str> = Vec::new();
for token in raw.split_whitespace() {
match token.split_once(':') {
Some(("type" | "t", value)) if !value.is_empty() => {
query.types.push(value.to_lowercase());
}
Some(("gen" | "g", value)) => {
if let Ok(gen) = value.parse::<u8>() {
query.generations.push(gen);
}
}
Some(("dex" | "d", value)) => {
if let Some(range) = parse_dex_range(value) {
query.dex.push(range);
}
}
_ => words.push(token),
}
}
query.text = words.join(" ").to_lowercase();
query.text_dex = query.text.parse().ok();
query
}
pub fn matches_entry(&self, entry: &PokemonEntry) -> bool {
let dex = entry.dex_number();
if !self.text.is_empty() {
let by_name = entry.name.to_lowercase().contains(&self.text);
let by_number = self.text_dex.is_some_and(|number| dex == Some(number));
if !by_name && !by_number {
return false;
}
}
if !self.dex.is_empty() {
let Some(dex) = dex else {
return false;
};
if !self.dex.iter().any(|range| range.contains(&dex)) {
return false;
}
}
if !self.generations.is_empty() {
let Some(gen) = entry.generation() else {
return false;
};
if !self.generations.contains(&gen) {
return false;
}
}
true
}
}
fn parse_dex_range(value: &str) -> Option<RangeInclusive<u32>> {
match value.split_once('-') {
Some((start, end)) => {
let start = start.parse::<u32>().ok()?;
let end = end.parse::<u32>().ok()?;
Some(start.min(end)..=start.max(end))
}
None => {
let number = value.parse::<u32>().ok()?;
Some(number..=number)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn one_range(range: RangeInclusive<u32>) -> Vec<RangeInclusive<u32>> {
vec![range]
}
fn entry(name: &str, id: u32) -> PokemonEntry {
PokemonEntry {
name: name.to_string(),
id,
}
}
#[test]
fn plain_text_is_lowercased_and_joined() {
assert_eq!(Query::parse(" MR Mime ").text, "mr mime");
}
#[test]
fn terms_are_lifted_out_of_the_text() {
let query = Query::parse("gen:1 type:Ghost ga");
assert_eq!(query.text, "ga");
assert_eq!(query.types, ["ghost"]);
assert_eq!(query.generations, [1]);
}
#[test]
fn short_forms_are_accepted() {
let query = Query::parse("t:fire g:2 d:25");
assert_eq!(query.types, ["fire"]);
assert_eq!(query.generations, [2]);
assert_eq!(query.dex, one_range(25..=25));
}
#[test]
fn repeated_terms_accumulate() {
let query = Query::parse("type:water type:flying gen:1 gen:2");
assert_eq!(query.types, ["water", "flying"]);
assert_eq!(query.generations, [1, 2]);
}
#[test]
fn unrecognised_terms_stay_searchable_text() {
let query = Query::parse("colour:red");
assert_eq!(query.text, "colour:red");
assert!(query.types.is_empty());
}
#[test]
fn a_half_typed_generation_filters_nothing_extra() {
assert_eq!(Query::parse("gen:"), Query::default());
}
#[test]
fn a_half_typed_dex_range_filters_nothing_extra() {
assert_eq!(Query::parse("dex:"), Query::default());
assert_eq!(Query::parse("dex:1-"), Query::default());
assert_eq!(Query::parse("dex:eevee"), Query::default());
}
#[test]
fn a_dex_range_is_read_in_either_direction() {
assert_eq!(Query::parse("dex:1-151").dex, one_range(1..=151));
assert_eq!(Query::parse("dex:151-1").dex, one_range(1..=151));
}
#[test]
fn an_empty_query_matches_everything() {
let query = Query::parse(" ");
assert_eq!(query, Query::default());
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(query.matches_entry(&entry("raichu-alola", 10100)));
}
#[test]
fn text_matches_anywhere_in_the_name() {
let query = Query::parse("saur");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(!query.matches_entry(&entry("pikachu", 25)));
}
#[test]
fn generations_are_matched_as_alternatives() {
let query = Query::parse("gen:1 gen:3");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(query.matches_entry(&entry("treecko", 252)));
assert!(!query.matches_entry(&entry("chikorita", 152)));
}
#[test]
fn a_generation_filter_excludes_alternate_forms() {
let query = Query::parse("gen:7");
assert!(!query.matches_entry(&entry("raichu-alola", 10100)));
}
#[test]
fn a_bare_number_matches_by_dex_number() {
let query = Query::parse("25");
assert!(query.matches_entry(&entry("pikachu", 25)));
assert!(!query.matches_entry(&entry("bulbasaur", 1)));
}
#[test]
fn a_bare_number_still_matches_a_name_that_contains_it() {
let query = Query::parse("2");
assert!(query.matches_entry(&entry("porygon2", 233)));
assert!(query.matches_entry(&entry("ivysaur", 2)));
assert!(!query.matches_entry(&entry("pikachu", 25)));
}
#[test]
fn a_dex_term_matches_by_number_alone() {
let query = Query::parse("dex:2");
assert!(query.matches_entry(&entry("ivysaur", 2)));
assert!(!query.matches_entry(&entry("porygon2", 233)));
}
#[test]
fn a_dex_range_matches_its_ends() {
let query = Query::parse("dex:1-151");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(query.matches_entry(&entry("mew", 151)));
assert!(!query.matches_entry(&entry("chikorita", 152)));
}
#[test]
fn dex_terms_are_matched_as_alternatives() {
let query = Query::parse("dex:1-3 dex:25");
assert!(query.matches_entry(&entry("ivysaur", 2)));
assert!(query.matches_entry(&entry("pikachu", 25)));
assert!(!query.matches_entry(&entry("mew", 151)));
}
#[test]
fn a_dex_filter_excludes_alternate_forms() {
let query = Query::parse("dex:26");
assert!(!query.matches_entry(&entry("raichu-alola", 10100)));
}
#[test]
fn dex_and_name_terms_combine() {
let query = Query::parse("dex:1-151 saur");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(!query.matches_entry(&entry("pikachu", 25)));
}
}