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>
This commit is contained in:
108
crates/mtgc-core/src/search.rs
Normal file
108
crates/mtgc-core/src/search.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user