Files
mtgc/crates/mtgc-core/src/search.rs
garrettmills 399c628461 Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
  architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
  tile/list view toggle, hover card previews, deck format + commander support,
  per-card location field, grouping by type/subtype/CMC/color, localStorage
  persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00

109 lines
3.5 KiB
Rust

//! 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<str>` yields the name that nucleo matches against.
struct Entry {
card: CardRef,
}
impl AsRef<str> 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<Entry>,
}
impl NameIndex {
/// Load every oracle card from the attached reference db.
pub fn build(conn: &Connection) -> Result<Self> {
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<String>>(6)?.unwrap_or_default(),
},
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
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<CardRef> {
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());
}
}