Add deck legality checking, deck/library import-export, and a command palette

Threads Scryfall's per-format legalities through the reference db and adds a
deck-page indicator (structural rules + banned/restricted lists) that lists
violation reasons inline. Decks and selected library cards can now be
exported to/imported from plain-text decklists, with best-effort name
resolution and a report of anything that didn't match. Also adds a shuffle-
and-draw sample hand modal, and an Alt+/ command palette for jumping to a
tab, deck, or card without leaving the keyboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:21:22 -05:00
parent cf9c930b02
commit 008ab33723
18 changed files with 1696 additions and 138 deletions

View File

@@ -0,0 +1,474 @@
//! Plain-text decklist import/export: parsing and formatting for decks
//! (with Commander/Deck/Maybeboard sections) and flat library card lists.
//!
//! Deck export format:
//! ```text
//! // mtgc deck export
//! // Format: Commander
//!
//! Commander
//! 1 Krenko, Mob Boss
//!
//! Deck
//! 1 Sol Ring
//! 4 Lightning Bolt
//!
//! Maybeboard
//! 1 Some Card
//! ```
//! `//` lines are comments (`// Format: X` is recognized specially to
//! restore the deck's format on import). Section headers are
//! case-insensitive; `Sideboard` is accepted as an input alias for
//! `Maybeboard` (compatibility with lists copied from other tools), but
//! export always emits `Maybeboard`. Cards before any header, or under
//! `Deck`, are mainboard. Quantities accept a bare integer (`4 Foo`) or an
//! `Nx`/`N×` prefix (`4x Foo`). Unrecognized lines are silently skipped,
//! never a hard error — see [`parse_deck_text`].
//!
//! Library export format is the same comment/quantity-name line convention
//! with no sections — see [`parse_library_text`].
use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{Deck, DeckEntry};
use crate::Catalog;
/// One `<quantity> <name>` line from a decklist, prior to name resolution.
#[derive(Debug, Clone, PartialEq)]
pub struct DecklistLine {
pub quantity: i64,
pub name: String,
}
/// The structured result of parsing deck-export text.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParsedDeckText {
/// From a `// Format: X` comment, if present.
pub format: Option<String>,
/// Commander section lines. Normally 0 or 1 — see
/// [`Catalog::import_deck`] for how extra lines are handled.
pub commander: Vec<DecklistLine>,
pub mainboard: Vec<DecklistLine>,
pub maybeboard: Vec<DecklistLine>,
}
#[derive(Clone, Copy, PartialEq)]
enum Section {
Mainboard,
Commander,
Maybeboard,
}
/// Parse deck-export text into structured lines. Lenient: blank lines are
/// skipped, `//` lines are comments, section headers are case-insensitive,
/// and lines that don't parse as `<qty> <name>` are silently skipped rather
/// than erroring the whole import.
pub fn parse_deck_text(text: &str) -> ParsedDeckText {
let mut parsed = ParsedDeckText::default();
let mut section = Section::Mainboard;
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("//") {
let rest = rest.trim();
if let Some(fmt) = rest
.strip_prefix("Format:")
.or_else(|| rest.strip_prefix("format:"))
{
parsed.format = Some(fmt.trim().to_string());
}
continue;
}
match line.to_lowercase().as_str() {
"commander" => {
section = Section::Commander;
continue;
}
"deck" => {
section = Section::Mainboard;
continue;
}
"maybeboard" | "sideboard" => {
section = Section::Maybeboard;
continue;
}
_ => {}
}
if let Some(card_line) = parse_card_line(line) {
match section {
Section::Mainboard => parsed.mainboard.push(card_line),
Section::Commander => parsed.commander.push(card_line),
Section::Maybeboard => parsed.maybeboard.push(card_line),
}
}
}
parsed
}
/// Parse a single `<qty> <name>` line. Accepts a bare integer quantity
/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`).
/// Returns `None` (skip, don't error) if the line doesn't start with a
/// recognizable positive quantity or has no name after it.
fn parse_card_line(line: &str) -> Option<DecklistLine> {
let (head, rest) = line.split_once(char::is_whitespace)?;
let head_lower = head.to_lowercase();
let qty_str = head_lower
.strip_suffix('x')
.or_else(|| head_lower.strip_suffix('×'))
.unwrap_or(&head_lower);
let quantity: i64 = qty_str.parse().ok()?;
if quantity <= 0 {
return None;
}
let name = rest.trim();
if name.is_empty() {
return None;
}
Some(DecklistLine {
quantity,
name: name.to_string(),
})
}
/// Parse library-export text into `<qty> <name>` lines (no sections). Same
/// leniency rules as [`parse_deck_text`].
pub fn parse_library_text(text: &str) -> Vec<DecklistLine> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
.filter_map(parse_card_line)
.collect()
}
/// Render a deck (metadata + contents) as export text. Empty sections (no
/// commander, no maybeboard entries) are omitted entirely. The
/// `// Format: X` comment is emitted only when `deck.format` is non-empty.
pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String {
let mut out = String::from("// mtgc deck export\n");
if !deck.format.is_empty() {
out.push_str(&format!("// Format: {}\n", deck.format));
}
let commander: Vec<_> = contents.iter().filter(|e| e.is_commander).collect();
let maybeboard: Vec<_> = contents.iter().filter(|e| e.is_maybeboard).collect();
let mainboard: Vec<_> = contents
.iter()
.filter(|e| !e.is_commander && !e.is_maybeboard)
.collect();
if !commander.is_empty() {
out.push_str("\nCommander\n");
for e in &commander {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
if !mainboard.is_empty() {
out.push_str("\nDeck\n");
for e in &mainboard {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
if !maybeboard.is_empty() {
out.push_str("\nMaybeboard\n");
for e in &maybeboard {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
out
}
/// Render `(name, quantity)` pairs as library-export text.
pub fn format_library_text(entries: &[(String, i64)]) -> String {
let mut out = String::from("// mtgc library export\n");
for (name, qty) in entries {
out.push_str(&format!("{qty} {name}\n"));
}
out
}
impl Catalog {
/// Case-insensitive exact-name lookup against the reference db's
/// precomputed `name_lower` column. No fuzzy matching — an unresolved
/// name is reported back to the caller rather than guessed at, so a
/// typo never silently imports the wrong card.
pub fn find_oracle_id_by_name(&self, name: &str) -> Result<Option<String>> {
Ok(self
.conn
.query_row(
&format!("SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 LIMIT 1"),
params![name.trim().to_lowercase()],
|r| r.get(0),
)
.optional()?)
}
/// Import a decklist as a brand-new deck named `name`. Always creates a
/// new deck — never merges into an existing one. Best-effort: card
/// names that don't resolve are skipped (not a hard failure) and
/// collected into the returned list, alongside the created `Deck`.
///
/// Only the first resolved Commander-section line becomes the actual
/// commander; any further Commander-section lines are folded into the
/// mainboard at their given quantity rather than dropped or erroring.
pub fn import_deck(&self, name: &str, text: &str) -> Result<(Deck, Vec<String>)> {
let parsed = parse_deck_text(text);
let deck = self.create_deck(name, None)?;
if let Some(fmt) = &parsed.format {
self.set_deck_format(deck.id, fmt)?;
}
let mut unresolved = Vec::new();
let mut commander_oracle_id: Option<String> = None;
for (i, line) in parsed.commander.iter().enumerate() {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => {
let is_first = i == 0;
self.set_deck_card(deck.id, &oid, line.quantity, is_first, false)?;
if is_first {
commander_oracle_id = Some(oid);
}
}
None => unresolved.push(line.name.clone()),
}
}
if let Some(oid) = &commander_oracle_id {
self.set_deck_commander(deck.id, Some(oid))?;
}
for line in &parsed.mainboard {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, false)?,
None => unresolved.push(line.name.clone()),
}
}
for line in &parsed.maybeboard {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, true)?,
None => unresolved.push(line.name.clone()),
}
}
let deck = self
.get_deck(deck.id)?
.expect("just-imported deck must exist");
Ok((deck, unresolved))
}
/// Render a deck's current contents as export text.
pub fn export_deck_text(&self, deck_id: i64) -> Result<String> {
let deck = self
.get_deck(deck_id)?
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {deck_id} not found")))?;
let contents = self.deck_contents(deck_id)?;
Ok(format_deck_text(&deck, &contents))
}
/// Import a plain-text library list, adding each resolved card's
/// quantity to existing ownership (additive via [`Catalog::add_owned`]).
/// Returns `(name, new_total_quantity_owned)` per applied line, plus
/// unresolved names.
pub fn import_library_list(&self, text: &str) -> Result<(Vec<(String, i64)>, Vec<String>)> {
let lines = parse_library_text(text);
let mut added = Vec::new();
let mut unresolved = Vec::new();
for line in &lines {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => {
let new_qty = self.add_owned(&oid, line.quantity)?;
added.push((line.name.clone(), new_qty));
}
None => unresolved.push(line.name.clone()),
}
}
Ok((added, unresolved))
}
/// Render the given owned cards (name + current quantity_owned) as
/// library-export text.
pub fn export_library_text(&self, oracle_ids: &[String]) -> Result<String> {
let mut entries = Vec::with_capacity(oracle_ids.len());
for oid in oracle_ids {
let row: Option<(String, i64)> = self
.conn
.query_row(
&format!(
"SELECT oc.name, lc.quantity_owned FROM {REF_DB}.oracle_cards oc
JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
WHERE oc.oracle_id = ?1"
),
params![oid],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some(entry) = row {
entries.push(entry);
}
}
entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
Ok(format_library_text(&entries))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support;
#[test]
fn parse_card_line_accepts_bare_and_x_prefixed_quantities() {
assert_eq!(
parse_card_line("4 Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
);
assert_eq!(
parse_card_line("4x Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
);
assert_eq!(
parse_card_line("4× Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
);
assert_eq!(parse_card_line("not a card line"), None);
assert_eq!(parse_card_line("0 Lightning Bolt"), None);
}
#[test]
fn parse_deck_text_tolerates_blanks_comments_and_sideboard_alias() {
let text = "// mtgc deck export\n// Format: Commander\n\nCommander\n1 Krenko, Mob Boss\n\nDeck\n1 Sol Ring\n\nSideboard\n1 Some Card\n";
let parsed = parse_deck_text(text);
assert_eq!(parsed.format.as_deref(), Some("Commander"));
assert_eq!(
parsed.commander,
vec![DecklistLine { quantity: 1, name: "Krenko, Mob Boss".into() }]
);
assert_eq!(parsed.mainboard, vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]);
assert_eq!(
parsed.maybeboard,
vec![DecklistLine { quantity: 1, name: "Some Card".into() }]
);
}
#[test]
fn parse_deck_text_defaults_unheaded_lines_to_mainboard() {
let parsed = parse_deck_text("4 Lightning Bolt\n1 Sol Ring\n");
assert_eq!(parsed.mainboard.len(), 2);
assert!(parsed.commander.is_empty());
assert!(parsed.maybeboard.is_empty());
}
#[test]
fn format_deck_text_omits_empty_sections() {
let deck = Deck {
id: 1,
name: "Test".into(),
format: String::new(),
commander_oracle_id: None,
notes: String::new(),
};
let text = format_deck_text(&deck, &[]);
assert_eq!(text, "// mtgc deck export\n");
}
#[test]
fn import_deck_resolves_names_sets_format_and_commander_and_reports_unresolved() {
let fx = test_support::make();
let cat = &fx.catalog;
let text = "// Format: Commander\n\nCommander\n1 Serra Angel\n\nDeck\n4 Lightning Bolt\n1 Totally Fake Card\n\nMaybeboard\n1 Sol Ring\n";
let (deck, unresolved) = cat.import_deck("Imported", text).unwrap();
assert_eq!(deck.format, "Commander");
assert_eq!(deck.commander_oracle_id.as_deref(), Some("serra-angel"));
assert_eq!(unresolved, vec!["Totally Fake Card".to_string()]);
let contents = cat.deck_contents(deck.id).unwrap();
let bolt = contents.iter().find(|e| e.card.oracle_id == "lightning-bolt").unwrap();
assert_eq!(bolt.quantity, 4);
assert!(!bolt.is_commander);
assert!(!bolt.is_maybeboard);
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(sol.is_maybeboard);
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
assert!(angel.is_commander);
}
#[test]
fn import_deck_extra_commander_lines_fold_into_mainboard() {
let fx = test_support::make();
let cat = &fx.catalog;
let text = "Commander\n1 Serra Angel\n1 Sol Ring\n";
let (deck, unresolved) = cat.import_deck("Two Commanders", text).unwrap();
assert!(unresolved.is_empty());
let contents = cat.deck_contents(deck.id).unwrap();
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
assert!(angel.is_commander);
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(!sol.is_commander);
assert!(!sol.is_maybeboard);
}
#[test]
fn export_deck_text_round_trips_through_parser() {
let fx = test_support::make();
let cat = &fx.catalog;
let deck = cat.create_deck("Round Trip", Some("serra-angel")).unwrap();
cat.set_deck_format(deck.id, "Commander").unwrap();
cat.set_deck_card(deck.id, "serra-angel", 1, true, false).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 4, false, false).unwrap();
cat.set_deck_card(deck.id, "sol-ring", 1, false, true).unwrap();
let text = cat.export_deck_text(deck.id).unwrap();
let parsed = parse_deck_text(&text);
assert_eq!(parsed.format.as_deref(), Some("Commander"));
assert_eq!(
parsed.commander,
vec![DecklistLine { quantity: 1, name: "Serra Angel".into() }]
);
assert_eq!(
parsed.mainboard,
vec![DecklistLine { quantity: 4, name: "Lightning Bolt".into() }]
);
assert_eq!(
parsed.maybeboard,
vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]
);
}
#[test]
fn import_library_list_adds_to_existing_ownership_and_reports_unresolved() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("lightning-bolt", 2).unwrap();
let text = "2 Lightning Bolt\n1 Sol Ring\n1 Not A Real Card\n";
let (added, unresolved) = cat.import_library_list(text).unwrap();
assert_eq!(unresolved, vec!["Not A Real Card".to_string()]);
let bolt_added = added.iter().find(|(n, _)| n == "Lightning Bolt").unwrap();
assert_eq!(bolt_added.1, 4); // 2 existing + 2 imported
let sol_added = added.iter().find(|(n, _)| n == "Sol Ring").unwrap();
assert_eq!(sol_added.1, 1);
}
#[test]
fn export_library_text_formats_only_requested_cards() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("lightning-bolt", 3).unwrap();
cat.add_owned("sol-ring", 1).unwrap();
let text = cat.export_library_text(&["lightning-bolt".to_string()]).unwrap();
assert!(text.contains("3 Lightning Bolt"));
assert!(!text.contains("Sol Ring"));
}
}

