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:
2026-07-07 19:37:56 -05:00
commit 399c628461
94 changed files with 11626 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
[package]
name = "mtgc-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Platform-agnostic core for the MTG library catalog: DB, fuzzy search, filters, decks, and Scryfall ingestion."
[lib]
name = "mtgc_core"
path = "src/lib.rs"
# Binary that builds reference.db from Scryfall bulk data.
[[bin]]
name = "mtgc-ingest"
path = "src/bin/ingest.rs"
[dependencies]
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
nucleo = { workspace = true }
ureq = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,28 @@
//! CLI: build reference.db from Scryfall bulk data.
//!
//! Usage: `mtgc-ingest [path/to/reference.db]` (defaults to ./reference.db)
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
let dest = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("reference.db"));
eprintln!("Building reference db at {}", dest.display());
match mtgc_core::ingest::build_reference_db(&dest, |msg| eprintln!(" {msg}")) {
Ok(stats) => {
eprintln!(
"Done: {} oracle cards, {} printings.",
stats.oracle_cards, stats.printings
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Ingest failed: {e}");
ExitCode::FAILURE
}
}
}

118
crates/mtgc-core/src/db.rs Normal file
View File

@@ -0,0 +1,118 @@
//! Database wiring: open the library, run migrations, ATTACH the reference db.
use std::path::Path;
use rusqlite::Connection;
use crate::error::Result;
/// The reference schema, applied to a freshly-created reference.db by the ingester
/// (and by tests that build a fixture reference db).
pub const REFERENCE_SCHEMA: &str = include_str!("sql/reference_schema.sql");
/// Ordered library migrations. Index + 1 is the schema version recorded in
/// `PRAGMA user_version`. Append new migrations; never edit an applied one.
const LIBRARY_MIGRATIONS: &[&str] = &[
include_str!("sql/library/0001_init.sql"),
include_str!("sql/library/0002_location.sql"),
include_str!("sql/library/0003_deck_format.sql"),
];
/// The attached alias under which the reference db is available at runtime.
pub const REF_DB: &str = "ref";
/// Correlated subquery selecting one image URL for the oracle card aliased `oc`.
/// Returns NULL when no printing has an image. Read it as `Option<String>`.
pub fn image_subquery() -> String {
format!(
"(SELECT p.image_uri FROM {REF_DB}.printings p
WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> '' LIMIT 1)"
)
}
/// Create (if needed) and open a reference.db, applying the reference schema.
/// Used by the ingester and by test fixtures.
pub fn open_reference_for_build(path: &Path) -> Result<Connection> {
let conn = Connection::open(path)?;
conn.execute_batch(REFERENCE_SCHEMA)?;
Ok(conn)
}
/// Open the library db at `library_path`, run migrations, and ATTACH the
/// reference db at `reference_path` under the [`REF_DB`] alias.
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> {
let conn = Connection::open(library_path)?;
conn.pragma_update(None, "foreign_keys", true)?;
migrate_library(&conn)?;
attach_reference(&conn, reference_path)?;
Ok(conn)
}
/// ATTACH a reference db to an already-open library connection.
pub fn attach_reference(conn: &Connection, reference_path: &Path) -> Result<()> {
conn.execute(
&format!("ATTACH DATABASE ?1 AS {REF_DB}"),
[reference_path.to_string_lossy()],
)?;
Ok(())
}
/// Apply any library migrations newer than the recorded `user_version`.
pub fn migrate_library(conn: &Connection) -> Result<()> {
let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
for (idx, sql) in LIBRARY_MIGRATIONS.iter().enumerate() {
let version = (idx + 1) as i64;
if version > current {
conn.execute_batch(&format!("BEGIN;\n{sql}\nPRAGMA user_version = {version};\nCOMMIT;"))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrations_are_idempotent_and_bump_version() {
let conn = Connection::open_in_memory().unwrap();
migrate_library(&conn).unwrap();
let v: i64 = conn
.pragma_query_value(None, "user_version", |r| r.get(0))
.unwrap();
assert_eq!(v, LIBRARY_MIGRATIONS.len() as i64);
// Running again is a no-op (no "table already exists" error).
migrate_library(&conn).unwrap();
// Core tables exist.
let n: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('decks','library_cards','deck_cards','library_card_copies')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 4);
}
#[test]
fn reference_schema_applies_with_fts() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(REFERENCE_SCHEMA).unwrap();
// FTS5 + trigram tokenizer must be available in the bundled SQLite.
conn.execute(
"INSERT INTO oracle_names_fts(name, oracle_id) VALUES ('Llanowar Elves','x')",
[],
)
.unwrap();
let hit: i64 = conn
.query_row(
"SELECT count(*) FROM oracle_names_fts WHERE oracle_names_fts MATCH 'lan'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(hit, 1);
}
}

View File

@@ -0,0 +1,287 @@
//! Deck operations and deck membership lookups.
use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
use crate::Catalog;
impl Catalog {
/// Create a deck. `commander_oracle_id` is optional.
pub fn create_deck(&self, name: &str, commander_oracle_id: Option<&str>) -> Result<Deck> {
self.conn.execute(
"INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)",
params![name, commander_oracle_id],
)?;
let id = self.conn.last_insert_rowid();
self.get_deck(id)
.map(|d| d.expect("just-inserted deck must exist"))
}
pub fn get_deck(&self, id: i64) -> Result<Option<Deck>> {
Ok(self
.conn
.query_row(
"SELECT id, name, format, commander_oracle_id, notes FROM decks WHERE id = ?1",
params![id],
Self::map_deck,
)
.optional()?)
}
pub fn list_decks(&self) -> Result<Vec<Deck>> {
let mut stmt = self.conn.prepare(
"SELECT id, name, format, commander_oracle_id, notes
FROM decks ORDER BY name COLLATE NOCASE",
)?;
let rows = stmt
.query_map([], Self::map_deck)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn delete_deck(&self, id: i64) -> Result<()> {
// ON DELETE CASCADE clears deck_cards.
self.conn
.execute("DELETE FROM decks WHERE id = ?1", params![id])?;
Ok(())
}
/// Set how many copies of a card belong to a deck. `quantity <= 0` removes
/// the card from the deck. Upserts on (deck_id, oracle_id).
pub fn set_deck_card(
&self,
deck_id: i64,
oracle_id: &str,
quantity: i64,
is_commander: bool,
) -> Result<()> {
if quantity <= 0 {
self.conn.execute(
"DELETE FROM deck_cards WHERE deck_id = ?1 AND oracle_id = ?2",
params![deck_id, oracle_id],
)?;
return Ok(());
}
self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity,
is_commander = excluded.is_commander",
params![deck_id, oracle_id, quantity, is_commander as i64],
)?;
Ok(())
}
/// All cards in a deck — owned or not — with their in-deck quantity and how
/// many copies are owned (0 if the card isn't in the library). Callers split
/// this into "in your library" vs "not owned" groups.
pub fn deck_contents(&self, deck_id: i64) -> Result<Vec<DeckEntry>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
WHERE dc.deck_id = ?1
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery()
))?;
let rows = stmt
.query_map(params![deck_id], |r| {
Ok(DeckEntry {
quantity: r.get(0)?,
is_commander: r.get::<_, i64>(1)? != 0,
quantity_owned: r.get(2)?,
card: CardRef {
oracle_id: r.get(3)?,
name: r.get(4)?,
mana_value: r.get(5)?,
colors: ColorSet(r.get::<_, i64>(6)? as u8),
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
type_line: r.get(8)?,
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
},
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
/// The decks a given card is currently assigned to.
pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> {
let mut stmt = self.conn.prepare(
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
WHERE dc.oracle_id = ?1
ORDER BY d.name COLLATE NOCASE",
)?;
let rows = stmt
.query_map(params![oracle_id], Self::map_deck)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
fn map_deck(r: &rusqlite::Row) -> rusqlite::Result<Deck> {
Ok(Deck {
id: r.get(0)?,
name: r.get(1)?,
format: r.get(2)?,
commander_oracle_id: r.get(3)?,
notes: r.get(4)?,
})
}
/// Set the deck's format (e.g. "Commander"). Empty string clears it.
pub fn set_deck_format(&self, deck_id: i64, format: &str) -> Result<()> {
self.conn.execute(
"UPDATE decks SET format = ?1 WHERE id = ?2",
params![format, deck_id],
)?;
Ok(())
}
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
/// the corresponding deck_cards row as the commander when present.
pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
self.conn.execute(
"UPDATE decks SET commander_oracle_id = ?1 WHERE id = ?2",
params![oracle_id, deck_id],
)?;
// Reset any existing commander flag, then set it on the chosen card.
self.conn.execute(
"UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1",
params![deck_id],
)?;
if let Some(oid) = oracle_id {
self.conn.execute(
"UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2",
params![deck_id, oid],
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::test_support;
#[test]
fn deck_membership_visible_from_library() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("llanowar-elves", 4).unwrap();
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap();
cat.set_deck_card(elves.id, "llanowar-elves", 4, false)
.unwrap();
// card_decks reports membership.
let decks = cat.card_decks("llanowar-elves").unwrap();
assert_eq!(decks.len(), 1);
assert_eq!(decks[0].name, "Elfball");
// list_library surfaces the deck name on the card row.
let lib = cat.list_library().unwrap();
let row = lib
.iter()
.find(|c| c.card.oracle_id == "llanowar-elves")
.unwrap();
assert_eq!(row.decks, vec!["Elfball".to_string()]);
assert_eq!(row.quantity_assigned, 4);
assert!(!row.is_over_assigned());
}
#[test]
fn over_assignment_detected_across_decks() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap(); // own one physical copy
let a = cat.create_deck("Deck A", None).unwrap();
let b = cat.create_deck("Deck B", None).unwrap();
cat.set_deck_card(a.id, "sol-ring", 1, false).unwrap();
cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.quantity_assigned, 2);
assert_eq!(sol.quantity_owned, 1);
assert!(sol.is_over_assigned());
assert_eq!(sol.decks.len(), 2);
}
#[test]
fn format_and_commander_metadata() {
let fx = test_support::make();
let cat = &fx.catalog;
let d = cat.create_deck("EDH", None).unwrap();
assert_eq!(d.format, "");
cat.set_deck_format(d.id, "Commander").unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false).unwrap();
cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
let d = cat.get_deck(d.id).unwrap().unwrap();
assert_eq!(d.format, "Commander");
assert_eq!(d.commander_oracle_id.as_deref(), Some("llanowar-elves"));
// The card is flagged as commander in the deck.
let commander = cat
.deck_contents(d.id)
.unwrap()
.into_iter()
.find(|e| e.card.oracle_id == "llanowar-elves")
.unwrap();
assert!(commander.is_commander);
// Clearing works.
cat.set_deck_commander(d.id, None).unwrap();
assert_eq!(
cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id,
None
);
}
#[test]
fn deck_contents_include_owned_and_unowned() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("llanowar-elves", 4).unwrap(); // owned
// serra-angel intentionally NOT owned.
let d = cat.create_deck("Mixed", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 4, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
assert_eq!(contents.len(), 2);
let owned: Vec<_> = contents.iter().filter(|e| e.quantity_owned > 0).collect();
let unowned: Vec<_> = contents.iter().filter(|e| e.quantity_owned == 0).collect();
assert_eq!(owned.len(), 1);
assert_eq!(owned[0].card.name, "Llanowar Elves");
assert_eq!(owned[0].quantity, 4);
assert_eq!(unowned.len(), 1);
assert_eq!(unowned[0].card.name, "Serra Angel");
assert!(unowned[0].is_commander);
}
#[test]
fn setting_zero_removes_and_cascade_on_delete() {
let fx = test_support::make();
let cat = &fx.catalog;
let d = cat.create_deck("Temp", None).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 2, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
cat.set_deck_card(d.id, "lightning-bolt", 1, false).unwrap();
cat.delete_deck(d.id).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
}
}

View File

@@ -0,0 +1,29 @@
//! Core error type.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CoreError {
#[error(transparent)]
Sqlite(#[from] rusqlite::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("http error: {0}")]
Http(String),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, CoreError>;
impl From<ureq::Error> for CoreError {
fn from(e: ureq::Error) -> Self {
CoreError::Http(e.to_string())
}
}

View File

@@ -0,0 +1,228 @@
//! Criteria search over the owned library, compiled to SQL across the library
//! and attached reference dbs.
use rusqlite::types::Value;
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{LibraryCard, LibraryFilter};
use crate::Catalog;
impl Catalog {
/// Search the owned library by the given criteria. An empty filter returns
/// the whole owned library (same set as [`Catalog::list_library`]).
///
/// Semantics: within a facet, multiple `types`/`subtypes`/`keywords` values
/// are ANDed (the card must have all of them). Commander compatibility keeps
/// only cards whose color identity is a subset of the given identity.
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
let mut sql = format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0",
image = crate::db::image_subquery()
);
let mut vals: Vec<Value> = Vec::new();
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
sql.push_str(" AND oc.name_lower LIKE ?");
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
}
if let Some(c) = filter.colors_exact {
sql.push_str(" AND oc.colors = ?");
vals.push(Value::Integer(c.0 as i64));
}
if let Some(c) = filter.colors_include {
sql.push_str(" AND (oc.colors & ?) = ?");
vals.push(Value::Integer(c.0 as i64));
vals.push(Value::Integer(c.0 as i64));
}
if let Some(id) = filter.commander_identity {
// subset: card_identity & ~commander == 0 (see ColorSet::is_subset_of)
sql.push_str(" AND (oc.color_identity & ?) = 0");
vals.push(Value::Integer((!id.0 & 0b11111) as i64));
}
if let Some(min) = filter.cmc_min {
sql.push_str(" AND oc.mana_value >= ?");
vals.push(Value::Real(min));
}
if let Some(max) = filter.cmc_max {
sql.push_str(" AND oc.mana_value <= ?");
vals.push(Value::Real(max));
}
for t in &filter.types {
sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_types x
WHERE x.oracle_id = oc.oracle_id AND x.type = ? COLLATE NOCASE)"
));
vals.push(Value::Text(t.clone()));
}
for s in &filter.subtypes {
sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes x
WHERE x.oracle_id = oc.oracle_id AND x.subtype = ? COLLATE NOCASE)"
));
vals.push(Value::Text(s.clone()));
}
for k in &filter.keywords {
sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_keywords x
WHERE x.oracle_id = oc.oracle_id AND x.keyword = ? COLLATE NOCASE)"
));
vals.push(Value::Text(k.clone()));
}
if let Some(deck_id) = filter.in_deck {
sql.push_str(
" AND EXISTS (SELECT 1 FROM deck_cards dc
WHERE dc.oracle_id = oc.oracle_id AND dc.deck_id = ?)",
);
vals.push(Value::Integer(deck_id));
}
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(
rusqlite::params_from_iter(vals),
crate::library::row_to_library_parts,
)?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Enrich with deck context (shared with list_library).
let deck_names = self.deck_names_by_oracle()?;
let assigned = self.assigned_totals_by_oracle()?;
Ok(rows
.into_iter()
.map(|(oid, qty, location, card)| LibraryCard {
quantity_owned: qty,
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
location,
card,
})
.collect())
}
}
#[cfg(test)]
mod tests {
use crate::model::{ColorSet, LibraryFilter};
use crate::test_support;
/// Own the whole fixture set so filters have something to select from.
fn owned_fixture() -> test_support::Fixture {
let fx = test_support::make();
for c in test_support::seed_cards() {
fx.catalog.add_owned(c.oracle_id, 1).unwrap();
}
fx
}
fn names(cards: &[crate::model::LibraryCard]) -> Vec<String> {
cards.iter().map(|c| c.card.name.clone()).collect()
}
#[test]
fn empty_filter_returns_all_owned() {
let fx = owned_fixture();
let all = fx.catalog.search_library(&LibraryFilter::default()).unwrap();
assert_eq!(all.len(), 5);
}
#[test]
fn commander_identity_subset() {
let fx = owned_fixture();
// A B/G commander: Golgari (BG) and mono-B/mono-G and colorless are legal;
// white/red cards are not.
let f = LibraryFilter {
commander_identity: Some(ColorSet::parse("BG")),
..Default::default()
};
let got = names(&fx.catalog.search_library(&f).unwrap());
assert!(got.contains(&"Deathrite Shaman".to_string())); // BG
assert!(got.contains(&"Llanowar Elves".to_string())); // G
assert!(got.contains(&"Sol Ring".to_string())); // colorless
assert!(!got.contains(&"Serra Angel".to_string())); // W
assert!(!got.contains(&"Lightning Bolt".to_string())); // R
// Mono-white commander: the BG card is illegal.
let f = LibraryFilter {
commander_identity: Some(ColorSet::parse("W")),
..Default::default()
};
let got = names(&fx.catalog.search_library(&f).unwrap());
assert!(got.contains(&"Serra Angel".to_string()));
assert!(!got.contains(&"Deathrite Shaman".to_string()));
}
#[test]
fn type_subtype_keyword_and_cmc() {
let fx = owned_fixture();
let creatures = names(&fx.catalog.search_library(&LibraryFilter {
types: vec!["Creature".into()],
..Default::default()
}).unwrap());
assert_eq!(creatures.len(), 3); // Llanowar, Deathrite, Serra
let elves = names(&fx.catalog.search_library(&LibraryFilter {
subtypes: vec!["Elf".into()],
..Default::default()
}).unwrap());
assert_eq!(elves, vec!["Deathrite Shaman", "Llanowar Elves"]);
let flyers = names(&fx.catalog.search_library(&LibraryFilter {
keywords: vec!["Flying".into()],
..Default::default()
}).unwrap());
assert_eq!(flyers, vec!["Serra Angel"]);
let cheap = fx.catalog.search_library(&LibraryFilter {
cmc_max: Some(1.0),
..Default::default()
}).unwrap();
assert!(cheap.iter().all(|c| c.card.mana_value <= 1.0));
assert!(!cheap.iter().any(|c| c.card.name == "Serra Angel"));
}
#[test]
fn colors_exact_and_include() {
let fx = owned_fixture();
let exact_g = names(&fx.catalog.search_library(&LibraryFilter {
colors_exact: Some(ColorSet::parse("G")),
..Default::default()
}).unwrap());
assert_eq!(exact_g, vec!["Llanowar Elves"]); // not Deathrite (BG)
let has_g = names(&fx.catalog.search_library(&LibraryFilter {
colors_include: Some(ColorSet::parse("G")),
..Default::default()
}).unwrap());
assert!(has_g.contains(&"Llanowar Elves".to_string()));
assert!(has_g.contains(&"Deathrite Shaman".to_string()));
}
#[test]
fn text_and_in_deck() {
let fx = owned_fixture();
let cat = &fx.catalog;
let bolt = names(&cat.search_library(&LibraryFilter {
text: Some("bolt".into()),
..Default::default()
}).unwrap());
assert_eq!(bolt, vec!["Lightning Bolt"]);
let deck = cat.create_deck("Mono-R", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false).unwrap();
let in_deck = names(&cat.search_library(&LibraryFilter {
in_deck: Some(deck.id),
..Default::default()
}).unwrap());
assert_eq!(in_deck, vec!["Lightning Bolt"]);
}
}

