use std::ops::RangeInclusive;
use crate::models::{PokemonEntry, RosterKind, RosterTerm};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Query {
pub text: String,
pub text_dex: Option<u32>,
pub rosters: Vec<RosterTerm>,
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)) => {
query.push_roster(RosterKind::Type, value);
}
Some(("ability" | "a", value)) => {
query.push_roster(RosterKind::Ability, value);
}
Some(("egg" | "e", value)) => {
query.push_roster(RosterKind::EggGroup, &egg_group_slug(value));
}
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
}
fn push_roster(&mut self, kind: RosterKind, value: &str) {
if value.is_empty() {
return;
}
let term = RosterTerm::new(kind, value.to_lowercase());
if !self.rosters.contains(&term) {
self.rosters.push(term);
}
}
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 egg_group_slug(value: &str) -> String {
let lowered = value.to_lowercase();
match lowered.as_str() {
"grass" => "plant".to_string(),
"field" => "ground".to_string(),
"human-like" | "humanlike" | "human" => "humanshape".to_string(),
"amorphous" => "indeterminate".to_string(),
"water-1" => "water1".to_string(),
"water-2" => "water2".to_string(),
"water-3" => "water3".to_string(),
_ => lowered,
}
}
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 ghost() -> RosterTerm {
RosterTerm::new(RosterKind::Type, "ghost")
}
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.rosters, [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.rosters, [RosterTerm::new(RosterKind::Type, "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.rosters,
[
RosterTerm::new(RosterKind::Type, "water"),
RosterTerm::new(RosterKind::Type, "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.rosters.is_empty());
}
#[test]
fn abilities_and_egg_groups_are_terms_of_their_own() {
let query = Query::parse("ability:Levitate egg:dragon");
assert_eq!(
query.rosters,
[
RosterTerm::new(RosterKind::Ability, "levitate"),
RosterTerm::new(RosterKind::EggGroup, "dragon"),
]
);
assert!(query.text.is_empty());
}
#[test]
fn ability_and_egg_have_short_forms_too() {
let query = Query::parse("a:levitate e:dragon");
assert_eq!(
query.rosters,
[
RosterTerm::new(RosterKind::Ability, "levitate"),
RosterTerm::new(RosterKind::EggGroup, "dragon"),
]
);
}
#[test]
fn egg_groups_accept_their_in_game_names() {
let slug = |raw: &str| Query::parse(raw).rosters[0].value.clone();
assert_eq!(slug("egg:grass"), "plant");
assert_eq!(slug("egg:Field"), "ground");
assert_eq!(slug("egg:human-like"), "humanshape");
assert_eq!(slug("egg:amorphous"), "indeterminate");
assert_eq!(slug("egg:water-1"), "water1");
assert_eq!(slug("egg:monster"), "monster");
}
#[test]
fn a_repeated_roster_term_is_recorded_once() {
assert_eq!(Query::parse("type:ghost t:Ghost").rosters, [ghost()]);
}
#[test]
fn a_half_typed_roster_term_filters_nothing_extra() {
assert_eq!(Query::parse("type:"), Query::default());
assert_eq!(Query::parse("ability:"), Query::default());
assert_eq!(Query::parse("egg:"), Query::default());
}
#[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)));
}
}