//! In-memory fuzzy search over oracle card names (the "rapid entry" path). //! //! Names are loaded once from the reference db into a [`NameIndex`]; each query //! is ranked with nucleo (fzf-quality), giving typo-tolerant ordering far better //! than SQL `LIKE`. ~30k names rank in well under a millisecond. use nucleo::pattern::{CaseMatching, Normalization, Pattern}; use nucleo::{Config, Matcher}; use rusqlite::Connection; use crate::db::REF_DB; use crate::error::Result; use crate::model::{CardRef, ColorSet}; /// One searchable entry, carrying the full [`CardRef`] so a hit needs no extra /// DB round-trip. `AsRef` yields the name that nucleo matches against. struct Entry { card: CardRef, } impl AsRef for Entry { fn as_ref(&self) -> &str { &self.card.name } } /// An immutable, in-memory index of all oracle card names for fuzzy search. pub struct NameIndex { entries: Vec, } impl NameIndex { /// Load every oracle card from the attached reference db. pub fn build(conn: &Connection) -> Result { let mut stmt = conn.prepare(&format!( "SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image} FROM {REF_DB}.oracle_cards oc", image = crate::db::image_subquery() ))?; let entries = stmt .query_map([], |r| { Ok(Entry { card: CardRef { oracle_id: r.get(0)?, name: r.get(1)?, mana_value: r.get(2)?, colors: ColorSet(r.get::<_, i64>(3)? as u8), color_identity: ColorSet(r.get::<_, i64>(4)? as u8), type_line: r.get(5)?, image_uri: r.get::<_, Option>(6)?.unwrap_or_default(), }, }) })? .collect::, _>>()?; Ok(Self { entries }) } pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } /// Rank cards by fuzzy match against `query`, returning up to `limit` of the /// best matches (highest score first). An empty query returns nothing. pub fn search(&self, query: &str, limit: usize) -> Vec { if query.trim().is_empty() { return Vec::new(); } let mut matcher = Matcher::new(Config::DEFAULT); let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); let mut matches = pattern.match_list(self.entries.iter(), &mut matcher); matches.truncate(limit); matches.into_iter().map(|(e, _score)| e.card.clone()).collect() } } #[cfg(test)] mod tests { use crate::test_support; #[test] fn typo_still_ranks_target_first() { let fx = test_support::make(); let idx = fx.catalog.build_name_index().unwrap(); assert_eq!(idx.len(), 5); // Misspelled "Llanowar Elves". let hits = idx.search("lanowar elvs", 5); assert!(!hits.is_empty(), "typo query should still match"); assert_eq!(hits[0].name, "Llanowar Elves"); } #[test] fn prefix_and_limit() { let fx = test_support::make(); let idx = fx.catalog.build_name_index().unwrap(); let hits = idx.search("light", 5); assert_eq!(hits[0].name, "Lightning Bolt"); assert!(idx.search("bolt", 1).len() <= 1); assert!(idx.search("", 10).is_empty()); } }