View File

@@ -0,0 +1,448 @@
//! Build `reference.db` from Scryfall bulk data.
//!
//! Two passes: the *Oracle Cards* bulk (one entry per `oracle_id`) fills
//! `oracle_cards` + the normalized type/subtype/keyword tables + the name FTS;
//! the *Default Cards* bulk (one entry per printing) fills `printings`.
//!
//! Both bulk files are large JSON arrays, so we stream them element-by-element
//! rather than loading the whole array into memory.
use std::io::{BufReader, Read};
use std::marker::PhantomData;
use std::path::Path;
use rusqlite::{params, Connection};
use serde::de::{DeserializeOwned, SeqAccess, Visitor};
use serde::Deserialize;
use crate::db::open_reference_for_build;
use crate::error::{CoreError, Result};
use crate::model::ColorSet;
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
/// Row counts from a build, for reporting/verification.
#[derive(Debug, Default, Clone, Copy)]
pub struct IngestStats {
pub oracle_cards: usize,
pub printings: usize,
}
/// Download the latest Scryfall bulk data and (re)build `reference.db` at `dest`.
///
/// `progress` is called with human-readable status lines.
pub fn build_reference_db(dest: &Path, mut progress: impl FnMut(&str)) -> Result<IngestStats> {
let (oracle_uri, default_uri) = fetch_bulk_uris(&mut progress)?;
if dest.exists() {
std::fs::remove_file(dest)?;
}
let conn = open_reference_for_build(dest)?;
// Bulk-load fast; this db is disposable/rebuildable so durability is moot.
conn.pragma_update(None, "journal_mode", "MEMORY")?;
conn.pragma_update(None, "synchronous", "OFF")?;
progress("Loading oracle cards…");
let oracle_cards = load_oracle_cards(&conn, http_reader(&oracle_uri)?)?;
progress("Loading printings…");
let printings = load_printings(&conn, http_reader(&default_uri)?)?;
progress("Optimizing…");
conn.execute_batch("INSERT INTO oracle_names_fts(oracle_names_fts) VALUES('optimize');")?;
Ok(IngestStats {
oracle_cards,
printings,
})
}
// ---- Scryfall bulk index -------------------------------------------------
#[derive(Deserialize)]
struct BulkIndex {
data: Vec<BulkEntry>,
}
#[derive(Deserialize)]
struct BulkEntry {
#[serde(rename = "type")]
kind: String,
download_uri: String,
}
fn fetch_bulk_uris(progress: &mut impl FnMut(&str)) -> Result<(String, String)> {
progress("Fetching Scryfall bulk index…");
let index: BulkIndex = ureq::get(BULK_INDEX_URL)
.set("User-Agent", USER_AGENT)
.set("Accept", "application/json")
.call()?
.into_json()?;
let find = |kind: &str| {
index
.data
.iter()
.find(|e| e.kind == kind)
.map(|e| e.download_uri.clone())
.ok_or_else(|| CoreError::Other(format!("Scryfall bulk index missing '{kind}'")))
};
Ok((find("oracle_cards")?, find("default_cards")?))
}
fn http_reader(url: &str) -> Result<impl Read> {
let resp = ureq::get(url)
.set("User-Agent", USER_AGENT)
.call()?;
Ok(BufReader::with_capacity(1 << 20, resp.into_reader()))
}
// ---- Streaming array parse ----------------------------------------------
/// Deserialize a top-level JSON array element-by-element, invoking `f` per item
/// without holding the whole array in memory.
fn stream_json_array<R, T, F>(reader: R, f: F) -> Result<()>
where
R: Read,
T: DeserializeOwned,
F: FnMut(T) -> Result<()>,
{
struct SeqVisitor<T, F> {
f: F,
_marker: PhantomData<fn() -> T>,
}
impl<'de, T, F> Visitor<'de> for SeqVisitor<T, F>
where
T: DeserializeOwned,
F: FnMut(T) -> Result<()>,
{
type Value = ();
fn expecting(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
fmt.write_str("a JSON array of objects")
}
fn visit_seq<A>(mut self, mut seq: A) -> std::result::Result<(), A::Error>
where
A: SeqAccess<'de>,
{
while let Some(item) = seq.next_element::<T>()? {
(self.f)(item).map_err(serde::de::Error::custom)?;
}
Ok(())
}
}
let mut de = serde_json::Deserializer::from_reader(reader);
serde::de::Deserializer::deserialize_seq(
&mut de,
SeqVisitor {
f,
_marker: PhantomData,
},
)?;
Ok(())
}
// ---- Oracle pass ---------------------------------------------------------
#[derive(Deserialize)]
struct ScryOracle {
oracle_id: Option<String>,
name: String,
#[serde(default)]
cmc: f64,
#[serde(default)]
colors: Option<Vec<String>>,
#[serde(default)]
color_identity: Vec<String>,
#[serde(default)]
type_line: Option<String>,
#[serde(default)]
oracle_text: Option<String>,
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
card_faces: Option<Vec<ScryFace>>,
}
#[derive(Deserialize)]
struct ScryFace {
#[serde(default)]
type_line: Option<String>,
#[serde(default)]
colors: Option<Vec<String>>,
}
fn load_oracle_cards(conn: &Connection, reader: impl Read) -> Result<usize> {
conn.execute_batch("BEGIN")?;
let mut count = 0usize;
{
let mut ins_card = conn.prepare(
"INSERT OR IGNORE INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
)?;
let mut ins_fts =
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
let mut ins_type =
conn.prepare("INSERT INTO card_types(oracle_id,type) VALUES (?1,?2)")?;
let mut ins_sub =
conn.prepare("INSERT INTO card_subtypes(oracle_id,subtype) VALUES (?1,?2)")?;
let mut ins_kw =
conn.prepare("INSERT INTO card_keywords(oracle_id,keyword) VALUES (?1,?2)")?;
stream_json_array::<_, ScryOracle, _>(reader, |c| {
let Some(oracle_id) = c.oracle_id.clone() else {
return Ok(()); // rare face-only records without a top-level oracle_id
};
// Colors: prefer top-level, else union of faces.
let colors = match &c.colors {
Some(cs) => ColorSet::from_letters(cs),
None => {
let mut m = 0u8;
if let Some(faces) = &c.card_faces {
for f in faces {
if let Some(cs) = &f.colors {
m |= ColorSet::from_letters(cs).0;
}
}
}
ColorSet(m)
}
};
let identity = ColorSet::from_letters(&c.color_identity);
// Type line: top-level, else join faces.
let type_line = c.type_line.clone().unwrap_or_else(|| {
c.card_faces
.as_ref()
.map(|faces| {
faces
.iter()
.filter_map(|f| f.type_line.clone())
.collect::<Vec<_>>()
.join(" // ")
})
.unwrap_or_default()
});
let changed = ins_card.execute(params![
oracle_id,
c.name,
c.name.to_lowercase(),
c.cmc,
colors.0 as i64,
identity.0 as i64,
type_line,
c.oracle_text.clone().unwrap_or_default(),
])?;
// Only emit satellite rows the first time we see this oracle_id.
if changed == 1 {
ins_fts.execute(params![c.name, oracle_id])?;
let (types, subtypes) = parse_type_line(&type_line);
for t in types {
ins_type.execute(params![oracle_id, t])?;
}
for s in subtypes {
ins_sub.execute(params![oracle_id, s])?;
}
let mut kws = c.keywords.clone();
kws.sort();
kws.dedup();
for k in kws {
ins_kw.execute(params![oracle_id, k])?;
}
count += 1;
}
Ok(())
})?;
}
conn.execute_batch("COMMIT")?;
Ok(count)
}
/// Split a Scryfall type line into (types+supertypes, subtypes). Handles split /
/// double-faced lines joined by " // ". The separator is the em dash '—'.
pub fn parse_type_line(type_line: &str) -> (Vec<String>, Vec<String>) {
let mut types = Vec::new();
let mut subtypes = Vec::new();
for part in type_line.split("//") {
let part = part.trim();
let mut halves = part.splitn(2, '—');
let left = halves.next().unwrap_or("").trim();
let right = halves.next().unwrap_or("").trim();
for t in left.split_whitespace() {
types.push(t.to_string());
}
for s in right.split_whitespace() {
subtypes.push(s.to_string());
}
}
types.sort();
types.dedup();
subtypes.sort();
subtypes.dedup();
(types, subtypes)
}
// ---- Printings pass ------------------------------------------------------
#[derive(Deserialize)]
struct ScryPrinting {
id: String,
oracle_id: Option<String>,
#[serde(default)]
set: String,
#[serde(default)]
collector_number: String,
#[serde(default)]
finishes: Vec<String>,
#[serde(default)]
rarity: String,
#[serde(default)]
image_uris: Option<ImageUris>,
#[serde(default)]
card_faces: Option<Vec<PrintFace>>,
}
#[derive(Deserialize)]
struct ImageUris {
#[serde(default)]
normal: Option<String>,
}
#[derive(Deserialize)]
struct PrintFace {
#[serde(default)]
image_uris: Option<ImageUris>,
}
fn load_printings(conn: &Connection, reader: impl Read) -> Result<usize> {
conn.execute_batch("BEGIN")?;
let mut count = 0usize;
{
let mut ins = conn.prepare(
"INSERT OR IGNORE INTO printings
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri)
VALUES (?1,?2,?3,?4,?5,?6,?7)",
)?;
stream_json_array::<_, ScryPrinting, _>(reader, |p| {
let Some(oracle_id) = p.oracle_id.clone() else {
return Ok(());
};
let image = p
.image_uris
.as_ref()
.and_then(|i| i.normal.clone())
.or_else(|| {
p.card_faces
.as_ref()
.and_then(|f| f.first())
.and_then(|f| f.image_uris.as_ref())
.and_then(|i| i.normal.clone())
})
.unwrap_or_default();
ins.execute(params![
p.id,
oracle_id,
p.set,
p.collector_number,
p.finishes.join(","),
p.rarity,
image,
])?;
count += 1;
Ok(())
})?;
}
conn.execute_batch("COMMIT")?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_type_lines() {
let (t, s) = parse_type_line("Legendary Creature — Elf Druid");
assert_eq!(t, vec!["Creature", "Legendary"]);
assert_eq!(s, vec!["Druid", "Elf"]);
let (t, s) = parse_type_line("Instant");
assert_eq!(t, vec!["Instant"]);
assert!(s.is_empty());
// Split card.
let (t, s) = parse_type_line("Sorcery // Sorcery");
assert_eq!(t, vec!["Sorcery"]);
assert!(s.is_empty());
}
#[test]
fn streams_array_of_objects() {
#[derive(serde::Deserialize)]
struct Item {
n: i32,
}
let json = br#"[{"n":1},{"n":2},{"n":3}]"#;
let mut sum = 0;
stream_json_array::<_, Item, _>(&json[..], |it| {
sum += it.n;
Ok(())
})
.unwrap();
assert_eq!(sum, 6);
}
#[test]
fn oracle_and_printing_load_from_fixture_json() {
// Minimal Scryfall-shaped fixtures exercising the insert paths.
let oracle = r#"[
{"oracle_id":"oid-1","name":"Llanowar Elves","cmc":1.0,
"colors":["G"],"color_identity":["G"],
"type_line":"Creature — Elf Druid","keywords":[]},
{"oracle_id":"oid-2","name":"Serra Angel","cmc":5.0,
"colors":["W"],"color_identity":["W"],
"type_line":"Creature — Angel","keywords":["Flying","Vigilance"]}
]"#;
let printings = r#"[
{"id":"p-1","oracle_id":"oid-1","set":"m19","collector_number":"310",
"finishes":["nonfoil","foil"],"rarity":"common",
"image_uris":{"normal":"http://img/1"}}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
let oc = load_oracle_cards(&conn, oracle.as_bytes()).unwrap();
let pc = load_printings(&conn, printings.as_bytes()).unwrap();
assert_eq!(oc, 2);
assert_eq!(pc, 1);
// Keyword table populated.
let flyers: i64 = conn
.query_row(
"SELECT count(*) FROM card_keywords WHERE keyword='Flying'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(flyers, 1);
// Bitmask colors stored.
let g: i64 = conn
.query_row(
"SELECT colors FROM oracle_cards WHERE oracle_id='oid-1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(g as u8, crate::model::G);
}
}