View File

@@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities};
use crate::Catalog;
impl Catalog {
@@ -114,8 +114,8 @@ impl Catalog {
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}, {back}, oc.produced_mana, oc.produces_colorless, {price},
dc.is_maybeboard
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
oc.legalities, {price}, dc.is_maybeboard
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
@@ -142,9 +142,10 @@ impl Catalog {
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
produces_colorless: r.get::<_, i64>(12)? != 0,
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
legalities: Legalities(r.get::<_, i64>(13)?),
price_usd: r.get::<_, Option<f64>>(14)?.unwrap_or_default(),
},
is_maybeboard: r.get::<_, i64>(14)? != 0,
is_maybeboard: r.get::<_, i64>(15)? != 0,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -236,6 +237,33 @@ mod tests {
assert!(!row.is_over_assigned());
}
#[test]
fn legalities_are_plumbed_through_deck_contents() {
use crate::model::LegalStatus;
let fx = test_support::make();
let cat = &fx.catalog;
// 2 (Legal) in the "commander" slot (bit offset 12, per LEGALITY_FORMATS).
cat.connection()
.execute(
"UPDATE ref.oracle_cards SET legalities = 4096 WHERE oracle_id = 'sol-ring'",
[],
)
.unwrap();
let d = cat.create_deck("Legality Check", None).unwrap();
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.card.legalities.status_for("Commander"), LegalStatus::Legal);
// Fixture cards default to no legality data at all.
let elves = contents.iter().find(|e| e.card.oracle_id == "llanowar-elves").unwrap();
assert_eq!(elves.card.legalities.status_for("Commander"), LegalStatus::NotLegal);
}
#[test]
fn over_assignment_detected_across_decks() {
let fx = test_support::make();

View File

@@ -19,7 +19,7 @@ impl Catalog {
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}, {back},
oc.produced_mana, oc.produces_colorless, {price}
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0",

View File

@@ -19,7 +19,7 @@ use serde::Deserialize;
use crate::db::open_reference_for_build;
use crate::error::{CoreError, Result};
use crate::model::ColorSet;
use crate::model::{ColorSet, Legalities};
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
@@ -434,6 +434,10 @@ struct ScryOracle {
/// top-level-only Scryfall field — not per-face, even on DFCs.
#[serde(default)]
produced_mana: Option<Vec<String>>,
/// Format-slug -> status-string (e.g. "legal"/"not_legal"/"banned"/
/// "restricted"). See [`crate::model::Legalities`].
#[serde(default)]
legalities: std::collections::HashMap<String, String>,
}
#[derive(Deserialize)]
@@ -453,8 +457,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
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,
produced_mana,produces_colorless)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
produced_mana,produces_colorless,legalities)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
)?;
let mut ins_fts =
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
@@ -533,6 +537,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
let produced_mana = ColorSet::from_letters(produced);
let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C"));
let legalities = Legalities::from_scryfall(&c.legalities);
let changed = ins_card.execute(params![
oracle_id,
c.name,
@@ -544,6 +550,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
oracle_text,
produced_mana.0 as i64,
produces_colorless as i64,
legalities.0,
])?;
// Only emit satellite rows the first time we see this oracle_id.
if changed == 1 {
@@ -922,6 +929,48 @@ mod tests {
assert_eq!(vanilla_colorless, 0); // no produced_mana field at all
}
#[test]
fn captures_legalities() {
let oracle = r#"[
{"oracle_id":"oid-banned","name":"Balance","cmc":1.0,"color_identity":["W"],"keywords":[],
"legalities":{"standard":"not_legal","modern":"banned","legacy":"banned","vintage":"restricted","commander":"banned"}},
{"oracle_id":"oid-legal","name":"Lightning Bolt","cmc":1.0,"color_identity":["R"],"keywords":[],
"legalities":{"modern":"legal","legacy":"legal","vintage":"legal","pauper":"legal"}},
{"oracle_id":"oid-nolegal","name":"Some Test Card","cmc":1.0,"color_identity":[],"keywords":[]}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
let fetch = |id: &str| -> Legalities {
Legalities(
conn.query_row(
"SELECT legalities FROM oracle_cards WHERE oracle_id=?1",
params![id],
|r| r.get(0),
)
.unwrap(),
)
};
let balance = fetch("oid-banned");
assert_eq!(balance.status_for("Vintage"), crate::model::LegalStatus::Restricted);
assert_eq!(balance.status_for("Modern"), crate::model::LegalStatus::Banned);
assert_eq!(balance.status_for("Standard"), crate::model::LegalStatus::NotLegal);
let bolt = fetch("oid-legal");
assert_eq!(bolt.status_for("Modern"), crate::model::LegalStatus::Legal);
assert_eq!(bolt.status_for("Standard"), crate::model::LegalStatus::NotLegal); // absent key
let vanilla = fetch("oid-nolegal");
for (_, label) in crate::model::LEGALITY_FORMATS {
assert_eq!(vanilla.status_for(label), crate::model::LegalStatus::NotLegal); // no legalities object at all
}
}
#[test]
fn captures_back_face_image_for_double_faced_cards() {
// Transform/MDFC cards: two `card_faces`, each with its own image.

View File

@@ -6,6 +6,7 @@
//! unit-testable and reusable when the app is ported to mobile.
pub mod db;
pub mod decklist;
pub mod decks;
pub mod error;
pub mod filters;
@@ -20,7 +21,8 @@ use rusqlite::Connection;
pub use error::{CoreError, Result};
pub use model::{
CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats,
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryFacets,
LibraryFilter, LibraryStats,
};
pub use search::NameIndex;

View File

@@ -6,13 +6,13 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
use crate::model::{CardRef, ColorSet, Legalities, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
/// produces_colorless, price_usd)` into its library parts. Shared by
/// [`Catalog::list_library`] and the criteria search.
/// produces_colorless, legalities, price_usd)` 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)> {
@@ -32,7 +32,8 @@ pub(crate) fn row_to_library_parts(
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
produces_colorless: r.get::<_, i64>(11)? != 0,
price_usd: r.get::<_, Option<f64>>(12)?.unwrap_or_default(),
legalities: Legalities(r.get::<_, i64>(12)?),
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
},
))
}
@@ -75,7 +76,7 @@ impl Catalog {
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}, {back},
oc.produced_mana, oc.produces_colorless, {price}
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0

