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>
109 lines
3.5 KiB
Rust
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());
|
|
}
|
|
}
|