217
crates/mtgc-core/src/lib.rs Normal file
View File

@@ -0,0 +1,217 @@
//! mtgc-core — platform-agnostic core for the MTG library catalog.
//!
//! Everything that isn't UI lives here: the SQLite schema/migrations, the
//! in-memory fuzzy name search, the criteria filters, deck operations, and the
//! Scryfall ingester. It has no Tauri (or any GUI) dependency so it stays
//! unit-testable and reusable when the app is ported to mobile.
pub mod db;
pub mod decks;
pub mod error;
pub mod filters;
pub mod ingest;
pub mod library;
pub mod model;
pub mod search;
use std::path::Path;
use rusqlite::Connection;
pub use error::{CoreError, Result};
pub use model::{CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFilter};
pub use search::NameIndex;
/// A handle to an open catalog: the library db with the reference db attached.
///
/// All read/write operations go through this. It owns the SQLite connection;
/// the fuzzy [`NameIndex`] is built separately (see [`Catalog::build_name_index`])
/// so the caller can cache it in app state.
pub struct Catalog {
pub(crate) conn: Connection,
}
impl Catalog {
/// Open the library at `library_path` (running migrations) with the
/// reference db at `reference_path` attached.
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Self> {
let conn = db::open(library_path, reference_path)?;
Ok(Self { conn })
}
/// Build (or rebuild) the in-memory fuzzy name index from the attached
/// reference db. Cheap enough to hold in app state and rebuild after a
/// reference-data update.
pub fn build_name_index(&self) -> Result<NameIndex> {
NameIndex::build(&self.conn)
}
/// Borrow the underlying connection (used by tests and advanced callers).
pub fn connection(&self) -> &Connection {
&self.conn
}
}
#[cfg(test)]
pub(crate) mod test_support {
//! Builds a tiny library+reference catalog in temp files for tests.
use super::*;
use crate::model::ColorSet;
pub struct Fixture {
pub catalog: Catalog,
// Keep the temp dir alive for the lifetime of the fixture.
_dir: tempfile::TempDir,
}
/// (oracle_id, name, mana_value, colors, color_identity, type_line,
/// types, subtypes, keywords)
pub struct SeedCard {
pub oracle_id: &'static str,
pub name: &'static str,
pub mv: f64,
pub colors: &'static str,
pub identity: &'static str,
pub type_line: &'static str,
pub types: &'static [&'static str],
pub subtypes: &'static [&'static str],
pub keywords: &'static [&'static str],
}
pub fn seed_cards() -> Vec<SeedCard> {
vec![
SeedCard {
oracle_id: "llanowar-elves",
name: "Llanowar Elves",
mv: 1.0,
colors: "G",
identity: "G",
type_line: "Creature — Elf Druid",
types: &["Creature"],
subtypes: &["Elf", "Druid"],
keywords: &[],
},
SeedCard {
oracle_id: "deathrite-shaman",
name: "Deathrite Shaman",
mv: 1.0,
colors: "BG",
identity: "BG",
type_line: "Creature — Elf Shaman",
types: &["Creature"],
subtypes: &["Elf", "Shaman"],
keywords: &[],
},
SeedCard {
oracle_id: "serra-angel",
name: "Serra Angel",
mv: 5.0,
colors: "W",
identity: "W",
type_line: "Creature — Angel",
types: &["Creature"],
subtypes: &["Angel"],
keywords: &["Flying", "Vigilance"],
},
SeedCard {
oracle_id: "lightning-bolt",
name: "Lightning Bolt",
mv: 1.0,
colors: "R",
identity: "R",
type_line: "Instant",
types: &["Instant"],
subtypes: &[],
keywords: &[],
},
SeedCard {
oracle_id: "sol-ring",
name: "Sol Ring",
mv: 1.0,
colors: "",
identity: "",
type_line: "Artifact",
types: &["Artifact"],
subtypes: &[],
keywords: &[],
},
]
}
pub fn make() -> Fixture {
let dir = tempfile::tempdir().unwrap();
let ref_path = dir.path().join("reference.db");
let lib_path = dir.path().join("library.db");
// Build reference.db with fixture cards.
{
let rconn = db::open_reference_for_build(&ref_path).unwrap();
for c in seed_cards() {
let colors = ColorSet::parse(c.colors).0 as i64;
let identity = ColorSet::parse(c.identity).0 as i64;
rconn
.execute(
"INSERT INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line)
VALUES (?1,?2,?3,?4,?5,?6,?7)",
rusqlite::params![
c.oracle_id,
c.name,
c.name.to_lowercase(),
c.mv,
colors,
identity,
c.type_line
],
)
.unwrap();
rconn
.execute(
"INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)",
rusqlite::params![c.name, c.oracle_id],
)
.unwrap();
for t in c.types {
rconn
.execute(
"INSERT INTO card_types(oracle_id,type) VALUES (?1,?2)",
rusqlite::params![c.oracle_id, t],
)
.unwrap();
}
for s in c.subtypes {
rconn
.execute(
"INSERT INTO card_subtypes(oracle_id,subtype) VALUES (?1,?2)",
rusqlite::params![c.oracle_id, s],
)
.unwrap();
}
for k in c.keywords {
rconn
.execute(
"INSERT INTO card_keywords(oracle_id,keyword) VALUES (?1,?2)",
rusqlite::params![c.oracle_id, k],
)
.unwrap();
}
// One printing per card, carrying an image URL.
rconn
.execute(
"INSERT INTO printings(scryfall_id,oracle_id,image_uri)
VALUES (?1,?2,?3)",
rusqlite::params![
format!("print-{}", c.oracle_id),
c.oracle_id,
format!("http://img/{}", c.oracle_id)
],
)
.unwrap();
}
}
let catalog = Catalog::open(&lib_path, &ref_path).unwrap();
Fixture { catalog, _dir: dir }
}
}