View File

@@ -75,6 +75,73 @@ impl ColorSet {
}
}
/// The 8 formats this app tracks legality for, in a fixed order that defines
/// their bit position within [`Legalities`] (2 bits each, in order). This
/// order must never change once shipped — it's baked into every ingested
/// reference.db. `.0` is the Scryfall `legalities` JSON key; `.1` is the
/// display label used throughout the app (matches DeckView's
/// `FORMATS`/`COMMANDER_FORMATS`).
pub const LEGALITY_FORMATS: [(&str, &str); 8] = [
("standard", "Standard"),
("pioneer", "Pioneer"),
("modern", "Modern"),
("legacy", "Legacy"),
("vintage", "Vintage"),
("pauper", "Pauper"),
("commander", "Commander"),
("brawl", "Brawl"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegalStatus {
NotLegal,
Legal,
Banned,
Restricted,
}
/// Per-format legality, encoded as 2 bits/format x 8 formats packed into one
/// i64 (see [`LEGALITY_FORMATS`] for bit-position order). Mirrors
/// `ColorSet`'s bitmask-newtype pattern but with 2-bit fields instead of
/// 1-bit flags, since each format has 4 possible statuses (not-legal / legal
/// / banned / restricted), not just a binary flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Legalities(pub i64);
impl Legalities {
/// Build from Scryfall's `legalities` object (format-slug -> status-string
/// map). Any format not present in `map`, or present with an unrecognized
/// status string, decodes as `NotLegal`.
pub fn from_scryfall(map: &std::collections::HashMap<String, String>) -> Self {
let mut bits = 0i64;
for (i, (key, _label)) in LEGALITY_FORMATS.iter().enumerate() {
let status: i64 = match map.get(*key).map(String::as_str) {
Some("legal") => 1,
Some("banned") => 2,
Some("restricted") => 3,
_ => 0, // "not_legal", or key missing entirely
};
bits |= status << (i * 2);
}
Legalities(bits)
}
/// Decode the status for a given format's display label (e.g.
/// "Commander", "Modern" — matches DeckView's `FORMATS` strings).
/// Unknown labels decode as `NotLegal`.
pub fn status_for(self, format_label: &str) -> LegalStatus {
let Some(i) = LEGALITY_FORMATS.iter().position(|(_, label)| *label == format_label) else {
return LegalStatus::NotLegal;
};
match (self.0 >> (i * 2)) & 0b11 {
1 => LegalStatus::Legal,
2 => LegalStatus::Banned,
3 => LegalStatus::Restricted,
_ => LegalStatus::NotLegal,
}
}
}
/// 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)]
@@ -102,6 +169,10 @@ pub struct CardRef {
/// USD market price (Scryfall's `prices.usd`) for the same printing that
/// supplied `image_uri`, or 0 if unknown.
pub price_usd: f64,
/// Per-format legality (banned/restricted/legal). See
/// [`LEGALITY_FORMATS`] for the bit-position order and
/// [`Legalities::status_for`] to decode a specific format.
pub legalities: Legalities,
}
/// A library row: a card the user owns, with quantity and its deck memberships.
@@ -212,3 +283,38 @@ pub struct LibraryFilter {
/// Case-insensitive substring match against the card's oracle rules text.
pub oracle_text: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn legalities_round_trip_all_statuses() {
let mut map = HashMap::new();
map.insert("standard".to_string(), "legal".to_string());
map.insert("modern".to_string(), "banned".to_string());
map.insert("vintage".to_string(), "restricted".to_string());
// "pauper" deliberately absent
let legalities = Legalities::from_scryfall(&map);
assert_eq!(legalities.status_for("Standard"), LegalStatus::Legal);
assert_eq!(legalities.status_for("Modern"), LegalStatus::Banned);
assert_eq!(legalities.status_for("Vintage"), LegalStatus::Restricted);
assert_eq!(legalities.status_for("Pauper"), LegalStatus::NotLegal);
}
#[test]
fn legalities_unknown_format_label_is_not_legal() {
let legalities = Legalities::from_scryfall(&HashMap::new());
assert_eq!(legalities.status_for("Nonsense"), LegalStatus::NotLegal);
}
#[test]
fn legalities_default_is_all_not_legal() {
let legalities = Legalities::default();
assert_eq!(legalities.status_for("Commander"), LegalStatus::NotLegal);
assert_eq!(legalities.status_for("Brawl"), LegalStatus::NotLegal);
}
}

View File

@@ -10,7 +10,7 @@ use rusqlite::Connection;
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet};
use crate::model::{CardRef, ColorSet, Legalities};
/// 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.
@@ -34,7 +34,8 @@ impl NameIndex {
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}, {back}, oc.produced_mana, oc.produces_colorless, {price}
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
oc.legalities, {price}
FROM {REF_DB}.oracle_cards oc",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri"),
@@ -54,7 +55,8 @@ impl NameIndex {
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
produces_colorless: r.get::<_, i64>(9)? != 0,
price_usd: r.get::<_, Option<f64>>(10)?.unwrap_or_default(),
legalities: Legalities(r.get::<_, i64>(10)?),
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
},
})
})?

View File

@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
type_line TEXT NOT NULL DEFAULT '',
oracle_text TEXT NOT NULL DEFAULT '',
produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks)
produces_colorless INTEGER NOT NULL DEFAULT 0 -- boolean: can produce {C}
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
legalities INTEGER NOT NULL DEFAULT 0 -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
);
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);