View File

@@ -0,0 +1,192 @@
//! Ownership operations: what cards the user has and how many.
use std::collections::HashMap;
use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, LibraryCard};
use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri)` into its library parts. Shared
/// by [`Catalog::list_library`] and the criteria search.
pub(crate) fn row_to_library_parts(
r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> {
let oracle_id: String = r.get(0)?;
Ok((
oracle_id.clone(),
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
CardRef {
oracle_id,
name: r.get(3)?,
mana_value: r.get(4)?,
colors: ColorSet(r.get::<_, i64>(5)? as u8),
color_identity: ColorSet(r.get::<_, i64>(6)? as u8),
type_line: r.get(7)?,
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
},
))
}
impl Catalog {
/// Add `delta` copies of a card to the library (increment, creating the row
/// if needed). `delta` may be negative to decrement; the quantity floors at 0.
/// Returns the new owned quantity.
pub fn add_owned(&self, oracle_id: &str, delta: i64) -> Result<i64> {
let current: i64 = self
.conn
.query_row(
"SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1",
params![oracle_id],
|r| r.get(0),
)
.optional()?
.unwrap_or(0);
self.set_owned(oracle_id, current + delta)
}
/// Set the absolute owned quantity for a card (0 removes ownership but keeps
/// any deck assignments referencing it).
pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result<i64> {
let q = quantity.max(0);
self.conn.execute(
"INSERT INTO library_cards(oracle_id, quantity_owned) VALUES (?1, ?2)
ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned",
params![oracle_id, q],
)?;
Ok(q)
}
/// List every owned card (quantity_owned > 0) with deck context, ordered by
/// name. This is the "browse my library" view without extra criteria.
pub fn list_library(&self) -> Result<Vec<LibraryCard>> {
let deck_names = self.deck_names_by_oracle()?;
let assigned = self.assigned_totals_by_oracle()?;
let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery()
))?;
let rows = stmt
.query_map([], row_to_library_parts)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows
.into_iter()
.map(|(oid, qty, location, card)| LibraryCard {
quantity_owned: qty,
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
location,
card,
})
.collect())
}
/// Set the free-text storage location for a card (creates the library row if
/// absent). Empty string clears it.
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
self.conn.execute(
"INSERT INTO library_cards(oracle_id, location) VALUES (?1, ?2)
ON CONFLICT(oracle_id) DO UPDATE SET location = excluded.location",
params![oracle_id, location],
)?;
Ok(())
}
/// Map each owned/assigned oracle_id to the deck names it appears in.
pub(crate) fn deck_names_by_oracle(&self) -> Result<HashMap<String, Vec<String>>> {
let mut stmt = self.conn.prepare(
"SELECT dc.oracle_id, d.name
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
ORDER BY d.name COLLATE NOCASE",
)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
for row in rows {
let (oid, name) = row?;
map.entry(oid).or_default().push(name);
}
Ok(map)
}
pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
let mut stmt = self
.conn
.prepare("SELECT oracle_id, SUM(quantity) FROM deck_cards GROUP BY oracle_id")?;
let mut map = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows {
let (oid, total) = row?;
map.insert(oid, total);
}
Ok(map)
}
}
#[cfg(test)]
mod tests {
use crate::test_support;
#[test]
fn add_increment_and_floor() {
let fx = test_support::make();
let cat = &fx.catalog;
assert_eq!(cat.add_owned("sol-ring", 1).unwrap(), 1);
assert_eq!(cat.add_owned("sol-ring", 2).unwrap(), 3);
assert_eq!(cat.add_owned("sol-ring", -10).unwrap(), 0); // floors at 0
assert_eq!(cat.set_owned("sol-ring", 4).unwrap(), 4);
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.quantity_owned, 4);
assert!(sol.decks.is_empty());
// Image URL is plumbed through from the printings table.
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
}
#[test]
fn location_is_stored_and_listed() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
cat.set_location("sol-ring", "Box 1").unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.location, "Box 1");
// Clearing works.
cat.set_location("sol-ring", "").unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.location, "");
}
#[test]
fn list_is_ordered_and_excludes_zero() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("serra-angel", 1).unwrap();
cat.add_owned("lightning-bolt", 1).unwrap();
cat.set_owned("sol-ring", 0).unwrap();
let names: Vec<_> = cat
.list_library()
.unwrap()
.into_iter()
.map(|c| c.card.name)
.collect();
assert_eq!(names, vec!["Lightning Bolt", "Serra Angel"]);
}
}

View File

@@ -0,0 +1,165 @@
//! Shared domain types used across the core.
use serde::{Deserialize, Serialize};
/// A card's color identity / colors, encoded as a 5-bit WUBRG mask.
///
/// Bit layout (little end first): W=1, U=2, B=4, R=8, G=16.
/// Storing colors this way turns commander color-identity compatibility into a
/// single integer predicate (see [`ColorSet::is_subset_of`]).
pub const W: u8 = 1 << 0;
pub const U: u8 = 1 << 1;
pub const B: u8 = 1 << 2;
pub const R: u8 = 1 << 3;
pub const G: u8 = 1 << 4;
/// The five colors in canonical WUBRG order, paired with their single-letter code.
pub const WUBRG: [(u8, char); 5] = [(W, 'W'), (U, 'U'), (B, 'B'), (R, 'R'), (G, 'G')];
/// A set of colors as a WUBRG bitmask.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ColorSet(pub u8);
impl ColorSet {
pub const COLORLESS: ColorSet = ColorSet(0);
/// Build from an iterator of single-letter color codes (e.g. Scryfall's
/// `["B", "G"]`). Unknown letters are ignored.
pub fn from_letters<I, S>(letters: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut mask = 0u8;
for l in letters {
match l.as_ref() {
"W" | "w" => mask |= W,
"U" | "u" => mask |= U,
"B" | "b" => mask |= B,
"R" | "r" => mask |= R,
"G" | "g" => mask |= G,
_ => {}
}
}
ColorSet(mask)
}
/// Parse from a compact string like "BG" or "wubrg". Order-insensitive.
pub fn parse(s: &str) -> Self {
Self::from_letters(s.chars().map(|c| c.to_string()))
}
/// Canonical WUBRG string, e.g. `ColorSet(B|G)` -> "BG". Colorless -> "".
pub fn to_letters(self) -> String {
WUBRG
.iter()
.filter(|(bit, _)| self.0 & bit != 0)
.map(|(_, ch)| *ch)
.collect()
}
pub fn is_colorless(self) -> bool {
self.0 == 0
}
pub fn contains(self, bit: u8) -> bool {
self.0 & bit != 0
}
/// True if every color in `self` is also in `other`.
///
/// This is the commander-legality rule: a card is playable in a commander of
/// color identity `C` iff `card_identity.is_subset_of(C)`.
pub fn is_subset_of(self, other: ColorSet) -> bool {
self.0 & !other.0 == 0
}
}
/// A card as returned by search / library queries. Oracle-level (one per
/// `oracle_id`), enriched with the caller's ownership + deck context where relevant.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardRef {
pub oracle_id: String,
pub name: String,
pub mana_value: f64,
pub colors: ColorSet,
pub color_identity: ColorSet,
pub type_line: String,
/// URL of a card image (Scryfall "normal" size) for one of its printings,
/// or empty if none is available.
pub image_uri: String,
}
/// A library row: a card the user owns, with quantity and its deck memberships.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LibraryCard {
#[serde(flatten)]
pub card: CardRef,
pub quantity_owned: i64,
/// Names of decks this card is currently assigned to.
pub decks: Vec<String>,
/// Total copies assigned across all decks. If it exceeds `quantity_owned`,
/// the physical copies are over-committed.
pub quantity_assigned: i64,
/// Free-text physical storage location (e.g. "Box 1"). Meaningful mainly for
/// cards not currently in a deck.
pub location: String,
}
/// A card in a deck, whether or not the user owns it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeckEntry {
#[serde(flatten)]
pub card: CardRef,
/// Copies of this card in the deck.
pub quantity: i64,
pub is_commander: bool,
/// Copies owned in the library (0 if the card isn't in the library).
pub quantity_owned: i64,
}
impl LibraryCard {
pub fn is_over_assigned(&self) -> bool {
self.quantity_assigned > self.quantity_owned
}
}
/// A deck.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Deck {
pub id: i64,
pub name: String,
/// Free-text format, e.g. "Commander", "Modern" (empty if unset).
pub format: String,
/// The commander card's oracle id (meaningful for Commander/Brawl formats).
pub commander_oracle_id: Option<String>,
pub notes: String,
}
/// Criteria for searching the owned library. All fields are optional; an empty
/// filter matches every owned card.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LibraryFilter {
/// Fuzzy-ish name text (applied as a case-insensitive substring here; the
/// fast fuzzy path is [`crate::search`]).
pub text: Option<String>,
/// Cards whose `colors` exactly equal this set.
pub colors_exact: Option<ColorSet>,
/// Cards whose `colors` include at least these.
pub colors_include: Option<ColorSet>,
/// Commander compatibility: only cards whose color identity is a subset of
/// this set (playable under a commander of this identity).
pub commander_identity: Option<ColorSet>,
pub cmc_min: Option<f64>,
pub cmc_max: Option<f64>,
/// Card supertype/type, e.g. "Creature", "Instant" (matched against the
/// normalized `card_types` table).
pub types: Vec<String>,
/// Subtype, e.g. "Elf", "Equipment".
pub subtypes: Vec<String>,
/// Keyword ability, e.g. "Flying", "Deathtouch".
pub keywords: Vec<String>,
/// Restrict to cards assigned to this deck id.
pub in_deck: Option<i64>,
}

View 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());
}
}

View File

@@ -0,0 +1,41 @@
-- library.db v1 — the small, portable, synced file.
-- References reference cards by stable oracle_id / scryfall_id.
CREATE TABLE decks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
commander_oracle_id TEXT,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Oracle-level ownership: one row per distinct card.
CREATE TABLE library_cards (
id INTEGER PRIMARY KEY,
oracle_id TEXT NOT NULL UNIQUE,
quantity_owned INTEGER NOT NULL DEFAULT 0
);
-- Optional per-physical-copy printing detail (the "hybrid" identity).
CREATE TABLE library_card_copies (
id INTEGER PRIMARY KEY,
library_card_id INTEGER NOT NULL REFERENCES library_cards(id) ON DELETE CASCADE,
scryfall_id TEXT,
set_code TEXT,
collector_number TEXT,
finish TEXT,
condition TEXT
);
CREATE INDEX idx_copies_card ON library_card_copies(library_card_id);
-- Deck membership at the oracle level.
CREATE TABLE deck_cards (
id INTEGER PRIMARY KEY,
deck_id INTEGER NOT NULL REFERENCES decks(id) ON DELETE CASCADE,
oracle_id TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
is_commander INTEGER NOT NULL DEFAULT 0,
UNIQUE(deck_id, oracle_id)
);
CREATE INDEX idx_deck_cards_oid ON deck_cards(oracle_id);
CREATE INDEX idx_deck_cards_deck ON deck_cards(deck_id);

View File

@@ -0,0 +1,3 @@
-- v2 — physical storage location for owned cards (e.g. "Box 1", "Binder A").
-- Most relevant for cards not assigned to any deck.
ALTER TABLE library_cards ADD COLUMN location TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,3 @@
-- v3 — deck format metadata (e.g. "Commander", "Modern"). The commander card
-- itself is stored in the existing decks.commander_oracle_id column.
ALTER TABLE decks ADD COLUMN format TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,56 @@
-- Schema for reference.db: read-only card data built from Scryfall bulk.
-- Applied against a connection whose `main` database IS the reference db
-- (the ingester opens reference.db directly; at runtime it is ATTACHed as `ref`).
CREATE TABLE IF NOT EXISTS oracle_cards (
oracle_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_lower TEXT NOT NULL, -- for cheap case-insensitive substring
mana_value REAL NOT NULL DEFAULT 0,
colors INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
color_identity INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
type_line TEXT NOT NULL DEFAULT '',
oracle_text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
CREATE INDEX IF NOT EXISTS idx_oracle_mv ON oracle_cards(mana_value);
-- Normalized attribute tables for fast type/subtype/keyword filtering.
CREATE TABLE IF NOT EXISTS card_types (
oracle_id TEXT NOT NULL,
type TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_types_type ON card_types(type);
CREATE INDEX IF NOT EXISTS idx_card_types_oid ON card_types(oracle_id);
CREATE TABLE IF NOT EXISTS card_subtypes (
oracle_id TEXT NOT NULL,
subtype TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_subtypes_sub ON card_subtypes(subtype);
CREATE INDEX IF NOT EXISTS idx_card_subtypes_oid ON card_subtypes(oracle_id);
CREATE TABLE IF NOT EXISTS card_keywords (
oracle_id TEXT NOT NULL,
keyword TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_keywords_kw ON card_keywords(keyword);
CREATE INDEX IF NOT EXISTS idx_card_keywords_oid ON card_keywords(oracle_id);
-- One row per printing (default-language bulk).
CREATE TABLE IF NOT EXISTS printings (
scryfall_id TEXT PRIMARY KEY,
oracle_id TEXT NOT NULL,
set_code TEXT NOT NULL DEFAULT '',
collector_number TEXT NOT NULL DEFAULT '',
finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
rarity TEXT NOT NULL DEFAULT '',
image_uri TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);
-- Secondary trigram FTS index over names (typo-tolerant fallback; the primary
-- interactive search is the in-memory nucleo matcher).
CREATE VIRTUAL TABLE IF NOT EXISTS oracle_names_fts
USING fts5(name, oracle_id UNINDEXED, tokenize = 'trigram');